Skip to main content

mempal_runtime/
brief.rs

1#![warn(clippy::all)]
2
3use std::collections::BTreeSet;
4use std::path::PathBuf;
5
6use serde::Serialize;
7use thiserror::Error;
8
9use crate::context::{ContextError, ContextItem, ContextPack, ContextRequest, assemble_context};
10use crate::core::types::{AnchorKind, KnowledgeEvidenceRole, MemoryDomain};
11use crate::embed::Embedder;
12
13pub type Result<T> = std::result::Result<T, BriefError>;
14
15#[derive(Debug, Error)]
16pub enum BriefError {
17    #[error("failed to assemble brief context")]
18    Context(#[from] ContextError),
19}
20
21#[derive(Debug, Clone)]
22pub struct BriefRequest {
23    pub query: String,
24    pub domain: MemoryDomain,
25    pub field: String,
26    pub cwd: PathBuf,
27    pub max_items: usize,
28    pub dao_tian_limit: usize,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct CognitiveBrief {
33    pub query: String,
34    pub domain: MemoryDomain,
35    pub field: String,
36    pub summary: BriefSummary,
37    pub key_facts: Vec<BriefFact>,
38    pub evidence: Vec<BriefEvidence>,
39    pub cards: Vec<BriefCard>,
40    pub entities: Vec<String>,
41    pub unresolved_items: Vec<BriefUnresolvedItem>,
42    pub uncertainty: Vec<BriefUncertainty>,
43    pub next_actions: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct BriefSummary {
48    pub narrative: String,
49    pub key_fact_count: usize,
50    pub evidence_count: usize,
51    pub card_count: usize,
52    pub unresolved_count: usize,
53    pub uncertainty_count: usize,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct BriefFact {
58    pub text: String,
59    pub section: String,
60    pub citation: BriefCitation,
61}
62
63#[derive(Debug, Clone, Serialize)]
64pub struct BriefEvidence {
65    pub text: String,
66    pub citation: BriefCitation,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct BriefCard {
71    pub card_id: String,
72    pub text: String,
73    pub citation: BriefCitation,
74    pub evidence_citations: Vec<BriefEvidenceCitation>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct BriefUnresolvedItem {
79    pub text: String,
80    pub citation: BriefCitation,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct BriefUncertainty {
85    pub kind: String,
86    pub message: String,
87    #[serde(skip_serializing_if = "Vec::is_empty", default)]
88    pub citations: Vec<BriefCitation>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct BriefCitation {
93    pub drawer_id: String,
94    pub source_file: String,
95    pub anchor_kind: AnchorKind,
96    pub anchor_id: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub card_id: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize)]
102pub struct BriefEvidenceCitation {
103    pub evidence_drawer_id: String,
104    pub role: KnowledgeEvidenceRole,
105    pub source_file: String,
106}
107
108pub async fn assemble_brief<E: Embedder + ?Sized>(
109    db: &crate::core::db::Database,
110    embedder: &E,
111    request: BriefRequest,
112) -> Result<CognitiveBrief> {
113    let context = assemble_context(
114        db,
115        embedder,
116        ContextRequest {
117            query: request.query,
118            domain: request.domain,
119            field: request.field,
120            cwd: request.cwd,
121            include_evidence: true,
122            include_cards: true,
123            max_items: request.max_items,
124            dao_tian_limit: request.dao_tian_limit,
125            // brief is a separate surface; the P106 distill signal is scoped to
126            // mempal context / mempal_context only.
127            include_distill_suggestions: false,
128        },
129    )
130    .await?;
131    Ok(brief_from_context(context))
132}
133
134pub fn brief_from_context(context: ContextPack) -> CognitiveBrief {
135    let mut key_facts = Vec::new();
136    let mut evidence = Vec::new();
137    let mut cards = Vec::new();
138    let mut unresolved_items = Vec::new();
139    let mut all_text = Vec::new();
140
141    for section in &context.sections {
142        for item in &section.items {
143            all_text.push(item.text.clone());
144            if let Some(card_id) = item.card_id.as_deref() {
145                cards.push(BriefCard {
146                    card_id: card_id.to_string(),
147                    text: item.text.clone(),
148                    citation: citation_from_item(item),
149                    evidence_citations: item
150                        .evidence_citations
151                        .iter()
152                        .map(|citation| BriefEvidenceCitation {
153                            evidence_drawer_id: citation.evidence_drawer_id.clone(),
154                            role: citation.role.clone(),
155                            source_file: citation.source_file.clone(),
156                        })
157                        .collect(),
158                });
159            } else if section.name == "evidence" {
160                evidence.push(BriefEvidence {
161                    text: item.text.clone(),
162                    citation: citation_from_item(item),
163                });
164            } else {
165                key_facts.push(BriefFact {
166                    text: item.text.clone(),
167                    section: section.name.clone(),
168                    citation: citation_from_item(item),
169                });
170            }
171
172            if looks_unresolved(&item.text) {
173                unresolved_items.push(BriefUnresolvedItem {
174                    text: item.text.clone(),
175                    citation: citation_from_item(item),
176                });
177            }
178        }
179    }
180
181    let mut uncertainty = build_uncertainty(&key_facts, &evidence, &cards, &unresolved_items);
182    uncertainty.extend(conflict_uncertainty(&all_text, &context));
183    let next_actions = build_next_actions(&key_facts, &evidence, &cards, &unresolved_items);
184    let entities = extract_entities(&all_text);
185    let summary = BriefSummary {
186        narrative: build_narrative(
187            key_facts.len(),
188            evidence.len(),
189            cards.len(),
190            unresolved_items.len(),
191            uncertainty.len(),
192        ),
193        key_fact_count: key_facts.len(),
194        evidence_count: evidence.len(),
195        card_count: cards.len(),
196        unresolved_count: unresolved_items.len(),
197        uncertainty_count: uncertainty.len(),
198    };
199
200    CognitiveBrief {
201        query: context.query,
202        domain: context.domain,
203        field: context.field,
204        summary,
205        key_facts,
206        evidence,
207        cards,
208        entities,
209        unresolved_items,
210        uncertainty,
211        next_actions,
212    }
213}
214
215fn citation_from_item(item: &ContextItem) -> BriefCitation {
216    BriefCitation {
217        drawer_id: item.drawer_id.clone(),
218        source_file: item.source_file.clone(),
219        anchor_kind: item.anchor_kind.clone(),
220        anchor_id: item.anchor_id.clone(),
221        card_id: item.card_id.clone(),
222    }
223}
224
225fn build_narrative(
226    key_fact_count: usize,
227    evidence_count: usize,
228    card_count: usize,
229    unresolved_count: usize,
230    uncertainty_count: usize,
231) -> String {
232    if key_fact_count == 0 && evidence_count == 0 && card_count == 0 {
233        return "No cited memory was found for this query; treat any answer as unsupported until evidence is ingested.".to_string();
234    }
235
236    format!(
237        "Brief assembled from {key_fact_count} cited key facts, {evidence_count} evidence items, and {card_count} cards; {unresolved_count} unresolved cues and {uncertainty_count} uncertainty signals require review."
238    )
239}
240
241fn build_uncertainty(
242    key_facts: &[BriefFact],
243    evidence: &[BriefEvidence],
244    cards: &[BriefCard],
245    unresolved_items: &[BriefUnresolvedItem],
246) -> Vec<BriefUncertainty> {
247    let mut uncertainty = Vec::new();
248    if evidence.is_empty() {
249        uncertainty.push(BriefUncertainty {
250            kind: "no_evidence".to_string(),
251            message: "No cited evidence was found for this query.".to_string(),
252            citations: Vec::new(),
253        });
254    }
255    if key_facts.is_empty() {
256        uncertainty.push(BriefUncertainty {
257            kind: "no_key_facts".to_string(),
258            message: "No governed knowledge statement was found for this query.".to_string(),
259            citations: Vec::new(),
260        });
261    }
262    if cards.is_empty() {
263        uncertainty.push(BriefUncertainty {
264            kind: "no_cards".to_string(),
265            message: "No active knowledge card was found for this query.".to_string(),
266            citations: Vec::new(),
267        });
268    }
269    if !unresolved_items.is_empty() {
270        uncertainty.push(BriefUncertainty {
271            kind: "unresolved_items".to_string(),
272            message: format!(
273                "{} cited item(s) mention unresolved work or follow-up.",
274                unresolved_items.len()
275            ),
276            citations: unresolved_items
277                .iter()
278                .map(|item| item.citation.clone())
279                .collect(),
280        });
281    }
282    uncertainty
283}
284
285fn conflict_uncertainty(texts: &[String], context: &ContextPack) -> Vec<BriefUncertainty> {
286    let mut citations = Vec::new();
287    for section in &context.sections {
288        for item in &section.items {
289            if mentions_conflict(&item.text) {
290                citations.push(citation_from_item(item));
291            }
292        }
293    }
294    if citations.is_empty() && texts.iter().any(|text| mentions_conflict(text)) {
295        return vec![BriefUncertainty {
296            kind: "conflict_cue".to_string(),
297            message:
298                "Relevant text contains contradiction, rollback, stale, or counterexample language."
299                    .to_string(),
300            citations: Vec::new(),
301        }];
302    }
303    if citations.is_empty() {
304        Vec::new()
305    } else {
306        vec![BriefUncertainty {
307            kind: "conflict_cue".to_string(),
308            message: "Relevant cited text contains contradiction, rollback, stale, or counterexample language.".to_string(),
309            citations,
310        }]
311    }
312}
313
314fn build_next_actions(
315    key_facts: &[BriefFact],
316    evidence: &[BriefEvidence],
317    cards: &[BriefCard],
318    unresolved_items: &[BriefUnresolvedItem],
319) -> Vec<String> {
320    let mut actions = Vec::new();
321    if evidence.is_empty() {
322        actions.push("Ingest or add evidence before relying on this brief.".to_string());
323    }
324    if key_facts.is_empty() {
325        actions
326            .push("Distill candidate knowledge only after supporting evidence exists.".to_string());
327    }
328    if cards.is_empty() {
329        actions.push("Create or retrieve a knowledge card if this topic recurs.".to_string());
330    }
331    if !unresolved_items.is_empty() {
332        actions.push("Review and resolve the cited unresolved items.".to_string());
333    }
334    if actions.is_empty() {
335        actions.push("Review the cited evidence before acting on the brief.".to_string());
336    }
337    actions
338}
339
340fn looks_unresolved(text: &str) -> bool {
341    let lower = text.to_lowercase();
342    [
343        "todo",
344        "action item",
345        "follow up",
346        "unresolved",
347        "remain",
348        "blocked",
349        "next step",
350    ]
351    .iter()
352    .any(|needle| lower.contains(needle))
353}
354
355fn mentions_conflict(text: &str) -> bool {
356    let lower = text.to_lowercase();
357    ["contradiction", "rollback", "stale", "counterexample"]
358        .iter()
359        .any(|needle| lower.contains(needle))
360}
361
362fn extract_entities(texts: &[String]) -> Vec<String> {
363    let mut seen = BTreeSet::new();
364    for text in texts {
365        for raw in text.split(|ch: char| !ch.is_alphanumeric() && ch != '-') {
366            let token = raw.trim_matches('-');
367            if token.chars().count() < 2 || is_entity_stopword(token) {
368                continue;
369            }
370            if token
371                .chars()
372                .next()
373                .is_some_and(|first| first.is_uppercase())
374            {
375                seen.insert(token.to_string());
376            }
377        }
378    }
379    seen.into_iter().collect()
380}
381
382fn is_entity_stopword(token: &str) -> bool {
383    matches!(
384        token,
385        "The" | "This" | "That" | "No" | "Brief" | "Use" | "Review"
386    )
387}