1use crate::ir::WarbleIr;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RenderFlavor {
15 Programmatic,
16 Prompt,
17}
18
19pub const DEFAULT_RENDER_FLAVOR: RenderFlavor = RenderFlavor::Programmatic;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "kebab-case")]
30pub enum ContextInjectionMode {
31 SchemaOnly,
32 #[serde(rename = "schema+knowledge")]
33 SchemaWithKnowledge,
34}
35
36pub const DEFAULT_CONTEXT_INJECTION: ContextInjectionMode = ContextInjectionMode::SchemaOnly;
37
38impl ContextInjectionMode {
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 ContextInjectionMode::SchemaOnly => "schema-only",
42 ContextInjectionMode::SchemaWithKnowledge => "schema+knowledge",
43 }
44 }
45
46 pub fn parse(value: &str) -> Option<Self> {
47 match value {
48 "schema-only" => Some(Self::SchemaOnly),
49 "schema+knowledge" => Some(Self::SchemaWithKnowledge),
50 _ => None,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ContextInjection {
58 mode: ContextInjectionMode,
59 schema_digest: String,
60 knowledge: String,
61 introspected: bool,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct ContextInjectionReport {
69 pub mode: &'static str,
70 pub schema_digest_fingerprint: String,
71 pub knowledge_fingerprint: Option<String>,
72 pub knowledge_chars: usize,
73}
74
75impl ContextInjection {
76 pub fn from_ir(ir: &WarbleIr, mode: ContextInjectionMode, knowledge: Option<String>) -> Self {
79 let introspected = ir.context_binding.resolved.is_some();
80 let schema_digest = build_schema_digest(ir.context_binding.resolved.as_ref());
81 let knowledge = match mode {
82 ContextInjectionMode::SchemaOnly => String::new(),
83 ContextInjectionMode::SchemaWithKnowledge => {
84 normalize_text(knowledge.as_deref().unwrap_or(""))
85 }
86 };
87 Self {
88 mode,
89 schema_digest,
90 knowledge,
91 introspected,
92 }
93 }
94
95 pub fn mode(&self) -> ContextInjectionMode {
96 self.mode
97 }
98
99 pub fn introspected(&self) -> bool {
103 self.introspected
104 }
105
106 pub fn prompt_section(&self) -> String {
107 let knowledge = match self.mode {
108 ContextInjectionMode::SchemaOnly if !self.introspected => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files.".to_string(),
111 ContextInjectionMode::SchemaOnly => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files; answer from the injected schema and the question only.".to_string(),
112 ContextInjectionMode::SchemaWithKnowledge if self.knowledge.is_empty() => "Knowledge injection is enabled, but the host found no non-empty business rules. Do NOT call a context-instruction tool; there are no injected rules to recover.".to_string(),
113 ContextInjectionMode::SchemaWithKnowledge => format!(
114 "The host embedded the authoritative business rules below. Apply every relevant rule and do NOT retrieve context instructions again.\n\n<knowledge_rules>\n{}\n</knowledge_rules>",
115 self.knowledge
116 ),
117 };
118 if !self.introspected {
122 return format!(
123 "## Injected context\n\nContext injection mode: `{}`. The bound semantic layer was NOT introspected here — it is held elsewhere. You therefore know NOTHING about which models, metrics or dimensions exist. Never tell the user that something does not exist, and never rule a question out, on the strength of this absence; put the question to whatever tool answers it and report what comes back.\n\n{}",
124 self.mode.as_str(),
125 knowledge
126 );
127 }
128 format!(
129 "## Injected context\n\nContext injection mode: `{}`. Use this compiled schema digest before calling a semantic-introspection tool; introspect only when the question needs details absent from the digest.\n\n<schema_digest>\n{}\n</schema_digest>\n\n{}",
130 self.mode.as_str(),
131 self.schema_digest,
132 knowledge
133 )
134 }
135
136 pub fn report(&self) -> ContextInjectionReport {
137 ContextInjectionReport {
138 mode: self.mode.as_str(),
139 schema_digest_fingerprint: fingerprint(&self.schema_digest),
140 knowledge_fingerprint: (self.mode == ContextInjectionMode::SchemaWithKnowledge)
141 .then(|| fingerprint(&self.knowledge)),
142 knowledge_chars: self.knowledge.chars().count(),
143 }
144 }
145}
146
147fn normalize_text(value: &str) -> String {
148 value
149 .replace("\r\n", "\n")
150 .replace('\r', "\n")
151 .trim()
152 .to_string()
153}
154
155fn fingerprint(value: &str) -> String {
158 let mut hash = 0xcbf29ce484222325_u64;
159 for byte in value.as_bytes() {
160 hash ^= u64::from(*byte);
161 hash = hash.wrapping_mul(0x100000001b3);
162 }
163 format!("fnv1a64:{hash:016x}")
164}
165
166fn canonical_json(value: &serde_json::Value) -> String {
167 match value {
168 serde_json::Value::Null => "null".to_string(),
169 serde_json::Value::Bool(v) => v.to_string(),
170 serde_json::Value::Number(v) => v.to_string(),
171 serde_json::Value::String(v) => serde_json::to_string(v).expect("string serializes"),
172 serde_json::Value::Array(values) => format!(
173 "[{}]",
174 values
175 .iter()
176 .map(canonical_json)
177 .collect::<Vec<_>>()
178 .join(",")
179 ),
180 serde_json::Value::Object(values) => {
181 let mut entries = values.iter().collect::<Vec<_>>();
182 entries.sort_by_key(|(key, _)| *key);
183 format!(
184 "{{{}}}",
185 entries
186 .into_iter()
187 .map(|(key, value)| format!(
188 "{}:{}",
189 serde_json::to_string(key).expect("key serializes"),
190 canonical_json(value)
191 ))
192 .collect::<Vec<_>>()
193 .join(",")
194 )
195 }
196 }
197}
198
199fn sorted_entries(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
200 let mut entries = value
201 .and_then(|v| v.get(key))
202 .and_then(serde_json::Value::as_array)
203 .into_iter()
204 .flatten()
205 .map(|entry| match entry {
206 serde_json::Value::String(s) => s.clone(),
207 other => canonical_json(other),
208 })
209 .collect::<Vec<_>>();
210 entries.sort();
211 entries
212}
213
214fn digest_line(label: &str, entries: Vec<String>) -> String {
215 if entries.is_empty() {
216 format!("{label}: (none)")
217 } else {
218 format!("{label}: {}", entries.join(", "))
219 }
220}
221
222fn build_schema_digest(resolved: Option<&serde_json::Value>) -> String {
223 let lineage = resolved
224 .and_then(|v| v.get("lineage"))
225 .map(canonical_json)
226 .unwrap_or_else(|| "unavailable".to_string());
227 [
228 digest_line("Models", sorted_entries(resolved, "models")),
229 digest_line("Metrics", sorted_entries(resolved, "metrics")),
230 digest_line("Dimensions", sorted_entries(resolved, "dimensions")),
231 digest_line(
232 "Time dimensions",
233 sorted_entries(resolved, "time_dimensions"),
234 ),
235 format!("Lineage: {lineage}"),
236 ]
237 .join("\n")
238}
239
240impl RenderFlavor {
241 pub fn as_str(&self) -> &'static str {
242 match self {
243 RenderFlavor::Programmatic => "programmatic",
244 RenderFlavor::Prompt => "prompt",
245 }
246 }
247
248 pub fn parse(value: &str) -> Option<RenderFlavor> {
249 match value {
250 "programmatic" => Some(RenderFlavor::Programmatic),
251 "prompt" => Some(RenderFlavor::Prompt),
252 _ => None,
253 }
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
265pub enum HybridRealization {
266 #[default]
267 BashScript,
268 McpServer,
269}
270
271impl HybridRealization {
272 pub fn parse(value: &str) -> Option<HybridRealization> {
273 match value {
274 "bash-script" => Some(HybridRealization::BashScript),
275 "mcp-server" => Some(HybridRealization::McpServer),
276 _ => None,
277 }
278 }
279}