1use std::collections::HashMap;
4
5use futures::stream::{self, StreamExt};
6
7use super::confidence::{ExtractionContext, Provenance};
8use super::crud;
9use super::dedup::{self, ResolvedEntity};
10use super::error::GraphError;
11use super::extract;
12use super::llm::LlmProvider;
13use super::types::*;
14use super::utility;
15use super::GraphMemory;
16
17const LLM_CONCURRENCY: usize = 10;
19
20const USER_TURN_HEADING: &str = "### user";
22const ASSISTANT_TURN_HEADING: &str = "### assistant";
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum ProvenancePolicy {
27 #[default]
30 FromTurnRoles,
31 Fixed(Provenance),
34}
35
36impl ProvenancePolicy {
37 #[must_use]
39 pub fn classify(self, chunk: &str) -> Provenance {
40 match self {
41 Self::Fixed(provenance) => provenance,
42 Self::FromTurnRoles => infer_from_turn_roles(chunk),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
52pub struct IngestContext {
53 session_id: String,
54 log_number: Option<u32>,
55 provenance: ProvenancePolicy,
56}
57
58impl IngestContext {
59 #[must_use]
61 pub fn new(session_id: impl Into<String>, log_number: Option<u32>) -> Self {
62 Self {
63 session_id: session_id.into(),
64 log_number,
65 provenance: ProvenancePolicy::default(),
66 }
67 }
68
69 #[must_use]
71 pub fn with_provenance(mut self, provenance: ProvenancePolicy) -> Self {
72 self.provenance = provenance;
73 self
74 }
75
76 #[must_use]
79 pub fn with_override(self, provenance: Option<Provenance>) -> Self {
80 match provenance {
81 Some(class) => self.with_provenance(ProvenancePolicy::Fixed(class)),
82 None => self,
83 }
84 }
85
86 #[must_use]
88 pub fn session_id(&self) -> &str {
89 &self.session_id
90 }
91
92 #[must_use]
94 pub fn log_number(&self) -> Option<u32> {
95 self.log_number
96 }
97}
98
99fn infer_from_turn_roles(chunk: &str) -> Provenance {
106 let mut saw_user = false;
107 for line in chunk.lines() {
108 let heading = line.trim().to_lowercase();
109 if heading == ASSISTANT_TURN_HEADING {
110 return Provenance::SelfGenerated;
111 }
112 if heading == USER_TURN_HEADING {
113 saw_user = true;
114 }
115 }
116
117 if saw_user {
118 Provenance::User
119 } else {
120 Provenance::SelfGenerated
121 }
122}
123
124pub async fn ingest_archive(
133 gm: &GraphMemory,
134 archive_text: &str,
135 context: &IngestContext,
136 llm: Option<&dyn LlmProvider>,
137) -> Result<IngestionReport, GraphError> {
138 let mut report = IngestionReport::default();
139
140 let chunks = extract::chunk_conversation(archive_text, 500);
141 if chunks.is_empty() {
142 return Ok(report);
143 }
144
145 for (i, chunk) in chunks.iter().enumerate() {
148 let abstract_text = build_episode_abstract(chunk);
149 let episode = NewEpisode {
150 session_id: context.session_id.clone(),
151 abstract_text,
152 overview: None,
153 content: Some(chunk.clone()),
154 log_number: context.log_number,
155 };
156
157 match gm
158 .add_episode_from(episode, context.provenance.classify(chunk))
159 .await
160 {
161 Ok(_) => report.episodes_created += 1,
162 Err(e) => {
163 report.errors.push(format!("episode chunk {i}: {e}"));
164 }
165 }
166 }
167
168 if let Some(llm) = llm {
170 process_extraction(gm, &chunks, context, llm, &mut report).await?;
171 }
172
173 Ok(report)
174}
175
176pub async fn extract_from_archive(
181 gm: &GraphMemory,
182 archive_text: &str,
183 context: &IngestContext,
184 llm: &dyn LlmProvider,
185) -> Result<IngestionReport, GraphError> {
186 let mut report = IngestionReport::default();
187
188 let chunks = extract::chunk_conversation(archive_text, 500);
189 if chunks.is_empty() {
190 return Ok(report);
191 }
192
193 process_extraction(gm, &chunks, context, llm, &mut report).await?;
194
195 Ok(report)
196}
197
198async fn extract_indexed(
205 llm: &dyn LlmProvider,
206 chunk: &str,
207 session_id: &str,
208 log_number: Option<u32>,
209 index: usize,
210) -> (usize, Result<ExtractionResult, GraphError>) {
211 let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
212 (index, result)
213}
214
215async fn process_extraction(
224 gm: &GraphMemory,
225 chunks: &[String],
226 context: &IngestContext,
227 llm: &dyn LlmProvider,
228 report: &mut IngestionReport,
229) -> Result<(), GraphError> {
230 let session_id = context.session_id.as_str();
231 let log_number = context.log_number;
232 let pending: Vec<_> = chunks
238 .iter()
239 .enumerate()
240 .map(|(i, chunk)| extract_indexed(llm, chunk, session_id, log_number, i))
241 .collect();
242 let extraction_results: Vec<(usize, Result<ExtractionResult, GraphError>)> =
243 stream::iter(pending)
244 .buffer_unordered(LLM_CONCURRENCY)
245 .collect()
246 .await;
247
248 let mut all_entities: Vec<ExtractedEntity> = Vec::new();
252 let mut all_relationships: Vec<(Provenance, ExtractedRelationship)> = Vec::new();
253
254 for (i, result) in extraction_results {
255 match result {
256 Ok(extraction) => {
257 let provenance = context.provenance.classify(&chunks[i]);
258 all_entities.extend(extract::flatten_extraction(&extraction));
259 all_relationships.extend(
260 extraction
261 .relationships
262 .into_iter()
263 .map(|rel| (provenance, rel)),
264 );
265 report.estimated_tokens += 2500;
267 }
268 Err(e) => {
269 report.errors.push(format!("extraction chunk {i}: {e}"));
270 }
271 }
272 }
273
274 let deduplicated = local_merge_entities(all_entities);
276
277 let mut name_map: HashMap<String, String> = HashMap::new();
279
280 for candidate in &deduplicated {
281 report.estimated_tokens += 600;
283 match dedup::resolve_entity(gm, llm, candidate, session_id).await {
284 Ok(ResolvedEntity::Created(entity)) => {
285 name_map.insert(candidate.name.clone(), entity.name.clone());
286 report.entity_ids.push(entity.id_string());
287 report.entities_created += 1;
288 }
289 Ok(ResolvedEntity::Merged(entity)) => {
290 name_map.insert(candidate.name.clone(), entity.name.clone());
291 report.entity_ids.push(entity.id_string());
292 report.entities_merged += 1;
293 }
294 Ok(ResolvedEntity::Skipped) => {
295 name_map.insert(candidate.name.clone(), candidate.name.clone());
296 report.entities_skipped += 1;
297 }
298 Err(e) => {
299 report
300 .errors
301 .push(format!("dedup '{}': {}", candidate.name, e));
302 }
303 }
304 }
305
306 for (provenance, rel) in &all_relationships {
308 let from_name = name_map.get(&rel.source).unwrap_or(&rel.source);
309 let to_name = name_map.get(&rel.target).unwrap_or(&rel.target);
310
311 if let Some(existing) =
313 find_existing_relationship(gm, from_name, to_name, &rel.rel_type).await
314 {
315 let mut evidence = existing.edge_evidence();
320 evidence.corroborate(*provenance, gm.provenance_weights());
321 if let Err(e) =
322 crud::reinforce_relationship(gm.db(), &existing.id_string(), evidence).await
323 {
324 report
325 .errors
326 .push(format!("confidence update {from_name} -> {to_name}: {e}"));
327 }
328 report.relationships_skipped += 1;
329 continue;
330 }
331
332 let context: ExtractionContext = rel
334 .confidence
335 .as_deref()
336 .and_then(|s| s.parse().ok())
337 .unwrap_or(ExtractionContext::Inferred);
338
339 let new_rel = NewRelationship {
340 from_entity: from_name.clone(),
341 to_entity: to_name.clone(),
342 rel_type: rel.rel_type.clone(),
343 description: rel.description.clone(),
344 confidence: Some(context.prior() as f32),
345 source: Some(session_id.to_string()),
346 };
347
348 match gm.add_relationship(new_rel).await {
349 Ok(_) => report.relationships_created += 1,
350 Err(e) => {
351 report
352 .errors
353 .push(format!("relationship {from_name} -> {to_name}: {e}"));
354 }
355 }
356 }
357
358 if let Err(e) = utility::record_session_use(gm.db(), session_id, &report.entity_ids).await {
362 report
363 .errors
364 .push(format!("session use record for {session_id}: {e}"));
365 }
366
367 Ok(())
368}
369
370fn local_merge_entities(entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
379 let mut seen: HashMap<String, ExtractedEntity> = HashMap::new();
380 let mut order: Vec<String> = Vec::new();
381
382 for entity in entities {
383 let key = entity.name.to_lowercase();
384 if let Some(existing) = seen.get_mut(&key) {
385 if entity.abstract_text.len() > existing.abstract_text.len() {
387 existing.abstract_text = entity.abstract_text;
388 }
389 if let Some(new_overview) = entity.overview {
391 existing.overview = Some(match &existing.overview {
392 Some(o) => format!("{o}\n\n{new_overview}"),
393 None => new_overview,
394 });
395 }
396 if let Some(new_content) = entity.content {
398 existing.content = Some(match &existing.content {
399 Some(c) => format!("{c}\n\n{new_content}"),
400 None => new_content,
401 });
402 }
403 if let Some(new_attrs) = entity.attributes {
405 existing.attributes = Some(match &existing.attributes {
406 Some(a) => merge_json(a, &new_attrs),
407 None => new_attrs,
408 });
409 }
410 } else {
411 order.push(key.clone());
412 seen.insert(key, entity);
413 }
414 }
415
416 order.into_iter().filter_map(|k| seen.remove(&k)).collect()
418}
419
420use super::util::merge_json_objects as merge_json;
421
422fn build_episode_abstract(chunk: &str) -> String {
424 let chars: String = chunk.chars().take(200).collect();
425 if chars.len() < chunk.len() {
426 format!("{}...", chars.trim())
427 } else {
428 chars.trim().to_string()
429 }
430}
431
432async fn find_existing_relationship(
435 gm: &GraphMemory,
436 from_name: &str,
437 to_name: &str,
438 rel_type: &str,
439) -> Option<Relationship> {
440 let rels = gm
441 .get_relationships(from_name, Direction::Outgoing)
442 .await
443 .ok()?;
444 let to_entity = gm.get_entity(to_name).await.ok()??;
445 let to_id = to_entity.id_string();
446
447 rels.into_iter().find(|r| {
448 r.rel_type == rel_type && {
449 let out_id = match &r.to_id {
450 serde_json::Value::String(s) => s.clone(),
451 other => other.to_string(),
452 };
453 out_id == to_id
454 }
455 })
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn episode_abstract_truncates() {
464 let long = "x".repeat(500);
465 let abs = build_episode_abstract(&long);
466 assert!(abs.len() < 210);
467 assert!(abs.ends_with("..."));
468 }
469
470 #[test]
471 fn episode_abstract_short_unchanged() {
472 let short = "Hello world";
473 let abs = build_episode_abstract(short);
474 assert_eq!(abs, "Hello world");
475 }
476
477 #[test]
478 fn user_only_chunk_is_credited_to_the_human() {
479 let chunk = "### User\n\nI moved the repo to /opt/recall-echo.";
480 assert_eq!(infer_from_turn_roles(chunk), Provenance::User);
481 }
482
483 #[test]
484 fn assistant_turns_make_a_chunk_self_authored() {
485 let chunk = "### Assistant\n\nThe repo now lives at /opt/recall-echo.";
486 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
487 }
488
489 #[test]
490 fn mixed_chunk_is_self_authored() {
491 let chunk = "### User\n\nWhere does it live?\n\n---\n\n### Assistant\n\n/opt.";
494 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
495 }
496
497 #[test]
498 fn text_without_role_headings_is_self_authored() {
499 let chunk = "A pipeline document with no conversation structure at all.";
500 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
501 }
502
503 #[test]
504 fn heading_matching_is_exact() {
505 let chunk = "### Users of the system\n\nThey prefer NeoVim.";
507 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
508 }
509
510 #[test]
511 fn fixed_policy_overrides_turn_roles() {
512 let chunk = "### User\n\nA quote from a paper.";
513 let policy = ProvenancePolicy::Fixed(Provenance::External);
514 assert_eq!(policy.classify(chunk), Provenance::External);
515 assert_eq!(
516 ProvenancePolicy::FromTurnRoles.classify(chunk),
517 Provenance::User
518 );
519 }
520
521 #[test]
522 fn context_override_is_applied_only_when_present() {
523 let context = IngestContext::new("s1", Some(7));
524 assert_eq!(context.session_id(), "s1");
525 assert_eq!(context.log_number(), Some(7));
526
527 let inferring = context.clone().with_override(None);
528 assert_eq!(inferring.provenance, ProvenancePolicy::FromTurnRoles);
529
530 let forced = context.with_override(Some(Provenance::External));
531 assert_eq!(
532 forced.provenance,
533 ProvenancePolicy::Fixed(Provenance::External)
534 );
535 }
536}