1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::canon::CanonicalEncoder;
17use crate::error::{Result, SdkError};
18
19use super::vars::{ListStyle, VarValue};
20
21pub const PROMPT_DIGEST_TAG: &[u8] = b"perspt-prompt-v1";
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26pub struct PromptSectionId(pub String);
27
28impl std::fmt::Display for PromptSectionId {
29 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 formatter.write_str(&self.0)
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub struct PromptSectionVersion(pub u32);
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PromptMessageRole {
42 System,
43 User,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct VarSpec {
49 pub name: String,
51 pub declared_type: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub style: Option<ListStyle>,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct SectionSchema {
65 pub id: PromptSectionId,
66 pub version: PromptSectionVersion,
67 pub role: PromptMessageRole,
68 pub required: bool,
69 pub priority: u16,
71 pub max_bytes: usize,
72 pub vars: Vec<VarSpec>,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct SectionTemplate {
78 pub schema: SectionSchema,
79 pub body: String,
81 pub content_hash: String,
83}
84
85impl SectionTemplate {
86 pub fn hash_body(body: &str) -> String {
88 let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
89 encoder.text("section-body").text(body);
90 encoder.digest()
91 }
92
93 pub fn render(&self, values: &BTreeMap<String, VarValue>) -> Result<RenderedSection> {
98 let mut lines = Vec::new();
99 for line in self.body.lines() {
100 match render_line(line, &self.schema, values)? {
101 Some(rendered) => lines.push(rendered),
102 None => continue,
103 }
104 }
105 let content = lines.join("\n");
106 if content.len() > self.schema.max_bytes {
107 return Err(SdkError::Domain(format!(
108 "section {} rendered to {} bytes over its {}-byte cap",
109 self.schema.id,
110 content.len(),
111 self.schema.max_bytes
112 )));
113 }
114 Ok(RenderedSection {
115 id: self.schema.id.clone(),
116 version: self.schema.version,
117 role: self.schema.role,
118 required: self.schema.required,
119 priority: self.schema.priority,
120 content_hash: self.content_hash.clone(),
121 content,
122 })
123 }
124}
125
126fn render_line(
128 line: &str,
129 schema: &SectionSchema,
130 values: &BTreeMap<String, VarValue>,
131) -> Result<Option<String>> {
132 let mut rendered = String::new();
133 let mut rest = line;
134 while let Some(open) = rest.find("{{") {
135 let Some(close) = rest[open..].find("}}") else {
136 return Err(SdkError::Domain(format!(
137 "unterminated placeholder in line {line:?}"
138 )));
139 };
140 let name = rest[open + 2..open + close].trim();
141 let spec = schema
142 .vars
143 .iter()
144 .find(|spec| spec.name == name)
145 .ok_or_else(|| SdkError::Domain(format!("undeclared placeholder {{{{{name}}}}}")))?;
146 let value = values
147 .get(name)
148 .ok_or_else(|| SdkError::Domain(format!("no value supplied for {{{{{name}}}}}")))?;
149 if value.omits_line() {
150 return Ok(None);
151 }
152 rendered.push_str(&rest[..open]);
153 rendered.push_str(&value.render(spec.style)?);
154 rest = &rest[open + close + 2..];
155 }
156 rendered.push_str(rest);
157 Ok(Some(rendered))
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct RenderedSection {
163 pub id: PromptSectionId,
164 pub version: PromptSectionVersion,
165 pub role: PromptMessageRole,
166 pub required: bool,
167 pub priority: u16,
168 pub content_hash: String,
170 pub content: String,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum OverrideOrigin {
177 Base,
178 Family(String),
179 ExactModel(String),
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct SectionProvenance {
185 pub id: PromptSectionId,
186 pub version: PromptSectionVersion,
187 pub content_hash: String,
188 pub origin: OverrideOrigin,
189}
190
191pub trait PromptSection {
194 const ID: &'static str;
195 const VERSION: u32;
196 const CONTENT_HASH: &'static str;
197 const REQUIRED: bool;
198 const PRIORITY: u16;
199
200 fn render(&self) -> Result<RenderedSection>;
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn template() -> SectionTemplate {
209 let body = "Use only these tools:\n{{tool_names}}\n{{budget_note}}\nEnd.".to_string();
210 SectionTemplate {
211 schema: SectionSchema {
212 id: PromptSectionId("branch_correct/tool_protocol".into()),
213 version: PromptSectionVersion(3),
214 role: PromptMessageRole::System,
215 required: true,
216 priority: 90,
217 max_bytes: 4096,
218 vars: vec![
219 VarSpec {
220 name: "tool_names".into(),
221 declared_type: "BoundedList<64,128>".into(),
222 style: Some(ListStyle::BulletList),
223 },
224 VarSpec {
225 name: "budget_note".into(),
226 declared_type: "Option<BoundedText<512>>".into(),
227 style: None,
228 },
229 ],
230 },
231 content_hash: SectionTemplate::hash_body(body.as_str()),
232 body,
233 }
234 }
235
236 #[test]
237 fn substitution_and_line_omission_are_deterministic() {
238 let template = template();
239 let mut values = BTreeMap::new();
240 values.insert(
241 "tool_names".to_string(),
242 VarValue::List(vec!["read_file".into(), "edit_file".into()]),
243 );
244 values.insert("budget_note".to_string(), VarValue::Absent);
245 let rendered = template.render(&values).unwrap();
246 assert_eq!(
247 rendered.content,
248 "Use only these tools:\n- read_file\n- edit_file\nEnd."
249 );
250 assert_eq!(rendered.content, template.render(&values).unwrap().content);
251 }
252
253 #[test]
254 fn undeclared_and_missing_placeholders_fail_closed() {
255 let mut template = template();
256 template.body = "{{ghost}}".into();
257 assert!(template.render(&BTreeMap::new()).is_err());
258 let template = self::tests::template();
259 assert!(template.render(&BTreeMap::new()).is_err());
260 }
261
262 #[test]
263 fn oversize_rendering_fails_closed() {
264 let mut template = template();
265 template.schema.max_bytes = 8;
266 let mut values = BTreeMap::new();
267 values.insert(
268 "tool_names".to_string(),
269 VarValue::List(vec!["read_file".into()]),
270 );
271 values.insert("budget_note".to_string(), VarValue::Absent);
272 assert!(template.render(&values).is_err());
273 }
274}