velesdb_memory/context/transcript_bridge.rs
1//! The one place a *binding* turns a raw transcript into a
2//! [`CompileRequest`] plus an auditable segmentation report.
3//!
4//! `compile_transcript` is a one-call shortcut over `compile_context`: it
5//! segments a raw agent-session transcript into turns (and, within a turn,
6//! into code/log/body sub-segments) before compiling. The segmentation
7//! itself lives in [`super::segment`]; what lives here is the *glue* around
8//! it — the empty-transcript guard the MCP tool applies, the request
9//! assembly, and the per-segment audit trail a caller inspects to see how
10//! its transcript was cut before trusting the compiled result.
11//!
12//! That glue was copied verbatim into `velesdb-node` and `velesdb-wasm`,
13//! each doc comment pointing at the other as "mirrors the … binding's own"
14//! — two copies kept in step by hand. The Python binding could not be given
15//! `compile_transcript` without becoming a third. It lives here instead, so
16//! the three bindings relay one implementation and a fix reaches all of them
17//! at once.
18//!
19//! `fragment_id` is rendered as a decimal string, not a `u64`: it is a
20//! 64-bit content hash, routinely past 2^53, and a JS caller reading it as a
21//! `number` would round it. Every binding publishes it that way already, and
22//! so does the MCP tool — the string is the wire form, not a JS concession.
23//!
24//! No `path` field: resolving one needs an ingest-roots allowlist, which is
25//! MCP-server configuration. A binding caller reads the file itself and
26//! passes the text.
27
28use serde::{Deserialize, Serialize};
29
30use super::segment::{segment_transcript, SegmentFormat, SegmentKind, SegmentationPolicy};
31use super::{fragment_id, CompilePolicy, CompileRequest};
32use crate::error::MemoryError;
33
34/// A binding's `compile_transcript` request: the MCP tool's own fields minus
35/// `path` (see the module docs).
36#[derive(Debug, Clone, Deserialize)]
37pub struct TranscriptCompileInput {
38 /// What the compiled context has to serve — drives relevance ranking.
39 pub query: String,
40 /// The raw transcript, plain marker-based or JSONL.
41 pub transcript: String,
42 /// Token ceiling for the compiled context.
43 pub token_budget: u64,
44 /// Optional project key, recorded with the savings event.
45 #[serde(default)]
46 pub project: Option<String>,
47 /// Optional model name the context targets.
48 #[serde(default)]
49 pub target_model: Option<String>,
50 /// Optional compilation policy overrides.
51 #[serde(default)]
52 pub policy: Option<CompilePolicy>,
53 /// Optional segmentation policy overrides.
54 #[serde(default)]
55 pub segmentation: Option<SegmentationPolicy>,
56}
57
58/// One entry of [`SegmentationReport::segments`]: where a fragment came from
59/// in the original transcript.
60#[derive(Debug, Clone, Serialize)]
61pub struct SegmentInfo {
62 /// Position of this segment in the compiled fragment list.
63 pub index: usize,
64 /// 0-based turn the segment was cut from.
65 pub turn: usize,
66 /// Speaker of that turn, when the transcript labelled one.
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub role: Option<String>,
69 /// Whether the segment is body text, fenced code, or a log run.
70 pub kind: SegmentKind,
71 /// Start offset in the original transcript, in bytes.
72 pub byte_start: usize,
73 /// End offset in the original transcript, in bytes.
74 pub byte_end: usize,
75 /// Content-addressed fragment id, as a decimal string (see module docs).
76 pub fragment_id: String,
77}
78
79/// How a transcript was cut, returned alongside the compiled context so a
80/// caller can audit the cut before trusting the result.
81#[derive(Debug, Clone, Serialize)]
82pub struct SegmentationReport {
83 /// Format the segmenter detected (or was forced to).
84 pub format_detected: SegmentFormat,
85 /// One entry per resulting fragment, in compile order.
86 pub segments: Vec<SegmentInfo>,
87 /// How many adjacent segments were merged away.
88 pub merged_segments: usize,
89}
90
91/// Segment `input.transcript` and assemble the [`CompileRequest`] a binding
92/// then hands to `compile_context`, plus the [`SegmentationReport`] it
93/// returns next to the compiled context.
94///
95/// # Errors
96/// [`MemoryError::SegmentationError`] for an empty transcript — mirroring
97/// the MCP tool's own guard, since [`segment_transcript`] has none of its
98/// own (an empty string is a valid, if useless, zero-turn input to it) — or
99/// whatever [`segment_transcript`] itself returns: a genuine budget/cap
100/// breach, or a forced-format parse failure.
101pub fn build_transcript_compile_request(
102 input: TranscriptCompileInput,
103) -> Result<(CompileRequest, SegmentationReport), MemoryError> {
104 if input.transcript.is_empty() {
105 return Err(MemoryError::SegmentationError(
106 "the transcript is empty — `transcript` must be non-empty text".to_owned(),
107 ));
108 }
109 let outcome = segment_transcript(&input.transcript, &input.segmentation.unwrap_or_default())?;
110 let segments = outcome
111 .segments
112 .iter()
113 .enumerate()
114 .map(|(index, segment)| SegmentInfo {
115 index,
116 turn: segment.turn,
117 role: segment.role.clone(),
118 kind: segment.kind,
119 byte_start: segment.byte_start,
120 byte_end: segment.byte_end,
121 fragment_id: fragment_id(&segment.fragment.content).to_string(),
122 })
123 .collect();
124 let report = SegmentationReport {
125 format_detected: outcome.format_detected,
126 segments,
127 merged_segments: outcome.merged_segments,
128 };
129 let request = CompileRequest {
130 query: input.query,
131 fragments: outcome.segments.into_iter().map(|s| s.fragment).collect(),
132 project: input.project,
133 target_model: input.target_model,
134 token_budget: input.token_budget,
135 memory_scope: None,
136 policy: input.policy,
137 };
138 Ok((request, report))
139}
140
141#[cfg(test)]
142#[path = "transcript_bridge_tests.rs"]
143mod tests;