mempal_runtime/factcheck/
mod.rs1use rmcp::schemars::{self, JsonSchema};
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::core::db::{Database, DbError};
16
17pub mod contradictions;
18pub mod names;
19pub mod relations;
20
21#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23pub enum FactIssue {
24 SimilarNameConflict {
25 mentioned: String,
26 known_entity: String,
27 #[schemars(with = "i64")]
28 edit_distance: usize,
29 },
30 RelationContradiction {
31 subject: String,
32 text_claim: String,
33 kg_fact: String,
34 triple_id: String,
35 source_drawer: Option<String>,
36 },
37 StaleFact {
38 subject: String,
39 predicate: String,
40 object: String,
41 valid_to: String,
43 triple_id: String,
44 },
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
48pub struct FactCheckReport {
49 pub issues: Vec<FactIssue>,
50 pub checked_entities: Vec<String>,
51 #[schemars(with = "i64")]
52 pub kg_triples_scanned: usize,
53}
54
55#[derive(Debug, Error)]
56pub enum FactCheckError {
57 #[error("db error: {0}")]
58 Db(#[from] DbError),
59 #[error("invalid scope: {0}")]
60 InvalidScope(String),
61 #[error("invalid `now`: {0}")]
62 InvalidNow(String),
63}
64
65pub fn validate_scope<'a>(
66 wing: Option<&'a str>,
67 room: Option<&'a str>,
68) -> Result<Option<(&'a str, Option<&'a str>)>, FactCheckError> {
69 match (wing.map(str::trim), room.map(str::trim)) {
70 (None, Some(_)) => Err(FactCheckError::InvalidScope(
71 "room requires wing".to_string(),
72 )),
73 (Some(""), _) => Err(FactCheckError::InvalidScope(
74 "wing must not be empty".to_string(),
75 )),
76 (_, Some("")) => Err(FactCheckError::InvalidScope(
77 "room must not be empty".to_string(),
78 )),
79 (Some(wing), room) => Ok(Some((wing, room))),
80 (None, None) => Ok(None),
81 }
82}
83
84pub fn resolve_now(now: Option<&str>) -> Result<u64, FactCheckError> {
85 match now {
86 Some(raw) => {
87 let ts = crate::cowork::peek::parse_rfc3339(raw).ok_or_else(|| {
88 FactCheckError::InvalidNow(format!("expected RFC3339 timestamp, got `{raw}`"))
89 })?;
90 u64::try_from(ts).map_err(|_| {
91 FactCheckError::InvalidNow(format!(
92 "timestamp before Unix epoch is unsupported: {raw}"
93 ))
94 })
95 }
96 None => {
97 use std::time::{SystemTime, UNIX_EPOCH};
98 Ok(SystemTime::now()
99 .duration_since(UNIX_EPOCH)
100 .map(|d| d.as_secs())
101 .unwrap_or(0))
102 }
103 }
104}
105
106pub fn check(
116 text: &str,
117 db: &Database,
118 now_unix_secs: u64,
119 scope: Option<(&str, Option<&str>)>,
120) -> Result<FactCheckReport, FactCheckError> {
121 let scope = match scope {
122 Some((wing, room)) => validate_scope(Some(wing), room)?,
123 None => None,
124 };
125 let text_names = names::candidates_from_text(text);
126 let known = names::query_known_entities(db, scope)?;
127 let mut issues = names::detect_similar_name_conflicts(&text_names, &known);
128
129 let text_triples = relations::extract_triples(text);
130 let kg_triples_scanned = db.triple_count().unwrap_or(0) as usize;
131
132 issues.extend(contradictions::detect_relation_contradictions(
133 db,
134 &text_triples,
135 )?);
136 issues.extend(contradictions::detect_stale_facts(
137 db,
138 &text_triples,
139 now_unix_secs,
140 )?);
141
142 Ok(FactCheckReport {
143 issues,
144 checked_entities: text_names,
145 kg_triples_scanned,
146 })
147}