1use std::collections::HashMap;
8
9use futures::stream::{self, StreamExt};
10
11use super::confidence::{ExtractionContext, Observation, Provenance};
12use super::crud;
13use super::dedup::{self, Resolution, ResolvedEntity};
14use super::error::GraphError;
15use super::extract;
16use super::llm::{LlmProvider, TokenUsage};
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<ChunkExtraction, GraphError>) {
215 let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
216 (index, result)
217}
218
219type ChunkExtraction = (ExtractionResult, Option<TokenUsage>);
221
222const ESTIMATED_EXTRACTION_TOKENS: u64 = 2_500;
225
226const ESTIMATED_DEDUP_TOKENS: u64 = 600;
229
230async fn process_extraction(
239 gm: &GraphMemory,
240 chunks: &[String],
241 context: &IngestContext,
242 llm: &dyn LlmProvider,
243 report: &mut IngestionReport,
244) -> Result<(), GraphError> {
245 let session_id = context.session_id.as_str();
246 let log_number = context.log_number;
247 let pending: Vec<_> = chunks
253 .iter()
254 .enumerate()
255 .map(|(i, chunk)| extract_indexed(llm, chunk, session_id, log_number, i))
256 .collect();
257 let extraction_results: Vec<(usize, Result<ChunkExtraction, GraphError>)> =
258 stream::iter(pending)
259 .buffer_unordered(LLM_CONCURRENCY)
260 .collect()
261 .await;
262
263 let mut all_entities: Vec<ExtractedEntity> = Vec::new();
267 let mut all_relationships: Vec<(Provenance, ExtractedRelationship)> = Vec::new();
268
269 for (i, result) in extraction_results {
270 match result {
271 Ok((extraction, usage)) => {
272 let provenance = context.provenance.classify(&chunks[i]);
273 all_entities.extend(extract::flatten_extraction(&extraction));
274 all_relationships.extend(
275 extraction
276 .relationships
277 .into_iter()
278 .map(|rel| (provenance, rel)),
279 );
280 bill(report, usage, ESTIMATED_EXTRACTION_TOKENS);
281 }
282 Err(e) => {
283 report.errors.push(format!("extraction chunk {i}: {e}"));
284 }
285 }
286 }
287
288 let deduplicated = local_merge_entities(all_entities);
290
291 let mut name_map: HashMap<String, String> = HashMap::new();
293
294 for candidate in &deduplicated {
295 match dedup::resolve_entity(gm, llm, candidate, session_id).await {
296 Ok(resolution) => record_resolution(report, &mut name_map, candidate, resolution),
297 Err(e) => {
298 report
299 .errors
300 .push(format!("dedup '{}': {}", candidate.name, e));
301 }
302 }
303 }
304
305 for (provenance, rel) in &all_relationships {
307 let from_name = name_map.get(&rel.source).unwrap_or(&rel.source);
308 let to_name = name_map.get(&rel.target).unwrap_or(&rel.target);
309
310 if let Some(existing) =
312 find_existing_relationship(gm, from_name, to_name, &rel.rel_type).await
313 {
314 if let Err(e) = record_reextraction(gm, &existing, *provenance).await {
315 report
316 .errors
317 .push(format!("confidence update {from_name} -> {to_name}: {e}"));
318 }
319 report.relationships_skipped += 1;
320 continue;
321 }
322
323 let context: ExtractionContext = rel
325 .confidence
326 .as_deref()
327 .and_then(|s| s.parse().ok())
328 .unwrap_or(ExtractionContext::Inferred);
329
330 let new_rel = NewRelationship {
331 from_entity: from_name.clone(),
332 to_entity: to_name.clone(),
333 rel_type: rel.rel_type.clone(),
334 description: rel.description.clone(),
335 confidence: Some(context.prior() as f32),
336 source: Some(session_id.to_string()),
337 };
338
339 match gm.add_relationship(new_rel).await {
340 Ok(_) => report.relationships_created += 1,
341 Err(e) => {
342 report
343 .errors
344 .push(format!("relationship {from_name} -> {to_name}: {e}"));
345 }
346 }
347 }
348
349 if let Err(e) = utility::record_session_use(gm.db(), session_id, &report.entity_ids).await {
353 report
354 .errors
355 .push(format!("session use record for {session_id}: {e}"));
356 }
357
358 Ok(())
359}
360
361async fn record_reextraction(
374 gm: &GraphMemory,
375 existing: &Relationship,
376 provenance: Provenance,
377) -> Result<(), GraphError> {
378 let observation = Observation::Corroborating;
379 let mut evidence = existing.edge_evidence();
380 evidence.record(observation, provenance, gm.provenance_weights());
381 crud::record_observation(gm.db(), &existing.id_string(), evidence, observation).await
382}
383
384fn record_resolution(
387 report: &mut IngestionReport,
388 name_map: &mut HashMap<String, String>,
389 candidate: &ExtractedEntity,
390 resolution: Resolution,
391) {
392 if resolution.path.used_llm() {
393 report.dedup_llm_calls += 1;
395 bill(report, resolution.usage, ESTIMATED_DEDUP_TOKENS);
396 } else {
397 report.dedup_fast_path += 1;
398 }
399
400 match resolution.entity {
401 ResolvedEntity::Created(entity) => {
402 name_map.insert(candidate.name.clone(), entity.name.clone());
403 report.entity_ids.push(entity.id_string());
404 report.entities_created += 1;
405 }
406 ResolvedEntity::Merged(entity) => {
407 name_map.insert(candidate.name.clone(), entity.name.clone());
408 report.entity_ids.push(entity.id_string());
409 report.entities_merged += 1;
410 }
411 ResolvedEntity::Skipped => {
412 name_map.insert(candidate.name.clone(), candidate.name.clone());
413 report.entities_skipped += 1;
414 }
415 }
416}
417
418fn bill(report: &mut IngestionReport, usage: Option<TokenUsage>, estimate: u64) {
425 match usage {
426 Some(usage) => report.measured_tokens += usage.total(),
427 None => report.estimated_tokens += estimate,
428 }
429}
430
431fn local_merge_entities(entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
440 let mut seen: HashMap<String, ExtractedEntity> = HashMap::new();
441 let mut order: Vec<String> = Vec::new();
442
443 for entity in entities {
444 let key = entity.name.to_lowercase();
445 if let Some(existing) = seen.get_mut(&key) {
446 if entity.abstract_text.len() > existing.abstract_text.len() {
448 existing.abstract_text = entity.abstract_text;
449 }
450 if let Some(new_overview) = entity.overview {
452 existing.overview = Some(match &existing.overview {
453 Some(o) => format!("{o}\n\n{new_overview}"),
454 None => new_overview,
455 });
456 }
457 if let Some(new_content) = entity.content {
459 existing.content = Some(match &existing.content {
460 Some(c) => format!("{c}\n\n{new_content}"),
461 None => new_content,
462 });
463 }
464 if let Some(new_attrs) = entity.attributes {
466 existing.attributes = Some(match &existing.attributes {
467 Some(a) => merge_json(a, &new_attrs),
468 None => new_attrs,
469 });
470 }
471 } else {
472 order.push(key.clone());
473 seen.insert(key, entity);
474 }
475 }
476
477 order.into_iter().filter_map(|k| seen.remove(&k)).collect()
479}
480
481use super::util::merge_json_objects as merge_json;
482
483const EPISODE_ABSTRACT_MAX_CHARS: usize = 1_000;
492
493const MIN_BOUNDARY_FRACTION: f64 = 0.6;
496
497fn build_episode_abstract(chunk: &str) -> String {
500 let trimmed = chunk.trim();
501 if trimmed.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS {
502 return trimmed.to_string();
503 }
504
505 let window: String = trimmed.chars().take(EPISODE_ABSTRACT_MAX_CHARS).collect();
506 let cut = truncation_point(&window);
507 format!("{}...", window[..cut].trim_end())
508}
509
510fn truncation_point(window: &str) -> usize {
514 let floor = (window.len() as f64 * MIN_BOUNDARY_FRACTION) as usize;
515
516 let after_sentence = window
517 .char_indices()
518 .rev()
519 .find(|(_, c)| matches!(c, '.' | '!' | '?' | '\n'))
520 .map(|(i, c)| i + c.len_utf8());
521 if let Some(cut) = after_sentence.filter(|&cut| cut >= floor) {
522 return cut;
523 }
524
525 window
526 .char_indices()
527 .rev()
528 .find(|(_, c)| c.is_whitespace())
529 .map(|(i, _)| i)
530 .filter(|&cut| cut >= floor)
531 .unwrap_or(window.len())
532}
533
534async fn find_existing_relationship(
537 gm: &GraphMemory,
538 from_name: &str,
539 to_name: &str,
540 rel_type: &str,
541) -> Option<Relationship> {
542 let rels = gm
543 .get_relationships(from_name, Direction::Outgoing)
544 .await
545 .ok()?;
546 let to_entity = gm.get_entity(to_name).await.ok()??;
547 let to_id = to_entity.id_string();
548
549 rels.into_iter().find(|r| {
550 r.rel_type == rel_type && {
551 let out_id = match &r.to_id {
552 serde_json::Value::String(s) => s.clone(),
553 other => other.to_string(),
554 };
555 out_id == to_id
556 }
557 })
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn episode_abstract_truncates_at_the_cap() {
566 let long = "x".repeat(EPISODE_ABSTRACT_MAX_CHARS * 2);
567 let abs = build_episode_abstract(&long);
568 assert!(abs.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS + 3);
569 assert!(abs.ends_with("..."));
570 }
571
572 #[test]
573 fn episode_abstract_short_unchanged() {
574 let short = "Hello world";
575 let abs = build_episode_abstract(short);
576 assert_eq!(abs, "Hello world");
577 }
578
579 #[test]
581 fn episode_abstract_keeps_text_the_old_cap_would_have_cut() {
582 let chunk = format!(
583 "{} Currently, my favourite is Kansas City Masterpiece.",
584 "Padding sentence about barbecue. ".repeat(8)
585 );
586 assert!(chunk.chars().count() > 200);
587 let abs = build_episode_abstract(&chunk);
588 assert!(abs.contains("Kansas City Masterpiece"));
589 }
590
591 #[test]
594 fn episode_abstract_cuts_at_a_sentence_boundary() {
595 let chunk = format!(
596 "{}My favourite is Kansas City Masterpiece and nothing else comes close at all",
597 "The barbecue discussion continued at length. ".repeat(22)
598 );
599 let abs = build_episode_abstract(&chunk);
600 assert!(abs.ends_with("at length...."), "unexpected tail: {abs:?}");
601 assert!(!abs.contains("Kansas"));
602 }
603
604 #[test]
606 fn episode_abstract_never_cuts_mid_word() {
607 let chunk = "barbecue ".repeat(300);
608 let abs = build_episode_abstract(&chunk);
609 let body = abs.strip_suffix("...").expect("truncated");
610 assert!(
611 body.ends_with("barbecue"),
612 "cut landed mid-word: {:?}",
613 &body[body.len().saturating_sub(20)..]
614 );
615 }
616
617 #[test]
618 fn user_only_chunk_is_credited_to_the_human() {
619 let chunk = "### User\n\nI moved the repo to /opt/recall-echo.";
620 assert_eq!(infer_from_turn_roles(chunk), Provenance::User);
621 }
622
623 #[test]
624 fn assistant_turns_make_a_chunk_self_authored() {
625 let chunk = "### Assistant\n\nThe repo now lives at /opt/recall-echo.";
626 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
627 }
628
629 #[test]
630 fn mixed_chunk_is_self_authored() {
631 let chunk = "### User\n\nWhere does it live?\n\n---\n\n### Assistant\n\n/opt.";
634 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
635 }
636
637 #[test]
638 fn text_without_role_headings_is_self_authored() {
639 let chunk = "A pipeline document with no conversation structure at all.";
640 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
641 }
642
643 #[test]
644 fn heading_matching_is_exact() {
645 let chunk = "### Users of the system\n\nThey prefer NeoVim.";
647 assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
648 }
649
650 #[test]
651 fn fixed_policy_overrides_turn_roles() {
652 let chunk = "### User\n\nA quote from a paper.";
653 let policy = ProvenancePolicy::Fixed(Provenance::External);
654 assert_eq!(policy.classify(chunk), Provenance::External);
655 assert_eq!(
656 ProvenancePolicy::FromTurnRoles.classify(chunk),
657 Provenance::User
658 );
659 }
660
661 #[test]
662 fn context_override_is_applied_only_when_present() {
663 let context = IngestContext::new("s1", Some(7));
664 assert_eq!(context.session_id(), "s1");
665 assert_eq!(context.log_number(), Some(7));
666
667 let inferring = context.clone().with_override(None);
668 assert_eq!(inferring.provenance, ProvenancePolicy::FromTurnRoles);
669
670 let forced = context.with_override(Some(Provenance::External));
671 assert_eq!(
672 forced.provenance,
673 ProvenancePolicy::Fixed(Provenance::External)
674 );
675 }
676}