chat: scope insight lookup by library_id to fix regen-shadow bug

When a photo exists in more than one library and the user
regenerates its insight from library A's chat, the regenerate
streams cleanly, store_insight flips library A's old row to
is_current=false, and inserts a new is_current=true row tagged
(library A, rel_path). On the next history fetch the user sees
their old transcript — the regenerate appears to vanish.

The cause: get_insight(file_path) filters on rel_path + is_current
only, so library B's untouched is_current=true row for the same
rel_path satisfies the query and gets returned by SQLite's .first()
ahead of A's new row. Because get_insight is also what
chat_turn_stream uses to decide bootstrap vs. continuation, the
next chat turn after the shadow hit also routes against the
wrong insight, so update_training_messages corrupts library B's
transcript with library A's chat.

Fix: add get_current_insight_for_library(library_id, file_path)
filtered on (library_id, rel_path, is_current=true) and route the
chat surface (load_history, chat_turn{,_stream}, rewind_history)
through it. load_history falls back to the cross-library
get_insight when the scoped lookup misses — preserves the
"scalar data merges across libraries" intent for the case where
the active library has no insight but another does. The path-only
get_insight stays for callers that don't have library context
(populate_knowledge, the photo-grid metadata fetch).

chat_history_handler stops dropping the parsed library on the
floor and threads it through. Single-library deploys see no
behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-10 14:03:41 -04:00
parent b9d9ba0320
commit 08a5f46be1
3 changed files with 78 additions and 16 deletions

View File

@@ -21,6 +21,22 @@ pub trait InsightDao: Sync + Send {
file_path: &str,
) -> Result<Option<PhotoInsight>, DbError>;
/// Library-scoped variant of `get_insight`. The default `get_insight`
/// finds any `is_current=true` row matching `file_path` across
/// libraries — fine for the photo-grid metadata fetch (cross-library
/// merge), wrong for the chat path: a regenerate on lib1 flips lib1's
/// row to `is_current=false` and inserts a new lib1 row, but
/// lib2's untouched `is_current=true` row for the same rel_path
/// would still satisfy the path-only query and shadow the regen on
/// the next history fetch. Always pass a library_id when you have
/// one (chat / insight write paths always do).
fn get_current_insight_for_library(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
file_path: &str,
) -> Result<Option<PhotoInsight>, DbError>;
/// Return the most recent current insight whose rel_path is one of
/// `paths`. Used for content-hash sharing: the caller expands a
/// single file into all rel_paths with the same content_hash, then
@@ -182,6 +198,28 @@ impl InsightDao for SqliteInsightDao {
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_current_insight_for_library(
&mut self,
context: &opentelemetry::Context,
lib_id: i32,
path: &str,
) -> Result<Option<PhotoInsight>, DbError> {
trace_db_call(context, "query", "get_current_insight_for_library", |_span| {
use schema::photo_insights::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get InsightDao");
photo_insights
.filter(library_id.eq(lib_id))
.filter(rel_path.eq(path))
.filter(is_current.eq(true))
.first::<PhotoInsight>(connection.deref_mut())
.optional()
.map_err(|_| anyhow::anyhow!("Query error"))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_insight_for_paths(
&mut self,
context: &opentelemetry::Context,