feat(timeline): upgrade seed phase to hybrid search (FTS + vector RRF)

Replace the FTS-only seed phase with hybrid search that combines FTS5
full-text matching with vector similarity via Reciprocal Rank Fusion
(RRF), matching the approach already proven in the search command.

Key changes:
- seed_timeline is now async, accepting an optional OllamaClient for
  vector embedding. Gracefully falls back to FTS-only when Ollama is
  unavailable (same pattern as search_hybrid).
- SeedResult now includes search_mode ("hybrid", "lexical", or
  "lexical (hybrid fallback)") for provenance tracking in both human
  and robot output.
- run_timeline and handle_timeline are now async to propagate the
  seed_timeline future.
- Timeline result metadata includes the search mode used.
- Seed retrieval uses 3x oversampling (max_seeds * 3) then deduplicates
  to the requested entity count, improving recall for discussion-heavy
  entities.

Test updates:
- All seed tests updated for the new async + OllamaClient signature
  (client=None exercises the FTS fallback path).
- Pipeline integration tests updated similarly.
- timeline.rs gains #[derive(Debug)] on TimelineResult for test
  assertions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-13 12:37:48 -05:00
parent 0034958faf
commit 8dbabe7279
6 changed files with 192 additions and 84 deletions

View File

@@ -108,8 +108,8 @@ fn insert_label_event(
/// Full pipeline: seed -> expand -> collect for a scenario with an issue
/// that has a closing MR, state changes, and label events.
#[test]
fn pipeline_seed_expand_collect_end_to_end() {
#[tokio::test]
async fn pipeline_seed_expand_collect_end_to_end() {
let conn = setup_db();
let project_id = insert_project(&conn, "group/project");
@@ -149,7 +149,9 @@ fn pipeline_seed_expand_collect_end_to_end() {
insert_label_event(&conn, project_id, Some(issue_id), "bug", 1500);
// SEED: find entities matching "authentication"
let seed_result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
let seed_result = seed_timeline(&conn, None, "authentication", None, None, 50, 10)
.await
.unwrap();
assert!(
!seed_result.seed_entities.is_empty(),
"Seed should find at least one entity"
@@ -213,12 +215,14 @@ fn pipeline_seed_expand_collect_end_to_end() {
}
/// Verify the pipeline handles an empty FTS result gracefully.
#[test]
fn pipeline_empty_query_produces_empty_result() {
#[tokio::test]
async fn pipeline_empty_query_produces_empty_result() {
let conn = setup_db();
let _project_id = insert_project(&conn, "group/project");
let seed_result = seed_timeline(&conn, "", None, None, 50, 10).unwrap();
let seed_result = seed_timeline(&conn, None, "", None, None, 50, 10)
.await
.unwrap();
assert!(seed_result.seed_entities.is_empty());
let expand_result = expand_timeline(&conn, &seed_result.seed_entities, 1, false, 100).unwrap();
@@ -237,8 +241,8 @@ fn pipeline_empty_query_produces_empty_result() {
}
/// Verify since filter propagates through the full pipeline.
#[test]
fn pipeline_since_filter_excludes_old_events() {
#[tokio::test]
async fn pipeline_since_filter_excludes_old_events() {
let conn = setup_db();
let project_id = insert_project(&conn, "group/project");
@@ -255,7 +259,9 @@ fn pipeline_since_filter_excludes_old_events() {
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 2000);
insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 8000);
let seed_result = seed_timeline(&conn, "deploy", None, None, 50, 10).unwrap();
let seed_result = seed_timeline(&conn, None, "deploy", None, None, 50, 10)
.await
.unwrap();
let expand_result = expand_timeline(&conn, &seed_result.seed_entities, 0, false, 100).unwrap();
// Collect with since=5000: should exclude Created(1000) and closed(2000)
@@ -274,8 +280,8 @@ fn pipeline_since_filter_excludes_old_events() {
}
/// Verify unresolved references use Option<i64> for target_iid.
#[test]
fn pipeline_unresolved_refs_have_optional_iid() {
#[tokio::test]
async fn pipeline_unresolved_refs_have_optional_iid() {
let conn = setup_db();
let project_id = insert_project(&conn, "group/project");
@@ -302,7 +308,9 @@ fn pipeline_unresolved_refs_have_optional_iid() {
)
.unwrap();
let seed_result = seed_timeline(&conn, "cross project", None, None, 50, 10).unwrap();
let seed_result = seed_timeline(&conn, None, "cross project", None, None, 50, 10)
.await
.unwrap();
let expand_result = expand_timeline(&conn, &seed_result.seed_entities, 1, false, 100).unwrap();
assert_eq!(expand_result.unresolved_references.len(), 2);