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