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 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 §ion.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 §ion.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}