1use std::collections::HashMap;
8
9use futures::stream::{self, StreamExt};
10
11use super::confidence::{ExtractionContext, Provenance};
12use super::crud;
13use super::dedup::{self, Resolution, ResolvedEntity};
14use super::error::GraphError;
15use super::extract;
16use super::llm::LlmProvider;
17use super::types::*;
18use super::utility;
19use super::GraphMemory;
20
21const LLM_CONCURRENCY: usize = 10;
23
24const USER_TURN_HEADING: &str = "### user";
26const ASSISTANT_TURN_HEADING: &str = "### assistant";
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum ProvenancePolicy {
31 #[default]
34 FromTurnRoles,
35 Fixed(Provenance),
38}
39
40impl ProvenancePolicy {
41 #[must_use]
43 pub fn classify(self, chunk: &str) -> Provenance {
44 match self {
45 Self::Fixed(provenance) => provenance,
46 Self::FromTurnRoles => infer_from_turn_roles(chunk),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
56pub struct IngestContext {
57 session_id: String,
58 log_number: Option<u32>,
59 provenance: ProvenancePolicy,
60}
61
62impl IngestContext {
63 #[must_use]
65 pub fn new(session_id: impl Into<String>, log_number: Option<u32>) -> Self {
66 Self {
67 session_id: session_id.into(),
68 log_number,
69 provenance: ProvenancePolicy::default(),
70 }
71 }
72
73 #[must_use]
75 pub fn with_provenance(mut self, provenance: ProvenancePolicy) -> Self {
76 self.provenance = provenance;
77 self
78 }
79
80 #[must_use]
83 pub fn with_override(self, provenance: Option<Provenance>) -> Self {
84 match provenance {
85 Some(class) => self.with_provenance(ProvenancePolicy::Fixed(class)),
86 None => self,
87 }
88 }
89
90 #[must_use]
92 pub fn session_id(&self) -> &str {
93 &self.session_id
94 }
95
96 #[must_use]
98 pub fn log_number(&self) -> Option<u32> {
99 self.log_number
100 }
101}
102
103fn infer_from_turn_roles(chunk: &str) -> Provenance {
110 let mut saw_user = false;
111 for line in chunk.lines() {
112 let heading = line.trim().to_lowercase();
113 if heading == ASSISTANT_TURN_HEADING {
114 return Provenance::SelfGenerated;
115 }
116 if heading == USER_TURN_HEADING {
117 saw_user = true;
118 }
119 }
120
121 if saw_user {
122 Provenance::User
123 } else {
124 Provenance::SelfGenerated
125 }
126}
127
128pub async fn ingest_archive(
137 gm: &GraphMemory,
138 archive_text: &str,
139 context: &IngestContext,
140 llm: Option<&dyn LlmProvider>,
141) -> Result<IngestionReport, GraphError> {
142 let mut report = IngestionReport::default();
143
144 let chunks = extract::chunk_conversation(archive_text, 500);
145 if chunks.is_empty() {
146 return Ok(report);
147 }
148
149 for (i, chunk) in chunks.iter().enumerate() {
152 let abstract_text = build_episode_abstract(chunk);
153 let episode = NewEpisode {
154 session_id: context.session_id.clone(),
155 abstract_text,
156 overview: None,
157 content: Some(chunk.clone()),
158 log_number: context.log_number,
159 };
160
161 match gm
162 .add_episode_from(episode, context.provenance.classify(chunk))
163 .await
164 {
165 Ok(_) => report.episodes_created += 1,
166 Err(e) => {
167 report.errors.push(format!("episode chunk {i}: {e}"));
168 }
169 }
170 }
171
172 if let Some(llm) = llm {
174 process_extraction(gm, &chunks, context, llm, &mut report).await?;
175 }
176
177 Ok(report)
178}
179
180pub async fn extract_from_archive(
185 gm: &GraphMemory,
186 archive_text: &str,
187 context: &IngestContext,
188 llm: &dyn LlmProvider,
189) -> Result<IngestionReport, GraphError> {
190 let mut report = IngestionReport::default();
191
192 let chunks = extract::chunk_conversation(archive_text, 500);
193 if chunks.is_empty() {
194 return Ok(report);
195 }
196
197 process_extraction(gm, &chunks, context, llm, &mut report).await?;
198
199 Ok(report)
200}
201
202async fn extract_indexed(
209 llm: &dyn LlmProvider,
210 chunk: &str,
211 session_id: &str,
212 log_number: Option<u32>,
213 index: usize,
214) -> (usize, Result<ExtractionResult, GraphError>) {
215 let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
216 (index, result)
217}
218
219async fn process_extraction(
228 gm: &GraphMemory,
229 chunks: &[String],
230 context: &IngestContext,
231 llm: &dyn LlmProvider,
232 report: &mut IngestionReport,
233) -> Result<(), GraphError> {
234 let session_id = context.session_id.as_str();
235 let log_number = context.log_number;
236 let pending: Vec<_> = chunks
242 .iter()
243 .enumerate()
244 .map(|(i, chunk)| extract_indexed(llm, chunk, session_id, log_number, i))
245 .collect();
246 let extraction_results: Vec<(usize, Result<ExtractionResult, GraphError>)> =
247 stream::iter(pending)
248 .buffer_unordered(LLM_CONCURRENCY)
249 .collect()
250 .await;
251
252 let mut all_entities: Vec<ExtractedEntity> = Vec::new();
256 let mut all_relationships: Vec<(Provenance, ExtractedRelationship)> = Vec::new();
257
258 for (i, result) in extraction_results {
259 match result {
260 Ok(extraction) => {
261 let provenance = context.provenance.classify(&chunks[i]);
262 all_entities.extend(extract::flatten_extraction(&extraction));
263 all_relationships.extend(
264 extraction
265 .relationships
266 .into_iter()
267 .map(|rel| (provenance, rel)),
268 );
269 report.estimated_tokens += 2500;
271 }
272 Err(e) => {
273 report.errors.push(format!("extraction chunk {i}: {e}"));
274 }
275 }
276 }
277
278 let deduplicated = local_merge_entities(all_entities);
280
281 let mut name_map: HashMap<String, String> = HashMap::new();
283
284 for candidate in &deduplicated {
285 match dedup::resolve_entity(gm, llm, candidate, session_id).await {
286 Ok(resolution) => record_resolution(report, &mut name_map, candidate, resolution),
287 Err(e) => {
288 report
289 .errors
290 .push(format!("dedup '{}': {}", candidate.name, e));
291 }
292 }
293 }
294
295 for (provenance, rel) in &all_relationships {
297 let from_name = name_map.get(&rel.source).unwrap_or(&rel.source);
298 let to_name = name_map.get(&rel.target).unwrap_or(&rel.target);
299
300 if let Some(existing) =
302 find_existing_relationship(gm, from_name, to_name, &rel.rel_type).await
303 {
304 let mut evidence = existing.edge_evidence();
309 evidence.corroborate(*provenance, gm.provenance_weights());
310 if let Err(e) =
311 crud::reinforce_relationship(gm.db(), &existing.id_string(), evidence).await
312 {
313 report
314 .errors
315 .push(format!("confidence update {from_name} -> {to_name}: {e}"));
316 }
317 report.relationships_skipped += 1;
318 continue;
319 }
320
321 let context: ExtractionContext = rel
323 .confidence
324 .as_deref()
325 .and_then(|s| s.parse().ok())
326 .unwrap_or(ExtractionContext::Inferred);
327
328 let new_rel = NewRelationship {
329 from_entity: from_name.clone(),
330 to_entity: to_name.clone(),
331 rel_type: rel.rel_type.clone(),
332 description: rel.description.clone(),
333 confidence: Some(context.prior() as f32),
334 source: Some(session_id.to_string()),
335 };
336
337 match gm.add_relationship(new_rel).await {
338 Ok(_) => report.relationships_created += 1,
339 Err(e) => {
340 report
341 .errors
342 .push(format!("relationship {from_name} -> {to_name}: {e}"));
343 }
344 }
345 }
346
347 if let Err(e) = utility::record_session_use(gm.db(), session_id, &report.entity_ids).await {
351 report
352 .errors
353 .push(format!("session use record for {session_id}: {e}"));
354 }
355
356 Ok(())
357}
358
359fn record_resolution(
362 report: &mut IngestionReport,
363 name_map: &mut HashMap<String, String>,
364 candidate: &ExtractedEntity,
365 resolution: Resolution,
366) {
367 if resolution.path.used_llm() {
368 report.dedup_llm_calls += 1;
371 report.estimated_tokens += 600;
372 } else {
373 report.dedup_fast_path += 1;
374 }
375
376 match resolution.entity {
377 ResolvedEntity::Created(entity) => {
378 name_map.insert(candidate.name.clone(), entity.name.clone());
379 report.entity_ids.push(entity.id_string());
380 report.entities_created += 1;
381 }
382 ResolvedEntity::Merged(entity) => {
383 name_map.insert(candidate.name.clone(), entity.name.clone());
384 report.entity_ids.push(entity.id_string());
385 report.entities_merged += 1;
386 }
387 ResolvedEntity::Skipped => {
388 name_map.insert(candidate.name.clone(), candidate.name.clone());
389 report.entities_skipped += 1;
390 }
391 }
392}
393
394fn local_merge_entities(entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
403 let mut seen: HashMap<String, ExtractedEntity> = HashMap::new();
404 let mut order: Vec<String> = Vec::new();
405
406 for entity in entities {
407 let key = entity.name.to_lowercase();
408 if let Some(existing) = seen.get_mut(&key) {
409 if entity.abstract_text.len() > existing.abstract_text.len() {
411 existing.abstract_text = entity.abstract_text;
412 }
413 if let Some(new_overview) = entity.overview {
415 existing.overview = Some(match &existing.overview {
416 Some(o) => format!("{o}\n\n{new_overview}"),
417 None => new_overview,
418 });
419 }
420 if let Some(new_content) = entity.content {
422 existing.content = Some(match &existing.content {
423 Some(c) => format!("{c}\n\n{new_content}"),
424 None => new_content,
425 });
426 }
427 if let Some(new_attrs) = entity.attributes {
429 existing.attributes = Some(match &existing.attributes {
430 Some(a) => merge_json(a, &new_attrs),
431 None => new_attrs,
432 });
433 }
434 } else {
435 order.push(key.clone());
436 seen.insert(key, entity);
437 }
438 }
439
440 order.into_iter().filter_map(|k| seen.remove(&k)).collect()
442}
443
444use super::util::merge_json_objects as merge_json;
445
446const EPISODE_ABSTRACT_MAX_CHARS: usize = 1_000;
455
456const MIN_BOUNDARY_FRACTION: f64 = 0.6;
459
460fn build_episode_abstract(chunk: &str) -> String {
463 let trimmed = chunk.trim();
464 if trimmed.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS {
465 return trimmed.to_string();
466 }
467
468 let window: String = trimmed.chars().take(EPISODE_ABSTRACT_MAX_CHARS).collect();
469 let cut = truncation_point(&window);
470 format!("{}...", window[..cut].trim_end())
471}
472
473fn truncation_point(window: &str) -> usize {
477 let floor = (window.len() as f64 * MIN_BOUNDARY_FRACTION) as usize;
478
479 let after_sentence = window
480 .char_indices()
481 .rev()
482 .find(|(_, c)| matches!(c, '.' | '!' | '?' | '\n'))
483 .map(|(i, c)| i + c.len_utf8());
484 if let Some(cut) = after_sentence.filter(|&cut| cut >= floor) {
485 return cut;
486 }
487
488 window
489 .char_indices()
490 .rev()
491 .find(|(_, c)| c.is_whitespace())
492 .map(|(i, _)| i)
493 .filter(|&cut| cut >= floor)
494 .unwrap_or(window.len())
495}
496
497async fn find_existing_relationship(
500 gm: &GraphMemory,
501 from_name: &str,
502 to_name: &str,
503 rel_type: &str,
504) -> Option<Relationship> {
505 let rels = gm
506 .get_relationships(from_name, Direction::Outgoing)
507 .await
508 .ok()?;
509 let to_entity = gm.get_entity(to_name).await.ok()??;
510 let to_id = to_entity.id_string();
511
512 rels.into_iter().find(|r| {
513 r.rel_type == rel_type && {
514 let out_id = match &r.to_id {
515 serde_json::Value::String(s) => s.clone(),
516 other => other.to_string(),
517 };
518 out_id == to_id
519 }
520 })
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn episode_abstract_truncates_at_the_cap() {
529 let long = "x".repeat(EPISODE_ABSTRACT_MAX_CHARS * 2);
530 let abs = build_episode_abstract(&long);
531 assert!(abs.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS + 3);
532 assert!(abs.ends_with("..."));
533 }
534
535 #[test]
536 fn episode_abstract_short_unchanged() {
537 let short = "Hello world";
538 let abs = build_episode_abstract(short);
539 assert_eq!(abs, "Hello world");
540 }
541
542 #[test]
544 fn episode_abstract_keeps_text_the_old_cap_would_have_cut() {
545 let chunk = format!(
546 "{} Currently, my favourite is Kansas City Masterpiece.",
547 "Padding sentence about barbecue. ".repeat(8)
548 );
549 assert!(chunk.chars().count() > 200);
550 let abs = build_episode_abstract(&chunk);
551 assert!(abs.contains("Kansas City Masterpiece"));
552 }
553
554 #[test]
557 fn episode_abstract_cuts_at_a_sentence_boundary() {
558 let chunk = format!(
559 "{}My favourite is Kansas City Masterpiece and nothing else comes close at all",
560 "The barbecue discussion continued at length. ".repeat(22)
561 );
562 let abs = build_episode_abstract(&chunk);
563 assert!(abs.ends_with("at length...."), "unexpected tail: {abs:?}");
564 assert!(!abs.contains("Kansas"));
565 }
566
567 #[test]
569 fn episode_abstract_never_cuts_mid_word() {
570 let chunk = "barbecue ".repeat(300);
571 let abs = build_episode_abstract(&chunk);
572 let body = abs.strip_suffix("...").expect("truncated");
573 assert!(
574 body.ends_with("barbecue"),
575 "cut landed mid-word: {:?}",
576 &body[body.len().saturating_sub(20)..]
577 );
578 }
579
580 #[test]
581 fn user_only_chunk_is_credited_to_the_human() {
582 let chunk = "### User\n\nI moved the repo to /opt/recall-echo.";
583 assert_eq!(infer_from_turn_roles(chunk), Provenance::User);
584 }
585
586 #[test]
587 fn assistant_turns_make_a_chunk_self_authored() {
588 let chunk = "### Assistant\n\nThe repo now lives at /opt/recall-echo.";
589 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
590 }
591
592 #[test]
593 fn mixed_chunk_is_self_authored() {
594 let chunk = "### User\n\nWhere does it live?\n\n---\n\n### Assistant\n\n/opt.";
597 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
598 }
599
600 #[test]
601 fn text_without_role_headings_is_self_authored() {
602 let chunk = "A pipeline document with no conversation structure at all.";
603 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
604 }
605
606 #[test]
607 fn heading_matching_is_exact() {
608 let chunk = "### Users of the system\n\nThey prefer NeoVim.";
610 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
611 }
612
613 #[test]
614 fn fixed_policy_overrides_turn_roles() {
615 let chunk = "### User\n\nA quote from a paper.";
616 let policy = ProvenancePolicy::Fixed(Provenance::External);
617 assert_eq!(policy.classify(chunk), Provenance::External);
618 assert_eq!(
619 ProvenancePolicy::FromTurnRoles.classify(chunk),
620 Provenance::User
621 );
622 }
623
624 #[test]
625 fn context_override_is_applied_only_when_present() {
626 let context = IngestContext::new("s1", Some(7));
627 assert_eq!(context.session_id(), "s1");
628 assert_eq!(context.log_number(), Some(7));
629
630 let inferring = context.clone().with_override(None);
631 assert_eq!(inferring.provenance, ProvenancePolicy::FromTurnRoles);
632
633 let forced = context.with_override(Some(Provenance::External));
634 assert_eq!(
635 forced.provenance,
636 ProvenancePolicy::Fixed(Provenance::External)
637 );
638 }
639}