Skip to main content

systemprompt_provider_contracts/documents/
rich_op.rs

1//! The rich-text and table operations, applied to a text document through
2//! its `RichText` model rather than by a codec splicing native markup.
3//!
4//! Each op names one block of the projection the caller read: by id with the
5//! `expected_hash` it read, or by quote. `set_marks` adds or removes inline
6//! marks over the whole block or over the `nth` occurrence of `find` in it;
7//! `set_block_type` retypes a text block; the table ops address a table node
8//! (or any node inside it) and count rows and columns from zero, `at`
9//! defaulting to the end. `insert_table` and `insert_image` add a block
10//! after the anchor. A format whose model cannot hold the result refuses the
11//! op with an unsupported-op error.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use std::num::NonZeroUsize;
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use super::edit_op::{EditOp, NodeTarget};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24pub struct MarkSpec {
25    pub mark: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub value: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31pub struct SetMarks {
32    pub node: NodeTarget,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub expected_hash: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub find: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub nth: Option<NonZeroUsize>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub add: Vec<MarkSpec>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub remove: Vec<String>,
43}
44
45/// A text block's type, as `set_block_type` names it.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
47#[serde(tag = "type", rename_all = "snake_case")]
48pub enum TextBlockType {
49    Paragraph,
50    Heading {
51        level: u8,
52    },
53    ListItem {
54        #[serde(default)]
55        level: u8,
56        #[serde(default)]
57        ordered: bool,
58    },
59    Quote,
60    Code {
61        #[serde(default, skip_serializing_if = "Option::is_none")]
62        language: Option<String>,
63    },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67pub struct SetBlockType {
68    pub node: NodeTarget,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub expected_hash: Option<String>,
71    pub block: TextBlockType,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct InsertTable {
76    pub after: NodeTarget,
77    pub rows: Vec<Vec<String>>,
78}
79
80/// A row or column added to a table; `cells` fills it from the start and
81/// missing cells are empty.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83pub struct TableInsert {
84    pub table: NodeTarget,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub expected_hash: Option<String>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub at: Option<usize>,
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub cells: Vec<String>,
91}
92
93/// Deletes one row, one column, or (with neither) the whole table.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct TableDelete {
96    pub table: NodeTarget,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub expected_hash: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub row: Option<usize>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub col: Option<usize>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
106pub struct InsertImage {
107    pub after: NodeTarget,
108    pub blob: String,
109    #[serde(default, skip_serializing_if = "String::is_empty")]
110    pub alt: String,
111}
112
113/// The node a rich op addresses, and its guard when the op rewrites that
114/// node; an op that only inserts after its anchor has no guard.
115#[derive(Debug)]
116pub struct RichTarget<'a> {
117    pub node: &'a mut NodeTarget,
118    pub expected_hash: Option<&'a mut Option<String>>,
119}
120
121impl EditOp {
122    #[must_use]
123    pub const fn is_rich(&self) -> bool {
124        matches!(
125            self,
126            Self::SetMarks(_)
127                | Self::SetBlockType(_)
128                | Self::InsertTable(_)
129                | Self::TableInsertRow(_)
130                | Self::TableInsertCol(_)
131                | Self::TableDelete(_)
132                | Self::InsertImage(_)
133                | Self::SetParagraph(_)
134                | Self::InsertBlock(_)
135                | Self::UpdateTable(_)
136                | Self::SetSection(_)
137                | Self::SetStyle(_)
138                | Self::ApplyTheme(_)
139        )
140    }
141
142    #[must_use]
143    pub const fn is_document_level(&self) -> bool {
144        matches!(
145            self,
146            Self::SetSection(_) | Self::SetStyle(_) | Self::ApplyTheme(_)
147        )
148    }
149
150    pub const fn rich_target(&mut self) -> Option<RichTarget<'_>> {
151        let (node, expected_hash) = match self {
152            Self::SetMarks(op) => (&mut op.node, Some(&mut op.expected_hash)),
153            Self::SetBlockType(op) => (&mut op.node, Some(&mut op.expected_hash)),
154            Self::TableInsertRow(op) | Self::TableInsertCol(op) => {
155                (&mut op.table, Some(&mut op.expected_hash))
156            },
157            Self::TableDelete(op) => (&mut op.table, Some(&mut op.expected_hash)),
158            Self::InsertTable(op) => (&mut op.after, None),
159            Self::InsertImage(op) => (&mut op.after, None),
160            Self::SetParagraph(op) => (&mut op.node, Some(&mut op.expected_hash)),
161            Self::UpdateTable(op) => (&mut op.table, Some(&mut op.expected_hash)),
162            Self::InsertBlock(op) => (&mut op.after, None),
163            _ => return None,
164        };
165        Some(RichTarget {
166            node,
167            expected_hash,
168        })
169    }
170
171    #[must_use]
172    pub const fn rich_guard(&self) -> Option<(&NodeTarget, Option<&String>)> {
173        Some(match self {
174            Self::SetMarks(op) => (&op.node, op.expected_hash.as_ref()),
175            Self::SetBlockType(op) => (&op.node, op.expected_hash.as_ref()),
176            Self::TableInsertRow(op) | Self::TableInsertCol(op) => {
177                (&op.table, op.expected_hash.as_ref())
178            },
179            Self::TableDelete(op) => (&op.table, op.expected_hash.as_ref()),
180            Self::InsertTable(op) => (&op.after, None),
181            Self::InsertImage(op) => (&op.after, None),
182            Self::SetParagraph(op) => (&op.node, op.expected_hash.as_ref()),
183            Self::UpdateTable(op) => (&op.table, op.expected_hash.as_ref()),
184            Self::InsertBlock(op) => (&op.after, None),
185            _ => return None,
186        })
187    }
188}