use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::canon::CanonicalEncoder;
use crate::error::{Result, SdkError};
use super::vars::{ListStyle, VarValue};
pub const PROMPT_DIGEST_TAG: &[u8] = b"perspt-prompt-v1";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PromptSectionId(pub String);
impl std::fmt::Display for PromptSectionId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct PromptSectionVersion(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptMessageRole {
System,
User,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VarSpec {
pub name: String,
pub declared_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<ListStyle>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionSchema {
pub id: PromptSectionId,
pub version: PromptSectionVersion,
pub role: PromptMessageRole,
pub required: bool,
pub priority: u16,
pub max_bytes: usize,
pub vars: Vec<VarSpec>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionTemplate {
pub schema: SectionSchema,
pub body: String,
pub content_hash: String,
}
impl SectionTemplate {
pub fn hash_body(body: &str) -> String {
let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
encoder.text("section-body").text(body);
encoder.digest()
}
pub fn render(&self, values: &BTreeMap<String, VarValue>) -> Result<RenderedSection> {
let mut lines = Vec::new();
for line in self.body.lines() {
match render_line(line, &self.schema, values)? {
Some(rendered) => lines.push(rendered),
None => continue,
}
}
let content = lines.join("\n");
if content.len() > self.schema.max_bytes {
return Err(SdkError::Domain(format!(
"section {} rendered to {} bytes over its {}-byte cap",
self.schema.id,
content.len(),
self.schema.max_bytes
)));
}
Ok(RenderedSection {
id: self.schema.id.clone(),
version: self.schema.version,
role: self.schema.role,
required: self.schema.required,
priority: self.schema.priority,
content_hash: self.content_hash.clone(),
content,
})
}
}
fn render_line(
line: &str,
schema: &SectionSchema,
values: &BTreeMap<String, VarValue>,
) -> Result<Option<String>> {
let mut rendered = String::new();
let mut rest = line;
while let Some(open) = rest.find("{{") {
let Some(close) = rest[open..].find("}}") else {
return Err(SdkError::Domain(format!(
"unterminated placeholder in line {line:?}"
)));
};
let name = rest[open + 2..open + close].trim();
let spec = schema
.vars
.iter()
.find(|spec| spec.name == name)
.ok_or_else(|| SdkError::Domain(format!("undeclared placeholder {{{{{name}}}}}")))?;
let value = values
.get(name)
.ok_or_else(|| SdkError::Domain(format!("no value supplied for {{{{{name}}}}}")))?;
if value.omits_line() {
return Ok(None);
}
rendered.push_str(&rest[..open]);
rendered.push_str(&value.render(spec.style)?);
rest = &rest[open + close + 2..];
}
rendered.push_str(rest);
Ok(Some(rendered))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RenderedSection {
pub id: PromptSectionId,
pub version: PromptSectionVersion,
pub role: PromptMessageRole,
pub required: bool,
pub priority: u16,
pub content_hash: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OverrideOrigin {
Base,
Family(String),
ExactModel(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionProvenance {
pub id: PromptSectionId,
pub version: PromptSectionVersion,
pub content_hash: String,
pub origin: OverrideOrigin,
}
pub trait PromptSection {
const ID: &'static str;
const VERSION: u32;
const CONTENT_HASH: &'static str;
const REQUIRED: bool;
const PRIORITY: u16;
fn render(&self) -> Result<RenderedSection>;
}
#[cfg(test)]
mod tests {
use super::*;
fn template() -> SectionTemplate {
let body = "Use only these tools:\n{{tool_names}}\n{{budget_note}}\nEnd.".to_string();
SectionTemplate {
schema: SectionSchema {
id: PromptSectionId("branch_correct/tool_protocol".into()),
version: PromptSectionVersion(3),
role: PromptMessageRole::System,
required: true,
priority: 90,
max_bytes: 4096,
vars: vec![
VarSpec {
name: "tool_names".into(),
declared_type: "BoundedList<64,128>".into(),
style: Some(ListStyle::BulletList),
},
VarSpec {
name: "budget_note".into(),
declared_type: "Option<BoundedText<512>>".into(),
style: None,
},
],
},
content_hash: SectionTemplate::hash_body(body.as_str()),
body,
}
}
#[test]
fn substitution_and_line_omission_are_deterministic() {
let template = template();
let mut values = BTreeMap::new();
values.insert(
"tool_names".to_string(),
VarValue::List(vec!["read_file".into(), "edit_file".into()]),
);
values.insert("budget_note".to_string(), VarValue::Absent);
let rendered = template.render(&values).unwrap();
assert_eq!(
rendered.content,
"Use only these tools:\n- read_file\n- edit_file\nEnd."
);
assert_eq!(rendered.content, template.render(&values).unwrap().content);
}
#[test]
fn undeclared_and_missing_placeholders_fail_closed() {
let mut template = template();
template.body = "{{ghost}}".into();
assert!(template.render(&BTreeMap::new()).is_err());
let template = self::tests::template();
assert!(template.render(&BTreeMap::new()).is_err());
}
#[test]
fn oversize_rendering_fails_closed() {
let mut template = template();
template.schema.max_bytes = 8;
let mut values = BTreeMap::new();
values.insert(
"tool_names".to_string(),
VarValue::List(vec!["read_file".into()]),
);
values.insert("budget_note".to_string(), VarValue::Absent);
assert!(template.render(&values).is_err());
}
}