1#[derive(Debug, Clone, PartialEq, Eq)]
3pub struct FactRequest {
4 pub subject: String,
5 pub environment: Option<String>,
6 pub attributes: Vec<String>,
7}
8
9fn folded(text: &str) -> String {
10 text.to_lowercase()
11 .chars()
12 .map(|c| match c {
13 'á' => 'a',
14 'é' => 'e',
15 'í' => 'i',
16 'ó' => 'o',
17 'ú' | 'ü' => 'u',
18 'ñ' => 'n',
19 _ => c,
20 })
21 .collect()
22}
23fn attribute(text: &str) -> Option<String> {
24 Some(
25 match text.trim().trim_matches('`') {
26 "port" | "puerto" => "port",
27 "timeout" | "tiempo de espera" => "timeout",
28 "version" | "release number" => "version",
29 "replicas" | "replica count" => "replicas",
30 "password" | "passphrase" | "contrasena" => "password",
31 "encryption key" | "clave de cifrado" => "encryption_key",
32 "retries" | "retry count" | "reintentos" => "retries",
33 "retention" | "retencion" => "retention",
34 "memory limit" | "limite de memoria" => "memory_limit",
35 key if key.contains(['.', '_'])
36 && key
37 .chars()
38 .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) =>
39 {
40 key
41 }
42 _ => return None,
43 }
44 .to_owned(),
45 )
46}
47fn attributes(text: &str) -> Option<Vec<String>> {
48 let text = text.replace(" and ", ",").replace(" y ", ",");
49 let mut result = Vec::new();
50 for part in text.split(',') {
51 let attr = attribute(part)?;
52 if !result.contains(&attr) {
53 result.push(attr);
54 }
55 }
56 if result.is_empty() || result.len() > 4 {
57 None
58 } else {
59 Some(result)
60 }
61}
62fn request(subject: &str, attrs: &str) -> Option<FactRequest> {
63 let (subject, environment) = crate::facts::canonical_subject(subject);
64 if subject.is_empty()
65 || subject.split_whitespace().any(|w| {
66 matches!(
67 w,
68 "effect"
69 | "impact"
70 | "cause"
71 | "causes"
72 | "changing"
73 | "configure"
74 | "configuration"
75 | "best"
76 | "meaning"
77 | "difference"
78 )
79 })
80 {
81 return None;
82 }
83 Some(FactRequest {
84 subject,
85 environment,
86 attributes: attributes(attrs)?,
87 })
88}
89pub fn parse(query: &str) -> Option<FactRequest> {
92 if query.len() > 1024 {
93 return None;
94 }
95 let normalized = folded(query);
96 let q = normalized.trim_matches(['¿', '?', ' ', '.', '\n', '\t']);
97 if let Some(rest) = q.strip_prefix("what ") {
98 if let Some((attrs, subject)) = rest.split_once(" does ") {
99 for suffix in [" use", " require", " run", " have"] {
100 if let Some(subject) = subject.strip_suffix(suffix) {
101 return request(subject, attrs);
102 }
103 }
104 return None;
105 }
106 }
107 let rest = [
108 "what is ",
109 "what are ",
110 "what's ",
111 "which is ",
112 "which are ",
113 "cual es ",
114 "cuales son ",
115 "que es ",
116 ]
117 .iter()
118 .find_map(|p| q.strip_prefix(p))?;
119 let rest = ["the ", "el ", "la ", "los ", "las "]
120 .iter()
121 .find_map(|p| rest.strip_prefix(p))
122 .unwrap_or(rest);
123 for separator in [" for ", " of ", " del ", " de "] {
124 if let Some((attrs, subject)) = rest.split_once(separator) {
125 if attributes(attrs).is_some() {
126 return request(subject, attrs);
127 }
128 }
129 }
130 for (index, ch) in rest.char_indices() {
133 if ch == ' ' && attributes(&rest[index + 1..]).is_some() {
134 return request(&rest[..index], &rest[index + 1..]);
135 }
136 }
137 None
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
141pub struct SupportedFact {
142 pub attribute: String,
143 pub value: String,
144 pub sources: Vec<String>,
145}
146#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
147pub struct FactAssessment {
148 pub status: String,
149 pub subject: String,
150 pub environment: Option<String>,
151 pub supported: Vec<SupportedFact>,
152 pub missing: Vec<String>,
153 pub conflicting: Vec<String>,
154}
155pub fn matches(request: &FactRequest, claim: &crate::facts::FactClaim) -> bool {
156 request.subject == claim.subject
157 && request.environment == claim.environment
158 && request.attributes.contains(&claim.attribute)
159}
160pub fn assess(
161 request: &FactRequest,
162 claims: &[(&crate::facts::FactClaim, &str)],
163) -> FactAssessment {
164 use std::collections::{BTreeMap, BTreeSet};
165 let mut supported = Vec::new();
166 let mut missing = Vec::new();
167 let mut conflicting = Vec::new();
168 for attribute in &request.attributes {
169 let mut values: BTreeMap<String, (&str, BTreeSet<&str>)> = BTreeMap::new();
170 for (claim, source) in claims {
171 if matches(request, claim) && claim.attribute == *attribute {
172 values
173 .entry(crate::fact_values::equivalence_key(attribute, &claim.value))
174 .or_insert_with(|| (claim.value.as_str(), BTreeSet::new()))
175 .1
176 .insert(source);
177 }
178 }
179 match values.len() {
180 0 => missing.push(attribute.clone()),
181 1 => {
182 let (_, (value, sources)) = values.into_iter().next().unwrap();
183 supported.push(SupportedFact {
184 attribute: attribute.clone(),
185 value: value.into(),
186 sources: sources.into_iter().map(str::to_owned).collect(),
187 });
188 }
189 _ => conflicting.push(attribute.clone()),
190 }
191 }
192 let status = if !conflicting.is_empty() {
193 "conflicting"
194 } else if supported.is_empty() {
195 "missing"
196 } else if missing.is_empty() {
197 "supported"
198 } else {
199 "partial"
200 };
201 FactAssessment {
202 status: status.into(),
203 subject: request.subject.clone(),
204 environment: request.environment.clone(),
205 supported,
206 missing,
207 conflicting,
208 }
209}
210
211pub fn preserve_conflicts(result: &mut FactAssessment, known: &[String]) {
214 for attr in known {
215 result.supported.retain(|s| &s.attribute != attr);
216 result.missing.retain(|s| s != attr);
217 if !result.conflicting.contains(attr) {
218 result.conflicting.push(attr.clone());
219 }
220 }
221 if !result.conflicting.is_empty() {
222 result.status = "conflicting".into();
223 }
224}
225pub fn visible(
227 capsule: &crate::context::ContextCapsule,
228 fact: &crate::fact_store::StoredFact,
229) -> bool {
230 capsule.expansion_handle.strip_prefix("memory:") == Some(fact.memory_id.as_str())
231 && capsule.claim_revision.as_deref() == Some(fact.claim_revision.as_str())
232 && !fact.claim.evidence.is_empty()
233 && capsule.summary.contains(&fact.claim.evidence)
234}
235pub fn evaluate(
236 query: &str,
237 capsules: &[crate::context::ContextCapsule],
238) -> Option<FactAssessment> {
239 let request = parse(query)?;
240 let claims: Vec<_> = capsules
241 .iter()
242 .flat_map(|c| {
243 c.facts
244 .iter()
245 .filter(move |f| visible(c, f))
246 .map(move |f| (&f.claim, c.expansion_handle.as_str()))
247 })
248 .collect();
249 Some(assess(&request, &claims))
250}
251pub fn compress_capsule(
252 query: &str,
253 capsule: &crate::context::ContextCapsule,
254 sentences: usize,
255) -> String {
256 let short =
257 crate::answerability::compress_preserving_evidence(query, &capsule.summary, sentences);
258 if parse(query).is_some_and(|request| {
259 capsule.facts.iter().any(|f| {
260 visible(capsule, f) && matches(&request, &f.claim) && !short.contains(&f.claim.evidence)
261 })
262 }) {
263 capsule.summary.clone()
264 } else {
265 short
266 }
267}
268pub fn notice(query: &str, capsules: &[crate::context::ContextCapsule]) -> Option<String> {
269 notice_with_conflicts(query, capsules, &[])
270}
271pub fn notice_with_conflicts(
272 query: &str,
273 capsules: &[crate::context::ContextCapsule],
274 known: &[String],
275) -> Option<String> {
276 let mut result = evaluate(query, capsules)?;
277 preserve_conflicts(&mut result, known);
278 if result.status == "supported" {
279 return None;
280 }
281 let mut details = Vec::new();
282 if !result.missing.is_empty() {
283 details.push(format!(
284 "no supported value for {}",
285 result.missing.join(", ")
286 ));
287 }
288 if !result.conflicting.is_empty() {
289 details.push(format!(
290 "conflicting values for {}",
291 result.conflicting.join(", ")
292 ));
293 }
294 Some(format!("Retrieved fact evidence: {}.", details.join("; ")))
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 pub(crate) fn capsule(text: &str, id: &str) -> crate::context::ContextCapsule {
301 let mut c =
302 crate::context::ContextCapsule::wire_minimal(text.into(), "memory".into(), 0.99);
303 c.expansion_handle = format!("memory:{id}");
304 c.claim_revision = Some(format!("baseline:{id}"));
305 c.facts = crate::facts::extract(text)
306 .into_iter()
307 .map(|claim| crate::fact_store::StoredFact {
308 memory_id: id.into(),
309 claim_revision: format!("baseline:{id}"),
310 source_event_id: "accepted-event".into(),
311 valid_from: None,
312 valid_to: None,
313 claim,
314 })
315 .collect();
316 c
317 }
318 #[test]
319 fn only_visible_revision_bound_facts_support_an_answer() {
320 let q = "What is the Orchid staging gateway port?";
321 let mut c = capsule("Orchid staging gateway port is 7319.", "port");
322 assert_eq!(evaluate(q, &[c.clone()]).unwrap().status, "supported");
323 c.claim_revision = Some("different-revision".into());
324 assert_eq!(evaluate(q, &[c.clone()]).unwrap().status, "missing");
325 c.claim_revision = Some("baseline:port".into());
326 c.summary = "Unrelated text.".into();
327 assert_eq!(evaluate(q, &[c]).unwrap().status, "missing");
328 }
329 #[test]
330 fn compression_preserves_each_supported_attribute() {
331 let q = "What are the Orchid gateway port and timeout?";
332 let c = capsule(
333 "Orchid gateway port is 7319. Other setup notes. More setup notes. Orchid gateway timeout is 30 seconds.",
334 "settings",
335 );
336 assert!(compress_capsule(q, &c, 3).contains("30 seconds"));
337 }
338 #[test]
339 fn budget_trimming_does_not_hide_a_known_conflict() {
340 use crate::context::ContextBundle;
341 let q = "What is the Orchid gateway port?";
342 let bundle = ContextBundle {
343 stage: "localization".into(),
344 budget_tokens: 6000,
345 used_tokens: 0,
346 capsules: vec![
347 capsule("Orchid gateway port is 7319.", "a"),
348 capsule("Orchid gateway port is 7320.", "b"),
349 ],
350 excluded: vec![],
351 skipped: false,
352 top_score: 0.99,
353 top_abs_evidence: 0.99,
354 evidence_coverage: 1.0,
355 uncovered_terms: vec![],
356 chronological: false,
357 known_fact_conflicts: vec![],
358 };
359 let mut found = false;
360 for budget in (300..2000).step_by(20) {
361 let delivered = crate::serving::ServingPolicy {
362 budget,
363 explicit_fact_guard: true,
364 ..Default::default()
365 }
366 .render_for_query(
367 q,
368 bundle.clone(),
369 true,
370 crate::serving::EVAL_EXPOSURE_ID,
371 );
372 if delivered.capsules.len() == 1 {
373 found = true;
374 assert_eq!(delivered.payload["answerability"]["status"], "conflicting");
375 assert_eq!(
376 delivered.payload["answerability"]["conflicting"],
377 serde_json::json!(["port"])
378 );
379 break;
380 }
381 }
382 assert!(found);
383 }
384 #[test]
385 fn serving_metadata_tracks_the_final_budgeted_slice() {
386 use crate::context::ContextBundle;
387 let q = "What are the Orchid gateway port and timeout?";
388 let bundle = ContextBundle {
389 stage: "localization".into(),
390 budget_tokens: 6000,
391 used_tokens: 0,
392 capsules: vec![
393 capsule("Orchid gateway port is 7319.", "port"),
394 capsule("Orchid gateway timeout is 30 seconds.", "timeout"),
395 ],
396 excluded: vec![],
397 skipped: false,
398 top_score: 0.99,
399 top_abs_evidence: 0.99,
400 evidence_coverage: 1.0,
401 uncovered_terms: vec![],
402 chronological: false,
403 known_fact_conflicts: vec![],
404 };
405 let policy = crate::serving::ServingPolicy {
406 explicit_fact_guard: true,
407 ..Default::default()
408 };
409 let full =
410 policy.render_for_query(q, bundle.clone(), true, crate::serving::EVAL_EXPOSURE_ID);
411 assert_eq!(full.payload["answerability"]["status"], "supported");
412 let mut found = false;
413 for budget in (300..2000).step_by(20) {
414 let delivered = crate::serving::ServingPolicy { budget, ..policy }.render_for_query(
415 q,
416 bundle.clone(),
417 true,
418 crate::serving::EVAL_EXPOSURE_ID,
419 );
420 if delivered.capsules.len() == 1 {
421 found = true;
422 assert_eq!(delivered.payload["answerability"]["status"], "partial");
423 assert_eq!(
424 delivered.payload["answerability"]["missing"],
425 serde_json::json!(["timeout"])
426 );
427 assert!(
428 crate::context::delivery::serialized_output_tokens(&delivered.payload)
429 <= budget
430 );
431 break;
432 }
433 }
434 assert!(found, "expected a budget admitting only the first capsule");
435 }
436 fn claim(
437 subject: &str,
438 environment: Option<&str>,
439 attribute: &str,
440 value: &str,
441 ) -> crate::facts::FactClaim {
442 crate::facts::FactClaim {
443 subject: subject.into(),
444 environment: environment.map(str::to_owned),
445 attribute: attribute.into(),
446 value: value.into(),
447 evidence: "evidence".into(),
448 }
449 }
450 #[test]
451 fn supports_partial_answers_without_borrowing_another_scope() {
452 let request = parse("What are the Orchid staging gateway port and timeout?").unwrap();
453 let port = claim("orchid gateway", Some("staging"), "port", "7319");
454 let wrong_env = claim(
455 "orchid gateway",
456 Some("production"),
457 "timeout",
458 "30 seconds",
459 );
460 let wrong_subject = claim("quartz gateway", Some("staging"), "timeout", "30 seconds");
461 let result = assess(
462 &request,
463 &[
464 (&port, "memory:port"),
465 (&wrong_env, "memory:wrong-env"),
466 (&wrong_subject, "memory:wrong-subject"),
467 ],
468 );
469 assert_eq!(result.status, "partial");
470 assert_eq!(result.missing, ["timeout"]);
471 assert_eq!(result.supported[0].sources, ["memory:port"]);
472 }
473 #[test]
474 fn conflicting_values_are_reported_instead_of_selecting_one() {
475 let request = parse("What is the Orchid gateway port?").unwrap();
476 let a = claim("orchid gateway", None, "port", "7319");
477 let b = claim("orchid gateway", None, "port", "7320");
478 let result = assess(&request, &[(&a, "memory:a"), (&b, "memory:b")]);
479 assert_eq!(result.status, "conflicting");
480 assert_eq!(result.conflicting, ["port"]);
481 assert!(result.supported.is_empty());
482 }
483 #[test]
484 fn parses_shared_subject_and_environment_for_compound_request() {
485 let request = parse("What are the Orchid staging gateway port and timeout?").unwrap();
486 assert_eq!(request.subject, "orchid gateway");
487 assert_eq!(request.environment.as_deref(), Some("staging"));
488 assert_eq!(request.attributes, ["port", "timeout"]);
489 }
490 #[test]
491 fn parses_subject_after_attributes_and_spanish_aliases() {
492 assert_eq!(
493 parse("What is the port for the Quartz gateway?")
494 .unwrap()
495 .subject,
496 "quartz gateway"
497 );
498 assert_eq!(
499 parse("What port does the Quartz gateway use?")
500 .unwrap()
501 .attributes,
502 ["port"]
503 );
504 assert_eq!(
505 parse("¿Cuál es el puerto del Quartz gateway?")
506 .unwrap()
507 .attributes,
508 ["port"]
509 );
510 }
511 #[test]
512 fn broad_or_ambiguous_questions_keep_normal_retrieval() {
513 for query in [
514 "What causes a version conflict?",
515 "What version control system do we use?",
516 "What is the effect of changing port?",
517 "What does `cache.size` control?",
518 "What are the gateway port and database timeout?",
519 "What is the port?",
520 ] {
521 assert_eq!(parse(query), None, "{query}");
522 }
523 }
524}