use std::collections::HashMap;
use futures::stream::{self, StreamExt};
use super::confidence::{ExtractionContext, Observation, Provenance};
use super::crud;
use super::dedup::{self, Resolution, ResolvedEntity};
use super::error::GraphError;
use super::extract;
use super::llm::{LlmProvider, TokenUsage};
use super::types::*;
use super::utility;
use super::GraphMemory;
const LLM_CONCURRENCY: usize = 10;
const USER_TURN_HEADING: &str = "### user";
const ASSISTANT_TURN_HEADING: &str = "### assistant";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProvenancePolicy {
#[default]
FromTurnRoles,
Fixed(Provenance),
}
impl ProvenancePolicy {
#[must_use]
pub fn classify(self, chunk: &str) -> Provenance {
match self {
Self::Fixed(provenance) => provenance,
Self::FromTurnRoles => infer_from_turn_roles(chunk),
}
}
}
#[derive(Debug, Clone)]
pub struct IngestContext {
session_id: String,
log_number: Option<u32>,
provenance: ProvenancePolicy,
}
impl IngestContext {
#[must_use]
pub fn new(session_id: impl Into<String>, log_number: Option<u32>) -> Self {
Self {
session_id: session_id.into(),
log_number,
provenance: ProvenancePolicy::default(),
}
}
#[must_use]
pub fn with_provenance(mut self, provenance: ProvenancePolicy) -> Self {
self.provenance = provenance;
self
}
#[must_use]
pub fn with_override(self, provenance: Option<Provenance>) -> Self {
match provenance {
Some(class) => self.with_provenance(ProvenancePolicy::Fixed(class)),
None => self,
}
}
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
#[must_use]
pub fn log_number(&self) -> Option<u32> {
self.log_number
}
}
fn infer_from_turn_roles(chunk: &str) -> Provenance {
let mut saw_user = false;
for line in chunk.lines() {
let heading = line.trim().to_lowercase();
if heading == ASSISTANT_TURN_HEADING {
return Provenance::SelfGenerated;
}
if heading == USER_TURN_HEADING {
saw_user = true;
}
}
if saw_user {
Provenance::User
} else {
Provenance::SelfGenerated
}
}
pub async fn ingest_archive(
gm: &GraphMemory,
archive_text: &str,
context: &IngestContext,
llm: Option<&dyn LlmProvider>,
) -> Result<IngestionReport, GraphError> {
let mut report = IngestionReport::default();
let chunks = extract::chunk_conversation(archive_text, 500);
if chunks.is_empty() {
return Ok(report);
}
for (i, chunk) in chunks.iter().enumerate() {
let abstract_text = build_episode_abstract(chunk);
let episode = NewEpisode {
session_id: context.session_id.clone(),
abstract_text,
overview: None,
content: Some(chunk.clone()),
log_number: context.log_number,
};
match gm
.add_episode_from(episode, context.provenance.classify(chunk))
.await
{
Ok(_) => report.episodes_created += 1,
Err(e) => {
report.errors.push(format!("episode chunk {i}: {e}"));
}
}
}
if let Some(llm) = llm {
process_extraction(gm, &chunks, context, llm, &mut report).await?;
}
Ok(report)
}
pub async fn extract_from_archive(
gm: &GraphMemory,
archive_text: &str,
context: &IngestContext,
llm: &dyn LlmProvider,
) -> Result<IngestionReport, GraphError> {
let mut report = IngestionReport::default();
let chunks = extract::chunk_conversation(archive_text, 500);
if chunks.is_empty() {
return Ok(report);
}
process_extraction(gm, &chunks, context, llm, &mut report).await?;
Ok(report)
}
async fn extract_indexed(
llm: &dyn LlmProvider,
chunk: &str,
session_id: &str,
log_number: Option<u32>,
index: usize,
) -> (usize, Result<ChunkExtraction, GraphError>) {
let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
(index, result)
}
type ChunkExtraction = (ExtractionResult, Option<TokenUsage>);
const ESTIMATED_EXTRACTION_TOKENS: u64 = 2_500;
const ESTIMATED_DEDUP_TOKENS: u64 = 600;
async fn process_extraction(
gm: &GraphMemory,
chunks: &[String],
context: &IngestContext,
llm: &dyn LlmProvider,
report: &mut IngestionReport,
) -> Result<(), GraphError> {
let session_id = context.session_id.as_str();
let log_number = context.log_number;
let pending: Vec<_> = chunks
.iter()
.enumerate()
.map(|(i, chunk)| extract_indexed(llm, chunk, session_id, log_number, i))
.collect();
let extraction_results: Vec<(usize, Result<ChunkExtraction, GraphError>)> =
stream::iter(pending)
.buffer_unordered(LLM_CONCURRENCY)
.collect()
.await;
let mut all_entities: Vec<ExtractedEntity> = Vec::new();
let mut all_relationships: Vec<(Provenance, ExtractedRelationship)> = Vec::new();
for (i, result) in extraction_results {
match result {
Ok((extraction, usage)) => {
let provenance = context.provenance.classify(&chunks[i]);
all_entities.extend(extract::flatten_extraction(&extraction));
all_relationships.extend(
extraction
.relationships
.into_iter()
.map(|rel| (provenance, rel)),
);
bill(report, usage, ESTIMATED_EXTRACTION_TOKENS);
}
Err(e) => {
report.errors.push(format!("extraction chunk {i}: {e}"));
}
}
}
let deduplicated = local_merge_entities(all_entities);
let mut name_map: HashMap<String, String> = HashMap::new();
for candidate in &deduplicated {
match dedup::resolve_entity(gm, llm, candidate, session_id).await {
Ok(resolution) => record_resolution(report, &mut name_map, candidate, resolution),
Err(e) => {
report
.errors
.push(format!("dedup '{}': {}", candidate.name, e));
}
}
}
for (provenance, rel) in &all_relationships {
let from_name = name_map.get(&rel.source).unwrap_or(&rel.source);
let to_name = name_map.get(&rel.target).unwrap_or(&rel.target);
if let Some(existing) =
find_existing_relationship(gm, from_name, to_name, &rel.rel_type).await
{
if let Err(e) = record_reextraction(gm, &existing, *provenance).await {
report
.errors
.push(format!("confidence update {from_name} -> {to_name}: {e}"));
}
report.relationships_skipped += 1;
continue;
}
let context: ExtractionContext = rel
.confidence
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or(ExtractionContext::Inferred);
let new_rel = NewRelationship {
from_entity: from_name.clone(),
to_entity: to_name.clone(),
rel_type: rel.rel_type.clone(),
description: rel.description.clone(),
confidence: Some(context.prior() as f32),
source: Some(session_id.to_string()),
};
match gm.add_relationship(new_rel).await {
Ok(_) => report.relationships_created += 1,
Err(e) => {
report
.errors
.push(format!("relationship {from_name} -> {to_name}: {e}"));
}
}
}
if let Err(e) = utility::record_session_use(gm.db(), session_id, &report.entity_ids).await {
report
.errors
.push(format!("session use record for {session_id}: {e}"));
}
Ok(())
}
async fn record_reextraction(
gm: &GraphMemory,
existing: &Relationship,
provenance: Provenance,
) -> Result<(), GraphError> {
let observation = Observation::Corroborating;
let mut evidence = existing.edge_evidence();
evidence.record(observation, provenance, gm.provenance_weights());
crud::record_observation(gm.db(), &existing.id_string(), evidence, observation).await
}
fn record_resolution(
report: &mut IngestionReport,
name_map: &mut HashMap<String, String>,
candidate: &ExtractedEntity,
resolution: Resolution,
) {
if resolution.path.used_llm() {
report.dedup_llm_calls += 1;
bill(report, resolution.usage, ESTIMATED_DEDUP_TOKENS);
} else {
report.dedup_fast_path += 1;
}
match resolution.entity {
ResolvedEntity::Created(entity) => {
name_map.insert(candidate.name.clone(), entity.name.clone());
report.entity_ids.push(entity.id_string());
report.entities_created += 1;
}
ResolvedEntity::Merged(entity) => {
name_map.insert(candidate.name.clone(), entity.name.clone());
report.entity_ids.push(entity.id_string());
report.entities_merged += 1;
}
ResolvedEntity::Skipped => {
name_map.insert(candidate.name.clone(), candidate.name.clone());
report.entities_skipped += 1;
}
}
}
fn bill(report: &mut IngestionReport, usage: Option<TokenUsage>, estimate: u64) {
match usage {
Some(usage) => report.measured_tokens += usage.total(),
None => report.estimated_tokens += estimate,
}
}
fn local_merge_entities(entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
let mut seen: HashMap<String, ExtractedEntity> = HashMap::new();
let mut order: Vec<String> = Vec::new();
for entity in entities {
let key = entity.name.to_lowercase();
if let Some(existing) = seen.get_mut(&key) {
if entity.abstract_text.len() > existing.abstract_text.len() {
existing.abstract_text = entity.abstract_text;
}
if let Some(new_overview) = entity.overview {
existing.overview = Some(match &existing.overview {
Some(o) => format!("{o}\n\n{new_overview}"),
None => new_overview,
});
}
if let Some(new_content) = entity.content {
existing.content = Some(match &existing.content {
Some(c) => format!("{c}\n\n{new_content}"),
None => new_content,
});
}
if let Some(new_attrs) = entity.attributes {
existing.attributes = Some(match &existing.attributes {
Some(a) => merge_json(a, &new_attrs),
None => new_attrs,
});
}
} else {
order.push(key.clone());
seen.insert(key, entity);
}
}
order.into_iter().filter_map(|k| seen.remove(&k)).collect()
}
use super::util::merge_json_objects as merge_json;
const EPISODE_ABSTRACT_MAX_CHARS: usize = 1_000;
const MIN_BOUNDARY_FRACTION: f64 = 0.6;
fn build_episode_abstract(chunk: &str) -> String {
let trimmed = chunk.trim();
if trimmed.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS {
return trimmed.to_string();
}
let window: String = trimmed.chars().take(EPISODE_ABSTRACT_MAX_CHARS).collect();
let cut = truncation_point(&window);
format!("{}...", window[..cut].trim_end())
}
fn truncation_point(window: &str) -> usize {
let floor = (window.len() as f64 * MIN_BOUNDARY_FRACTION) as usize;
let after_sentence = window
.char_indices()
.rev()
.find(|(_, c)| matches!(c, '.' | '!' | '?' | '\n'))
.map(|(i, c)| i + c.len_utf8());
if let Some(cut) = after_sentence.filter(|&cut| cut >= floor) {
return cut;
}
window
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map(|(i, _)| i)
.filter(|&cut| cut >= floor)
.unwrap_or(window.len())
}
async fn find_existing_relationship(
gm: &GraphMemory,
from_name: &str,
to_name: &str,
rel_type: &str,
) -> Option<Relationship> {
let rels = gm
.get_relationships(from_name, Direction::Outgoing)
.await
.ok()?;
let to_entity = gm.get_entity(to_name).await.ok()??;
let to_id = to_entity.id_string();
rels.into_iter().find(|r| {
r.rel_type == rel_type && {
let out_id = match &r.to_id {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
out_id == to_id
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn episode_abstract_truncates_at_the_cap() {
let long = "x".repeat(EPISODE_ABSTRACT_MAX_CHARS * 2);
let abs = build_episode_abstract(&long);
assert!(abs.chars().count() <= EPISODE_ABSTRACT_MAX_CHARS + 3);
assert!(abs.ends_with("..."));
}
#[test]
fn episode_abstract_short_unchanged() {
let short = "Hello world";
let abs = build_episode_abstract(short);
assert_eq!(abs, "Hello world");
}
#[test]
fn episode_abstract_keeps_text_the_old_cap_would_have_cut() {
let chunk = format!(
"{} Currently, my favourite is Kansas City Masterpiece.",
"Padding sentence about barbecue. ".repeat(8)
);
assert!(chunk.chars().count() > 200);
let abs = build_episode_abstract(&chunk);
assert!(abs.contains("Kansas City Masterpiece"));
}
#[test]
fn episode_abstract_cuts_at_a_sentence_boundary() {
let chunk = format!(
"{}My favourite is Kansas City Masterpiece and nothing else comes close at all",
"The barbecue discussion continued at length. ".repeat(22)
);
let abs = build_episode_abstract(&chunk);
assert!(abs.ends_with("at length...."), "unexpected tail: {abs:?}");
assert!(!abs.contains("Kansas"));
}
#[test]
fn episode_abstract_never_cuts_mid_word() {
let chunk = "barbecue ".repeat(300);
let abs = build_episode_abstract(&chunk);
let body = abs.strip_suffix("...").expect("truncated");
assert!(
body.ends_with("barbecue"),
"cut landed mid-word: {:?}",
&body[body.len().saturating_sub(20)..]
);
}
#[test]
fn user_only_chunk_is_credited_to_the_human() {
let chunk = "### User\n\nI moved the repo to /opt/recall-echo.";
assert_eq!(infer_from_turn_roles(chunk), Provenance::User);
}
#[test]
fn assistant_turns_make_a_chunk_self_authored() {
let chunk = "### Assistant\n\nThe repo now lives at /opt/recall-echo.";
assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
}
#[test]
fn mixed_chunk_is_self_authored() {
let chunk = "### User\n\nWhere does it live?\n\n---\n\n### Assistant\n\n/opt.";
assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
}
#[test]
fn text_without_role_headings_is_self_authored() {
let chunk = "A pipeline document with no conversation structure at all.";
assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
}
#[test]
fn heading_matching_is_exact() {
let chunk = "### Users of the system\n\nThey prefer NeoVim.";
assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
}
#[test]
fn fixed_policy_overrides_turn_roles() {
let chunk = "### User\n\nA quote from a paper.";
let policy = ProvenancePolicy::Fixed(Provenance::External);
assert_eq!(policy.classify(chunk), Provenance::External);
assert_eq!(
ProvenancePolicy::FromTurnRoles.classify(chunk),
Provenance::User
);
}
#[test]
fn context_override_is_applied_only_when_present() {
let context = IngestContext::new("s1", Some(7));
assert_eq!(context.session_id(), "s1");
assert_eq!(context.log_number(), Some(7));
let inferring = context.clone().with_override(None);
assert_eq!(inferring.provenance, ProvenancePolicy::FromTurnRoles);
let forced = context.with_override(Some(Provenance::External));
assert_eq!(
forced.provenance,
ProvenancePolicy::Fixed(Provenance::External)
);
}
}