use super::postprocess::persist_entity_description;
use super::*;
use crate::errors::AppError;
use crate::storage::memories;
use rusqlite::Connection;
use std::path::Path;
pub(crate) const ENTITY_DESCRIPTION_CORPUS_TOP_K: usize = 8;
pub(crate) const ENTITY_DESCRIPTION_SNIPPET_CHARS: usize = 2000;
pub(crate) fn load_entity_corpus_snippets(
conn: &Connection,
entity_id: i64,
top_k: usize,
max_chars: usize,
) -> Result<String, AppError> {
let mut stmt = conn.prepare_cached(
"SELECT COALESCE(m.body, '') AS body
FROM memory_entities me
JOIN memories m ON m.id = me.memory_id
WHERE me.entity_id = ?1 AND m.deleted_at IS NULL
ORDER BY COALESCE(m.updated_at, m.created_at) DESC, m.id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![entity_id, top_k as i64], |r| {
r.get::<_, String>(0)
})?;
let mut snippets = Vec::with_capacity(top_k);
for row in rows {
let body = row.map_err(AppError::Database)?;
let trimmed = body.trim();
if trimmed.is_empty() {
continue;
}
let snippet: String = trimmed.chars().take(max_chars).collect();
snippets.push(snippet);
}
Ok(snippets.join("\n---\n"))
}
pub(crate) const ENTITY_DESCRIPTION_NEIGHBOUR_TOP_K: usize = 12;
pub(crate) fn load_entity_graph_context(
conn: &Connection,
entity_id: i64,
top_k: usize,
) -> Result<String, AppError> {
let mut stmt = conn.prepare_cached(
"SELECT r.relation, r.weight, e.name, r.source_id = ?1 AS outgoing
FROM relationships r
JOIN entities e
ON e.id = CASE WHEN r.source_id = ?1 THEN r.target_id ELSE r.source_id END
WHERE r.source_id = ?1 OR r.target_id = ?1
ORDER BY r.weight DESC, e.name ASC
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![entity_id, top_k as i64], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, f64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, bool>(3)?,
))
})?;
let mut lines = Vec::with_capacity(top_k);
for row in rows {
let (relation, weight, other, outgoing) = row.map_err(AppError::Database)?;
lines.push(if outgoing {
format!("- this entity --{relation}--> {other} (weight {weight:.2})")
} else {
format!("- {other} --{relation}--> this entity (weight {weight:.2})")
});
}
Ok(lines.join("\n"))
}
pub(crate) struct EvidenceTuning {
pub(crate) corpus_top_k_key: &'static str,
pub(crate) snippet_chars_key: &'static str,
pub(crate) neighbour_top_k_key: &'static str,
}
pub(crate) const ENTITY_DESCRIPTION_TUNING: EvidenceTuning = EvidenceTuning {
corpus_top_k_key: "enrich.entity_description.corpus_top_k",
snippet_chars_key: "enrich.entity_description.snippet_chars",
neighbour_top_k_key: "enrich.entity_description.neighbour_top_k",
};
pub(crate) fn load_entity_evidence(conn: &Connection, entity_id: i64) -> Result<String, AppError> {
load_entity_evidence_tuned(conn, entity_id, ENTITY_DESCRIPTION_TUNING)
}
pub(crate) fn load_entity_evidence_tuned(
conn: &Connection,
entity_id: i64,
tuning: EvidenceTuning,
) -> Result<String, AppError> {
let top_k = crate::runtime_config::resolve_usize(
None,
tuning.corpus_top_k_key,
ENTITY_DESCRIPTION_CORPUS_TOP_K,
);
let snippet_chars = crate::runtime_config::resolve_usize(
None,
tuning.snippet_chars_key,
ENTITY_DESCRIPTION_SNIPPET_CHARS,
);
let neighbour_top_k = crate::runtime_config::resolve_usize(
None,
tuning.neighbour_top_k_key,
ENTITY_DESCRIPTION_NEIGHBOUR_TOP_K,
);
let bodies = load_entity_corpus_snippets(conn, entity_id, top_k, snippet_chars)?;
let edges = load_entity_graph_context(conn, entity_id, neighbour_top_k)?;
let mut parts = Vec::with_capacity(2);
if !bodies.trim().is_empty() {
parts.push(format!("Linked memory bodies:\n{bodies}"));
}
if !edges.trim().is_empty() {
parts.push(format!("Typed relations in the graph:\n{edges}"));
}
Ok(parts.join("\n\n"))
}
pub(crate) fn call_entity_description(
conn: &Connection,
namespace: &str,
entity_name: &str,
provider: ProviderCall<'_>,
grounding_threshold: f64,
domain_label: &str,
) -> Result<EnrichItemResult, AppError> {
let (entity_id, entity_type, old_description): (i64, String, Option<String>) = conn
.query_row(
"SELECT id, type, description FROM entities WHERE namespace=?1 AND name=?2",
rusqlite::params![namespace, entity_name],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => AppError::EntityNotYetMaterialized {
name: entity_name.to_string(),
namespace: namespace.to_string(),
},
other => AppError::Database(other),
})?;
let chars_before_existing = old_description
.as_deref()
.map(|s| s.chars().count())
.unwrap_or(0);
let corpus = load_entity_evidence(conn, entity_id)?;
let threshold = grounding_threshold;
let min_corpus_chars = crate::runtime_config::resolve_usize(
None,
"enrich.entity_description.min_corpus_chars",
crate::preservation::DEFAULT_GROUNDING_MIN_CORPUS_CHARS,
);
let corpus_chars = corpus.trim().chars().count();
if !crate::preservation::corpus_is_sufficient(&corpus, min_corpus_chars) {
return Ok(EnrichItemResult::Skipped {
cost: 0.0,
reason: format!(
"insufficient_grounding: linked corpus has {corpus_chars} chars, \
minimum is {min_corpus_chars} (consolidate the entity's variants \
or bind it to a memory before describing it)"
),
});
}
let corpus_section = format!("Evidence (ground truth; use only these facts):\n{corpus}\n");
let domain_section = super::prompts::entity_description_domain_section(domain_label);
let user_text = super::prompts::entity_description_user_text(
entity_name,
&entity_type,
&domain_section,
&corpus_section,
);
let (value, mut cost, mut is_oauth) =
invoke_entity_description_llm(provider, ENTITY_DESCRIPTION_SYSTEM_PROMPT, &user_text)?;
if let Some(false) = value.get("sufficient_evidence").and_then(|v| v.as_bool()) {
return Ok(EnrichItemResult::Skipped {
cost,
reason: format!(
"insufficient_evidence: model declined to describe from {corpus_chars} chars \
of linked corpus"
),
});
}
let mut description = match value.get("description") {
Some(v) if v.is_null() => {
return Ok(EnrichItemResult::Skipped {
cost,
reason: crate::i18n::validation::description_returned_null(),
})
}
Some(v) => v
.as_str()
.ok_or_else(|| {
AppError::Validation(crate::i18n::validation::llm_missing_description_field())
})?
.to_string(),
None => {
return Err(AppError::Validation(
crate::i18n::validation::llm_missing_description_field(),
))
}
};
if description.trim().is_empty() {
return Ok(EnrichItemResult::Skipped {
cost,
reason: crate::i18n::validation::description_returned_empty(),
});
}
let verdict = crate::preservation::PreservationVerdict::evaluate_grounding_adaptive(
&description,
&corpus,
threshold,
min_corpus_chars,
);
if !verdict.is_accepted() {
let score = match verdict {
crate::preservation::PreservationVerdict::Preserved { score, .. } => score,
crate::preservation::PreservationVerdict::Rejected { score, .. } => score,
crate::preservation::PreservationVerdict::Unchanged { .. } => 1.0,
};
return Ok(EnrichItemResult::PreservationFailed {
score,
threshold,
chars_before: chars_before_existing,
chars_after: description.chars().count(),
});
}
if super::super::predicates::is_low_quality_description(&description) {
let anti_jargon_user = format!(
"{user_text}\n\nCRITICAL: your previous draft was rejected as generic filler. \
Write a concrete domain description using only the evidence above, or set \
`sufficient_evidence` to false if the evidence does not support one. \
Forbidden: configuration file, software component, module that, system design, chatbot."
);
match invoke_entity_description_llm(
provider,
ENTITY_DESCRIPTION_SYSTEM_PROMPT,
&anti_jargon_user,
) {
Ok((value2, cost2, oauth2)) => {
cost += cost2;
is_oauth = is_oauth || oauth2;
if let Some(d2) = value2.get("description").and_then(|v| v.as_str()) {
let d2 = d2.to_string();
let v2 = crate::preservation::PreservationVerdict::evaluate_grounding_adaptive(
&d2,
&corpus,
threshold,
min_corpus_chars,
);
if v2.is_accepted()
&& !super::super::predicates::is_low_quality_description(&d2)
{
description = d2;
}
}
}
Err(e) => {
tracing::warn!(
target: "enrich",
error = %e,
"G-PR-2 anti-jargon retry failed; keeping first draft for quality gate"
);
}
}
}
if super::super::predicates::is_low_quality_description(&description) {
return Ok(EnrichItemResult::Skipped {
cost,
reason: format!(
"quality_post_filter: description still matches low-quality predicate \
(orig={chars_before_existing} chars, candidate={} chars)",
description.chars().count()
),
});
}
persist_entity_description(conn, entity_id, &description)?;
Ok(EnrichItemResult::Done {
memory_id: None,
entity_id: Some(entity_id),
entities: 0,
rels: 0,
chars_before: Some(chars_before_existing),
chars_after: Some(description.chars().count()),
cost,
is_oauth,
})
}
fn invoke_entity_description_llm(
provider: ProviderCall<'_>,
system_prompt: &str,
user_text: &str,
) -> Result<(serde_json::Value, f64, bool), AppError> {
let ProviderCall {
model,
timeout,
mode,
} = provider;
match mode {
EnrichMode::OpenRouter => call_openrouter(
system_prompt,
ENTITY_DESCRIPTION_SCHEMA,
user_text,
model,
timeout,
),
}
}
pub(crate) fn call_description_enrich(
conn: &Connection,
_namespace: &str,
item_key: &str,
_binary: &Path,
model: Option<&str>,
timeout: u64,
mode: &EnrichMode,
) -> Result<EnrichItemResult, AppError> {
let (mem_id, body, old_desc): (i64, String, String) = conn
.query_row(
"SELECT id, body, description FROM memories WHERE name = ?1 AND deleted_at IS NULL",
rusqlite::params![item_key],
|r| Ok((r.get(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
)
.map_err(|_| {
AppError::NotFound(crate::i18n::validation::memory_named_not_found(item_key))
})?;
let snippet: String = body
.chars()
.take(crate::constants::ENRICH_BODY_PREVIEW_CHARS)
.collect();
let input_text = format!(
"Memory name: {item_key}\nCurrent description: {old_desc}\nBody preview: {snippet}"
);
let (value, cost, is_oauth) = match mode {
EnrichMode::OpenRouter => call_openrouter(
DESCRIPTION_ENRICH_PROMPT,
DESCRIPTION_ENRICH_SCHEMA,
&input_text,
model,
timeout,
)?,
};
let new_desc = value
.get("description")
.and_then(|v| v.as_str())
.unwrap_or(&old_desc);
let old_name: String = conn.query_row(
"SELECT name FROM memories WHERE id = ?1",
rusqlite::params![mem_id],
|r| r.get(0),
)?;
conn.execute(
"UPDATE memories SET description = ?1 WHERE id = ?2",
rusqlite::params![new_desc, mem_id],
)?;
memories::sync_fts_after_update(
conn, mem_id, &old_name, &old_desc, &body, &old_name, new_desc, &body,
)?;
Ok(EnrichItemResult::Done {
memory_id: Some(mem_id),
entity_id: None,
entities: 0,
rels: 0,
chars_before: Some(old_desc.len()),
chars_after: Some(new_desc.len()),
cost,
is_oauth,
})
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn open_evidence_db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
conn.execute_batch(
"CREATE TABLE memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
deleted_at INTEGER
);
CREATE TABLE entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE TABLE memory_entities (
memory_id INTEGER NOT NULL,
entity_id INTEGER NOT NULL,
PRIMARY KEY (memory_id, entity_id)
);
CREATE TABLE relationships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 0.5
);
INSERT INTO memories (id, body) VALUES (1, 'the subject signed the lease');
INSERT INTO entities (id, name) VALUES (1, 'subject'), (2, 'landlord');
INSERT INTO memory_entities (memory_id, entity_id) VALUES (1, 1);
INSERT INTO relationships (source_id, target_id, relation, weight)
VALUES (1, 2, 'depends-on', 0.9);",
)
.expect("fixture schema");
conn
}
#[test]
fn tuned_evidence_matches_the_untuned_entry_point() {
let conn = open_evidence_db();
let untuned = load_entity_evidence(&conn, 1).expect("untuned evidence");
let tuned = load_entity_evidence_tuned(&conn, 1, ENTITY_DESCRIPTION_TUNING)
.expect("tuned evidence");
assert_eq!(untuned, tuned);
assert!(untuned.contains("Linked memory bodies:"));
assert!(untuned.contains("Typed relations in the graph:"));
}
}