1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum FactEvidence {
4 Unrecognized,
5 ValuePresent,
6 MissingValue,
7}
8struct Rule {
9 query: regex::Regex,
10 value: regex::Regex,
11 secret: bool,
12}
13fn folded(text: &str) -> String {
14 text.to_lowercase()
15 .chars()
16 .map(|c| match c {
17 'á' => 'a',
18 'é' => 'e',
19 'í' => 'i',
20 'ó' => 'o',
21 'ú' | 'ü' => 'u',
22 'ñ' => 'n',
23 _ => c,
24 })
25 .collect()
26}
27fn rules() -> &'static [Rule] {
28 static RULES: std::sync::OnceLock<Vec<Rule>> = std::sync::OnceLock::new();
29 RULES.get_or_init(|| {
30 [
31 (r"\b(?:password|passphrase|contrasena)\b", r#"\b(?:password|passphrase|contrasena)\s*(?:is|es|=|:)\s*[`"']?(?P<value>[a-z0-9_+./-]+)"#, true),
32 (r"\b(?:encryption key|clave de cifrado|clave de encriptacion)\b", r#"\b(?:encryption key|clave de cifrado|clave de encriptacion)\s*(?:is|es|=|:)\s*[`"']?(?P<value>[a-z0-9_+./-]+)"#, true),
33 (r"\b(?:version|release number)\b", r"\b(?:version|release)\s*(?:is\s+|es\s+|=\s*|:\s*)?v?\d+(?:\.\d+)*\b", false),
34 (r"\b(?:replica|replicas)\b", r"\b(?:\d+\s+(?:production\s+)?replicas?|replicas?\s*(?:count\s*)?(?:is\s+|are\s+|son\s+|=\s*|:\s*)?\d+)\b", false),
35 (r"\b(?:retention|retencion)\b|\b(?:days|dias|weeks|semanas)\b.*\b(?:keep|kept|retain\w*|conserv\w*)\b|\bhow long\b.*\b(?:keep|kept|retain\w*)\b", r"\b(?:retention|retencion|retained|retain|keep|kept|conserv\w*)\b[^.;\n]{0,64}\b\d+\s*(?:seconds?|segundos?|minutes?|minutos?|hours?|horas?|days?|dias?|weeks?|semanas?|months?|meses|years?|anos?)\b", false),
36 (r"\b(?:port|puerto)\b", r"\b(?:port|puerto)\s*(?:is\s+|es\s+|=\s*|:\s*)?\d{1,5}\b", false),
37 (r"\b(?:timeout|tiempo de espera)\b", r"\b(?:timeout|tiempo de espera)\s*(?:is\s+|es\s+|=\s*|:\s*)?\d+(?:\.\d+)?\s*(?:ms|s|seconds?|segundos?|minutes?|minutos?)\b", false),
38 (r"\b(?:retries|retry count|reintentos)\b", r"\b(?:retries|retry count|reintentos)\s*(?:is\s+|are\s+|son\s+|=\s*|:\s*)?\d+\b|\b\d+\s+(?:retries|reintentos)\b", false),
39 (r"\b(?:memory limit|limite de memoria)\b", r"\b(?:memory limit|limite de memoria)\s*(?:is\s+|es\s+|=\s*|:\s*)?\d+\s*(?:kib|mib|gib|kb|mb|gb|bytes)\b", false),
40 ].into_iter().map(|(query,value,secret)| Rule {
41 query: regex::Regex::new(query).expect("constant query pattern"),
42 value: regex::Regex::new(value).expect("constant evidence pattern"), secret,
43 }).collect()
44 })
45}
46fn concrete(value: &str) -> bool {
47 !matches!(
48 value.trim_end_matches('.'),
49 "unknown"
50 | "redacted"
51 | "missing"
52 | "unavailable"
53 | "not"
54 | "stored"
55 | "configured"
56 | "required"
57 | "managed"
58 | "generated"
59 | "hidden"
60 | "provided"
61 | "set"
62 | "secret"
63 | "desconocida"
64 | "desconocido"
65 | "configurada"
66 | "configurado"
67 )
68}
69fn clause_supports(query: &str, body: &str, start: usize, end: usize) -> bool {
70 static ENTITIES: std::sync::OnceLock<Vec<regex::Regex>> = std::sync::OnceLock::new();
71 let entities = ENTITIES.get_or_init(|| {
72 [
73 r"\b(?:database|db|base de datos|sqlite|postgresql|postgres)\b",
74 r"\b(?:gateway|puerta de enlace)\b",
75 r"\b(?:client|cliente)\b",
76 r"\b(?:server|servidor|listener)\b",
77 r"\b(?:logs?|registros)\b",
78 r"\b(?:backups?|copias de seguridad)\b",
79 r"\b(?:workers?|trabajadores)\b",
80 r"\bsqlite\b",
81 r"\b(?:postgres|postgresql)\b",
82 r"\bopenssl\b",
83 r"\bredis\b",
84 r"\bpython\b",
85 r"\bnode\b",
86 r"\brust\b",
87 ]
88 .into_iter()
89 .map(|p| regex::Regex::new(p).unwrap())
90 .collect()
91 });
92 let boundary = |i: usize, c: char| {
93 c == ';'
94 || c == ','
95 || c == '\n'
96 || (c == '.' && body[i + 1..].starts_with(char::is_whitespace))
97 };
98 let left = body[..start]
99 .char_indices()
100 .filter(|(i, c)| boundary(*i, *c))
101 .map(|(i, _)| i + 1)
102 .next_back()
103 .unwrap_or(0);
104 let right = body[end..]
105 .char_indices()
106 .find(|(i, c)| boundary(end + *i, *c))
107 .map(|(i, _)| end + i)
108 .unwrap_or(body.len());
109 let clause = &body[left..right];
110 if [
111 "example",
112 "ejemplo",
113 "hypothetical",
114 "hipotetic",
115 "unknown",
116 "redacted",
117 "not configured",
118 "not installed",
119 "no longer",
120 ]
121 .iter()
122 .any(|word| clause.contains(word))
123 {
124 return false;
125 }
126 static NEGATED: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
129 let negated = NEGATED.get_or_init(|| {
130 regex::Regex::new(r"\b(?:not|never|no|nunca|sin|isn't|isnt|don't|doesn't)\b").unwrap()
131 });
132 let matched = &body[start..end];
133 let explicit_absence = matched.contains("no password")
134 || matched.contains("not required")
135 || matched.contains("requiere contrasena");
136 if negated.is_match(clause) && !explicit_absence {
137 return false;
138 }
139 entities
140 .iter()
141 .all(|entity| !entity.is_match(query) || entity.is_match(clause))
142}
143pub fn assess(query: &str, text: &str) -> FactEvidence {
147 let q = folded(query);
148 let q = q.trim_start_matches(['¿', ' ', '\t', '\n']);
149 if ![
150 "what ",
151 "what's ",
152 "which ",
153 "how many ",
154 "how much ",
155 "how long ",
156 "que ",
157 "cual ",
158 "cuantos ",
159 "cuantas ",
160 "cuanto ",
161 "tell me ",
162 "dime ",
163 ]
164 .iter()
165 .any(|prefix| q.starts_with(prefix))
166 {
167 return FactEvidence::Unrecognized;
168 }
169 if q.split_whitespace()
170 .any(|w| matches!(w, "should" | "could" | "would" | "deberia" | "debo"))
171 {
172 return FactEvidence::Unrecognized;
173 }
174 let normalized = folded(text);
175 let mut body = normalized.as_str();
176 if let Some((prefix, rest)) = body.split_once(" - ") {
177 if prefix.contains(':') && !prefix.contains(' ') {
178 body = rest;
179 }
180 }
181 while body.starts_with('[') {
182 if let Some((_, rest)) = body.split_once(']') {
183 body = rest.trim_start();
184 } else {
185 break;
186 }
187 }
188 if q.contains('`')
191 && (q.matches('`').count() != 2
192 || !["what is ", "what's ", "cual es ", "que valor "]
193 .iter()
194 .any(|p| q.starts_with(p)))
195 {
196 return FactEvidence::Unrecognized;
197 }
198 if let Some(key) = q.split('`').nth(1).filter(|key| {
201 key.contains(['.', '_'])
202 && key
203 .chars()
204 .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
205 }) {
206 static ASSIGNMENT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
207 let pattern = ASSIGNMENT.get_or_init(|| {
208 regex::Regex::new(
209 r#"\b(?P<key>[a-z_][a-z0-9_.-]*)`?\s*(?:=|:)\s*[`"']?(?P<value>[a-z0-9_+./-]+)"#,
210 )
211 .unwrap()
212 });
213 return if pattern.captures_iter(body).any(|c| {
214 &c["key"] == key
215 && concrete(&c["value"])
216 && clause_supports(q, body, c.get(0).unwrap().start(), c.get(0).unwrap().end())
217 }) {
218 FactEvidence::ValuePresent
219 } else {
220 FactEvidence::MissingValue
221 };
222 }
223 let matches: Vec<_> = rules()
226 .iter()
227 .filter_map(|r| r.query.find(q).map(|m| (m.start(), r)))
228 .collect();
229 if matches.len() != 1 {
230 return FactEvidence::Unrecognized;
231 }
232 let (offset, rule) = matches[0];
233 let attribute = rule.query.find(q).unwrap();
236 let suffix = q[attribute.end()..].trim_start();
237 if [
238 "control",
239 "hashing",
240 "policy",
241 "rotation",
242 "conflict",
243 "file",
244 "algorithm",
245 "management",
246 ]
247 .iter()
248 .any(|word| suffix.starts_with(word))
249 {
250 return FactEvidence::Unrecognized;
251 }
252 let prefix = &q[..offset];
253 let proper_names: Vec<_> = query
254 .split_whitespace()
255 .filter(|w| w.chars().next().is_some_and(char::is_uppercase))
256 .map(folded)
257 .collect();
258 if !prefix.split_whitespace().all(|word| {
259 matches!(
260 word,
261 "what"
262 | "what's"
263 | "which"
264 | "is"
265 | "are"
266 | "the"
267 | "a"
268 | "an"
269 | "how"
270 | "many"
271 | "much"
272 | "long"
273 | "que"
274 | "cual"
275 | "es"
276 | "la"
277 | "el"
278 | "cuantos"
279 | "cuantas"
280 | "cuanto"
281 | "tell"
282 | "me"
283 | "dime"
284 | "current"
285 | "configured"
286 | "required"
287 | "authentication"
288 | "request"
289 | "tcp"
290 | "http"
291 | "database"
292 | "db"
293 | "sqlite"
294 | "postgresql"
295 | "postgres"
296 | "gateway"
297 | "client"
298 | "server"
299 | "listener"
300 | "worker"
301 | "memory"
302 | "production"
303 | "staging"
304 | "backup"
305 | "log"
306 | "logs"
307 | "base"
308 | "de"
309 | "datos"
310 | "del"
311 ) || proper_names.iter().any(|name| name == word)
312 }) {
313 return FactEvidence::Unrecognized;
314 }
315 if rule.secret {
316 static ABSENT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
317 let absent = ABSENT.get_or_init(|| regex::Regex::new(r"\b(?:no password (?:is )?required|password (?:is )?not required|no (?:se )?requiere contrasena)\b").unwrap());
318 if rule.query.as_str().contains("password")
319 && absent
320 .find_iter(body)
321 .any(|m| clause_supports(q, body, m.start(), m.end()))
322 {
323 return FactEvidence::ValuePresent;
324 }
325 }
326 if rule.value.captures_iter(body).any(|c| {
327 (!rule.secret || concrete(&c["value"]))
328 && clause_supports(q, body, c.get(0).unwrap().start(), c.get(0).unwrap().end())
329 }) {
330 FactEvidence::ValuePresent
331 } else {
332 FactEvidence::MissingValue
333 }
334}
335
336pub fn filter_bundle(query: &str, bundle: &mut crate::context::ContextBundle) {
338 let request = crate::fact_query::parse(query);
339 for capsule in std::mem::take(&mut bundle.capsules) {
340 let rejected = if let Some(request) = request.as_ref().filter(|_| !capsule.facts.is_empty())
341 {
342 !capsule.facts.iter().any(|fact| {
343 crate::fact_query::visible(&capsule, fact)
344 && crate::fact_query::matches(request, &fact.claim)
345 })
346 } else {
347 assess(query, &capsule.summary) == FactEvidence::MissingValue
348 };
349 if rejected {
350 bundle.excluded.push(capsule);
351 } else {
352 bundle.capsules.push(capsule);
353 }
354 }
355 bundle.used_tokens = bundle.capsules.iter().map(|c| c.token_estimate).sum();
356 bundle.top_score = bundle
357 .capsules
358 .iter()
359 .map(|c| c.score)
360 .fold(0.0_f32, f32::max);
361 if bundle.capsules.is_empty() {
362 bundle.skipped = true;
363 bundle.evidence_coverage = 0.0;
364 }
365}
366pub fn compress_preserving_evidence(query: &str, summary: &str, sentences: usize) -> String {
369 let short = crate::context::compress_for_render(summary, sentences);
370 if assess(query, summary) == FactEvidence::ValuePresent
371 && assess(query, &short) == FactEvidence::MissingValue
372 {
373 summary.to_owned()
374 } else {
375 short
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 #[test]
383 fn related_topics_do_not_supply_missing_configuration_values() {
384 for (q, text) in [
385 (
386 "What password does the staging listener require?",
387 "The staging listener binds port 6319.",
388 ),
389 (
390 "¿Qué versión de SQLite requiere el proyecto?",
391 "The project uses SQLite WAL mode.",
392 ),
393 (
394 "How many replicas run in production?",
395 "Production runs in eu-north-1.",
396 ),
397 (
398 "¿Cuántos días se conservan las copias?",
399 "Backups run daily at 02:40 UTC.",
400 ),
401 (
402 "What is the encryption key?",
403 "Encryption is enabled for the database.",
404 ),
405 (
406 "What is the request timeout?",
407 "Requests are logged every 30 seconds.",
408 ),
409 ("What is `cache.max_entries`?", "cache.min_entries = 200"),
410 ] {
411 assert_eq!(assess(q, text), FactEvidence::MissingValue, "{q}");
412 }
413 }
414 #[test]
415 fn explicit_values_survive_in_both_languages() {
416 for (q, text) in [
417 (
418 "What password is configured?",
419 "password = `sample-only-value`",
420 ),
421 (
422 "¿Qué versión de SQLite requiere el proyecto?",
423 "SQLite version 3.46 is required.",
424 ),
425 (
426 "How many replicas run in production?",
427 "Production runs 4 replicas.",
428 ),
429 (
430 "¿Cuántos días se conservan las copias?",
431 "Backups are retained for 21 days.",
432 ),
433 (
434 "What is the encryption key?",
435 "encryption key = `sample-only-key`",
436 ),
437 (
438 "What is the request timeout?",
439 "The request timeout is 30 seconds.",
440 ),
441 ("What is `cache.max_entries`?", "cache.max_entries = 200"),
442 (
443 "Which TCP port is configured?",
444 "The listener binds TCP port 6319.",
445 ),
446 ] {
447 assert_eq!(assess(q, text), FactEvidence::ValuePresent, "{q}");
448 }
449 }
450 #[test]
451 fn mentions_and_unknown_values_are_not_answers() {
452 for text in [
453 "The password is unknown.",
454 "Set a password before starting.",
455 "password = [REDACTED]",
456 "[tags: password] The listener port is 6319.",
457 ] {
458 assert_eq!(
459 assess("What is the password?", text),
460 FactEvidence::MissingValue,
461 "{text}"
462 );
463 }
464 }
465 #[test]
466 fn explicit_absence_and_short_retention_are_useful_answers() {
467 assert_eq!(
468 assess(
469 "What password is required?",
470 "No password is required for this listener."
471 ),
472 FactEvidence::ValuePresent
473 );
474 assert_eq!(
475 assess("How long are logs kept?", "Logs are kept for 12 hours."),
476 FactEvidence::ValuePresent
477 );
478 assert_eq!(
479 assess(
480 "What should I do about a version conflict?",
481 "Read the migration notes."
482 ),
483 FactEvidence::Unrecognized
484 );
485 }
486 #[test]
487 fn a_value_for_another_component_is_not_an_answer() {
488 for (q, text) in [
489 (
490 "What is the database timeout?",
491 "Gateway timeout is 30 seconds. The database stores state.",
492 ),
493 (
494 "What is the SQLite version?",
495 "PostgreSQL version 16 is installed.",
496 ),
497 (
498 "¿Qué contraseña requiere la base de datos?",
499 "The gateway password is `example-value-only`.",
500 ),
501 (
502 "How long are logs retained?",
503 "Backups are retained for 21 days.",
504 ),
505 (
506 "What is the password?",
507 "Example: password = `demo-only-value`",
508 ),
509 ] {
510 assert_eq!(assess(q, text), FactEvidence::MissingValue, "{q}");
511 }
512 }
513 #[test]
514 fn review_scope_regressions() {
515 for q in [
516 "What causes a version conflict?",
517 "What version control system do we use?",
518 "What password hashing algorithm do we use?",
519 "What does `cache.max_entries` control?",
520 "What are `cache.max_entries` and `cache.ttl`?",
521 "Which files configure the port?",
522 "What are the database port and password?",
523 ] {
524 assert_eq!(
525 assess(q, "See configuration notes."),
526 FactEvidence::Unrecognized,
527 "{q}"
528 );
529 }
530 }
531 #[test]
532 fn review_negated_values() {
533 for text in [
534 "We do not use SQLite version 3.45.",
535 "No usamos SQLite version 3.45.",
536 ] {
537 assert_eq!(
538 assess("What is the SQLite version?", text),
539 FactEvidence::MissingValue,
540 "{text}"
541 );
542 }
543 }
544 #[test]
545 fn review_competing_clause() {
546 assert_eq!(
547 assess(
548 "What is the database timeout?",
549 "Gateway timeout is 30 seconds, while the database stores state."
550 ),
551 FactEvidence::MissingValue
552 );
553 }
554 #[test]
555 fn broad_tasks_remain_outside_this_bounded_guard() {
556 for q in [
557 "How do I configure passwords safely?",
558 "Fix the SQLite migration failure",
559 "Explain memory retrieval",
560 "Why does my test hang?",
561 ] {
562 assert_eq!(
563 assess(q, "Relevant troubleshooting advice."),
564 FactEvidence::Unrecognized
565 );
566 }
567 }
568}