Skip to main content

htmlp/
rendered.rs

1use crate::{Bindings, Diagnostic, Document, Node, Position, Report, TokenCounter};
2use std::{collections::BTreeMap, ops::Range};
3
4/// A tokenizer-specific count of a complete rendered document, excluding
5/// provider message framing. Distinct from a runtime request estimate.
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[cfg_attr(feature = "json", derive(serde::Serialize))]
8pub struct TextMeasurement {
9    pub tokenizer: String,
10    pub tokens: u64,
11}
12
13/// A containing section, including unnamed sections and their source position.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[cfg_attr(feature = "json", derive(serde::Serialize))]
16pub struct SectionOrigin {
17    pub id: Option<String>,
18    pub position: Position,
19}
20
21/// Non-overlapping UTF-8 byte span in the final text. Section ancestry preserves
22/// nesting without counting a parent's text a second time. Variables remain
23/// literal values and inherit their containing section's attribution.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[cfg_attr(feature = "json", derive(serde::Serialize))]
26pub struct RenderedSpan {
27    pub range: Range<usize>,
28    pub sections: Vec<SectionOrigin>,
29    pub variable: Option<String>,
30}
31
32/// Immutable output of a complete successful validation. No constructor or
33/// deserializer can fabricate checked output. Extracted text is ordinary text;
34/// modifying it does not preserve this object's validation guarantee.
35#[derive(Debug, Clone)]
36pub struct RenderedPrompt {
37    text: String,
38    measurement: TextMeasurement,
39    report: Report,
40    spans: Vec<RenderedSpan>,
41    sections: BTreeMap<String, Range<usize>>,
42}
43impl RenderedPrompt {
44    pub fn text(&self) -> &str {
45        &self.text
46    }
47    pub fn measurement(&self) -> &TextMeasurement {
48        &self.measurement
49    }
50    pub fn report(&self) -> &Report {
51        &self.report
52    }
53    pub fn spans(&self) -> &[RenderedSpan] {
54        &self.spans
55    }
56    /// Select from an already checked whole document. Ancestor and sibling
57    /// budgets were validated before any section became available.
58    pub fn section(&self, id: &str) -> Option<&str> {
59        self.sections.get(id).map(|range| &self.text[range.clone()])
60    }
61    pub fn into_text(self) -> String {
62        self.text
63    }
64}
65
66/// Bind and validate the entire document, returning immutable text, exact
67/// tokenizer measurement, budget report, and non-overlapping section provenance.
68pub fn render_checked(
69    doc: &Document,
70    bindings: &Bindings,
71    counter: &impl TokenCounter,
72) -> Result<RenderedPrompt, Vec<Diagnostic>> {
73    let (text, report) = crate::check::render_with_report(doc, bindings, counter)?;
74    let mut output = RenderedPrompt {
75        measurement: TextMeasurement {
76            tokenizer: counter.name().into(),
77            tokens: report
78                .measurements
79                .last()
80                .and_then(|m| m.tokens)
81                .expect("render counts root"),
82        },
83        text,
84        report,
85        spans: Vec::new(),
86        sections: BTreeMap::new(),
87    };
88    fn walk(
89        nodes: &[Node],
90        bindings: &Bindings,
91        ancestry: &mut Vec<SectionOrigin>,
92        offset: &mut usize,
93        out: &mut RenderedPrompt,
94    ) {
95        for node in nodes {
96            let (length, variable) = match node {
97                Node::Section(section) => {
98                    let start = *offset;
99                    ancestry.push(SectionOrigin {
100                        id: section.id.clone(),
101                        position: section.position,
102                    });
103                    walk(&section.children, bindings, ancestry, offset, out);
104                    ancestry.pop();
105                    if let Some(id) = &section.id {
106                        out.sections.insert(id.clone(), start..*offset);
107                    }
108                    continue;
109                }
110                Node::Text { value } => (value.len(), None),
111                Node::Variable(variable) => {
112                    (bindings[&variable.id].len(), Some(variable.id.clone()))
113                }
114            };
115            if length > 0 {
116                out.spans.push(RenderedSpan {
117                    range: *offset..*offset + length,
118                    sections: ancestry.clone(),
119                    variable,
120                });
121            }
122            *offset += length;
123        }
124    }
125    walk(
126        &doc.children,
127        bindings,
128        &mut Vec::new(),
129        &mut 0,
130        &mut output,
131    );
132    Ok(output)
133}