Skip to main content

systemprompt_provider_contracts/documents/
projection.rs

1//! The addressable node tree a codec projects a document into, and the
2//! hashline rendering agents read and edit against.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::collections::BTreeMap;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use systemprompt_identifiers::{ContentHash, DocumentNodeId};
13
14use super::format::DocumentFormat;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum NodeKind {
19    Body,
20    Heading { level: u8 },
21    Paragraph,
22    ListItem,
23    Table,
24    Row,
25    Cell,
26    Sheet,
27    Range,
28    Slide,
29    Shape,
30    Image,
31    Html,
32    Markdown,
33    Text,
34    Pending,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38pub struct ProjectionNode {
39    pub id: DocumentNodeId,
40    pub kind: NodeKind,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub text: Option<String>,
43    pub hash8: String,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub children: Vec<Self>,
46    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47    pub attrs: BTreeMap<String, String>,
48}
49
50/// A codec's view of one document revision.
51///
52/// `content_sha256` is the digest of the bytes the projection was read from;
53/// an edit applied against a projection whose digest no longer matches the
54/// stored head is applied to stale bytes and must be rejected by the caller.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
56pub struct Projection {
57    pub format: DocumentFormat,
58    pub root: Vec<ProjectionNode>,
59    pub content_sha256: ContentHash,
60}
61
62#[must_use]
63pub fn hash8(text: &str) -> String {
64    let normalised = text.split_whitespace().collect::<Vec<_>>().join(" ");
65    let digest = Sha256::digest(normalised.as_bytes());
66    hex::encode(&digest[..4])
67}
68
69#[must_use]
70pub fn content_hash(bytes: &[u8]) -> ContentHash {
71    ContentHash::new(hex::encode(Sha256::digest(bytes)))
72}
73
74impl ProjectionNode {
75    #[must_use]
76    pub fn hashline(&self) -> String {
77        format!(
78            "{}:{}| {}",
79            self.id.as_str(),
80            self.hash8,
81            self.text.as_deref().unwrap_or_default()
82        )
83    }
84}
85
86impl Projection {
87    #[must_use]
88    pub fn find(&self, id: &DocumentNodeId) -> Option<&ProjectionNode> {
89        self.nodes().find(|node| &node.id == id)
90    }
91
92    #[must_use]
93    pub fn lines(&self, range: Option<(&DocumentNodeId, &DocumentNodeId)>) -> Vec<String> {
94        let nodes = self.nodes();
95        match range {
96            None => nodes.map(ProjectionNode::hashline).collect(),
97            Some((start, end)) => {
98                let mut lines = Vec::new();
99                let mut inside = false;
100                for node in nodes {
101                    inside |= &node.id == start;
102                    if inside {
103                        lines.push(node.hashline());
104                        if &node.id == end {
105                            break;
106                        }
107                    }
108                }
109                lines
110            },
111        }
112    }
113
114    pub fn nodes(&self) -> impl Iterator<Item = &ProjectionNode> {
115        let mut stack: Vec<&ProjectionNode> = self.root.iter().rev().collect();
116        std::iter::from_fn(move || {
117            let node = stack.pop()?;
118            stack.extend(node.children.iter().rev());
119            Some(node)
120        })
121    }
122}