use std::collections::HashMap;
use futures::stream::{self, StreamExt};
use super::confidence::{ExtractionContext, Provenance};
use super::crud;
use super::dedup::{self, ResolvedEntity};
use super::error::GraphError;
use super::extract;
use super::llm::LlmProvider;
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<ExtractionResult, GraphError>) {
let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
(index, result)
}
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<ExtractionResult, 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) => {
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)),
);
report.estimated_tokens += 2500;
}
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 {
report.estimated_tokens += 600;
match dedup::resolve_entity(gm, llm, candidate, session_id).await {
Ok(ResolvedEntity::Created(entity)) => {
name_map.insert(candidate.name.clone(), entity.name.clone());
report.entity_ids.push(entity.id_string());
report.entities_created += 1;
}
Ok(ResolvedEntity::Merged(entity)) => {
name_map.insert(candidate.name.clone(), entity.name.clone());
report.entity_ids.push(entity.id_string());
report.entities_merged += 1;
}
Ok(ResolvedEntity::Skipped) => {
name_map.insert(candidate.name.clone(), candidate.name.clone());
report.entities_skipped += 1;
}
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
{
let mut evidence = existing.edge_evidence();
evidence.corroborate(*provenance, gm.provenance_weights());
if let Err(e) =
crud::reinforce_relationship(gm.db(), &existing.id_string(), evidence).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(())
}
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;
fn build_episode_abstract(chunk: &str) -> String {
let chars: String = chunk.chars().take(200).collect();
if chars.len() < chunk.len() {
format!("{}...", chars.trim())
} else {
chars.trim().to_string()
}
}
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() {
let long = "x".repeat(500);
let abs = build_episode_abstract(&long);
assert!(abs.len() < 210);
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 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)
);
}
}