use rusqlite::Connection;
use serde_json::{Value, json};
use uuid::Uuid;
use crate::infrastructure::db;
use crate::infrastructure::embedding::{self, Embedder};
const RELATED_THRESHOLD: f32 = 0.55;
const MAX_RELATED: usize = 2;
pub struct Related {
pub id: i64,
pub text: String,
pub cosine: f32,
}
pub fn related_findings(
conn: &Connection,
task_uuid: &Uuid,
new_text: &str,
exclude_id: Option<i64>,
) -> Vec<Related> {
let qv = embedding::bundled().embed(new_text);
if qv.iter().all(|&x| x == 0.0) {
return Vec::new();
}
let Ok(anns) = db::get_annotations(conn, task_uuid) else {
return Vec::new();
};
let mut scored: Vec<Related> = anns
.into_iter()
.filter(|a| a.kind == "finding" && Some(a.id) != exclude_id)
.filter_map(|a| {
let v = embedding::bundled().embed(&a.text);
let c = embedding::cosine(&qv, &v);
(c >= RELATED_THRESHOLD).then_some(Related {
id: a.id,
text: a.text,
cosine: c,
})
})
.collect();
scored.sort_by(|a, b| {
b.cosine
.partial_cmp(&a.cosine)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(MAX_RELATED);
scored
}
pub fn related_findings_json(related: &[Related]) -> Vec<Value> {
related
.iter()
.map(|r| json!({ "annotation_id": r.id, "cosine": r.cosine, "text": r.text }))
.collect()
}
pub fn print_related_findings(related: &[Related]) {
if related.is_empty() {
return;
}
eprintln!("⟳ reconsider — related prior finding(s) on this task:");
for r in related {
eprintln!(" (~{:.2}) #{}: {}", r.cosine, r.id, r.text);
}
eprintln!(
" If your new note revises or contradicts one, correct it (denotate / re-annotate)."
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::db;
use crate::infrastructure::model::Task;
fn seed_task(conn: &Connection) -> Task {
let mut task = Task::new("host task".into(), "proj".into());
db::insert_task(conn, &mut task).unwrap();
task
}
fn add_finding(conn: &Connection, task: &Task, text: &str) -> i64 {
db::add_annotation_full(conn, &task.uuid, text, "finding", "ai", None, None, false).unwrap()
}
#[test]
fn surfaces_a_semantically_close_prior_finding() {
let conn = db::open_in_memory_for_test();
let task = seed_task(&conn);
add_finding(
&conn,
&task,
"dependabot bump broke the restore step; pin the lockfile version back",
);
let related = related_findings(
&conn,
&task.uuid,
"the dependency update caused the restore to fail; revert the version bump",
None,
);
assert!(
!related.is_empty(),
"a semantically adjacent prior finding must resurface"
);
}
#[test]
fn ignores_an_unrelated_prior_finding() {
let conn = db::open_in_memory_for_test();
let task = seed_task(&conn);
add_finding(&conn, &task, "how to bake sourdough bread at home");
let related = related_findings(
&conn,
&task.uuid,
"the dependency update caused the restore to fail; revert the version bump",
None,
);
assert!(
related.is_empty(),
"an unrelated finding must not resurface (precision over recall)"
);
}
#[test]
fn excludes_the_just_inserted_finding() {
let conn = db::open_in_memory_for_test();
let task = seed_task(&conn);
let id = add_finding(
&conn,
&task,
"restore broke after the dependabot version bump",
);
let related = related_findings(
&conn,
&task.uuid,
"restore broke after the dependabot version bump",
Some(id),
);
assert!(related.is_empty(), "self-match is excluded");
}
#[test]
fn only_findings_resurface_not_other_note_kinds() {
let conn = db::open_in_memory_for_test();
let task = seed_task(&conn);
db::add_annotation_full(
&conn,
&task.uuid,
"restore broke after the dependabot version bump",
"comment",
"ai",
None,
None,
false,
)
.unwrap();
let related = related_findings(
&conn,
&task.uuid,
"restore broke after the dependabot version bump",
None,
);
assert!(related.is_empty(), "comments are not findings");
}
}