diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json index e75fd1254..e54c60f88 100644 --- a/docs/content/building-gormes/architecture_plan/progress.json +++ b/docs/content/building-gormes/architecture_plan/progress.json @@ -1534,9 +1534,9 @@ }, { "name": "Lineage-aware source-filtered search hits", - "status": "planned", + "status": "complete", "contract": "Session and message search can surface parent/child lineage context for matched sessions without widening the same-chat default recall fence", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1551,9 +1551,6 @@ "internal/memory/session_catalog.go", "docs/content/building-gormes/architecture_plan/phase-3-memory.md" ], - "blocked_by": [ - "parent_session_id lineage for compression splits" - ], "ready_when": [ "Session lineage metadata is persisted and can be queried from the session read model." ], @@ -1568,7 +1565,7 @@ "Same-chat default recall remains unchanged unless scope=user or source filters explicitly widen it.", "Orphaned or incomplete chains produce explicit evidence fields instead of dropped results." ], - "note": "TDD: once parent_session_id exists, make SearchMessages/SearchSessions expose parent/child lineage context for matched sessions without changing default same-chat recall behavior.", + "note": "Complete: TDD landed internal/memory/session_lineage_search_test.go. SearchMessages and SearchSessions now attach SearchLineage evidence with parent_session_id, lineage_kind, child_session_ids, and explicit ok/orphan/unavailable status without changing the same-chat default recall fence.", "write_scope": [ "internal/memory/", "internal/session/", diff --git a/internal/memory/session_catalog.go b/internal/memory/session_catalog.go index 171647646..7acf42d99 100644 --- a/internal/memory/session_catalog.go +++ b/internal/memory/session_catalog.go @@ -18,6 +18,18 @@ type SearchFilter struct { Query string } +// SearchLineageStatusUnavailable means the hit matched through a chat key but +// there was no session-specific metadata row to prove a lineage chain. +const SearchLineageStatusUnavailable = "unavailable" + +// SearchLineage is the lineage evidence attached to one matched session. +type SearchLineage struct { + ParentSessionID string + LineageKind string + ChildSessionIDs []string + Status string +} + // MessageSearchHit is one turn-level result from the session catalog. type MessageSearchHit struct { SessionID string @@ -26,6 +38,7 @@ type MessageSearchHit struct { Role string Content string TSUnix int64 + Lineage SearchLineage } // SessionSearchHit is one session-level result ordered by latest matching turn. @@ -34,6 +47,7 @@ type SessionSearchHit struct { ChatID string Source string LatestTurnUnix int64 + Lineage SearchLineage } // SearchMessages returns matching turns across the canonical sessions bound to @@ -45,6 +59,7 @@ func SearchMessages(ctx context.Context, db *sql.DB, metas []session.Metadata, f } sessionIDs, chatKeys, metaBySession, metaByChat := metadataIndexes(selected) + lineage := buildSearchLineageIndex(selected) query, args := buildTurnSearchQuery(filter.Query, sessionIDs, chatKeys, limit, false) rows, err := db.QueryContext(ctx, query, args...) if err != nil { @@ -63,6 +78,7 @@ func SearchMessages(ctx context.Context, db *sql.DB, metas []session.Metadata, f } else if meta, ok := metaByChat[hit.ChatID]; ok { hit.Source = meta.Source } + hit.Lineage = lineage.contextFor(hit.SessionID) hits = append(hits, hit) } if err := rows.Err(); err != nil { @@ -79,6 +95,7 @@ func SearchSessions(ctx context.Context, db *sql.DB, metas []session.Metadata, f } sessionIDs, chatKeys, metaBySession, metaByChat := metadataIndexes(selected) + lineage := buildSearchLineageIndex(selected) query, args := buildTurnSearchQuery(filter.Query, sessionIDs, chatKeys, limit, true) rows, err := db.QueryContext(ctx, query, args...) if err != nil { @@ -97,6 +114,7 @@ func SearchSessions(ctx context.Context, db *sql.DB, metas []session.Metadata, f } else if meta, ok := metaByChat[hit.ChatID]; ok { hit.Source = meta.Source } + hit.Lineage = lineage.contextFor(hit.SessionID) hits = append(hits, hit) } if err := rows.Err(); err != nil { @@ -166,6 +184,88 @@ func canonicalChatKey(meta session.Metadata) string { return source + ":" + chatID } +type searchLineageIndex struct { + bySession map[string]session.Metadata + children map[string][]string +} + +func buildSearchLineageIndex(metas []session.Metadata) searchLineageIndex { + idx := searchLineageIndex{ + bySession: make(map[string]session.Metadata, len(metas)), + children: make(map[string][]string, len(metas)), + } + for _, meta := range metas { + meta = normalizeSearchLineageMetadata(meta) + if meta.SessionID == "" { + continue + } + idx.bySession[meta.SessionID] = meta + } + for _, meta := range idx.bySession { + if meta.ParentSessionID == "" { + continue + } + childIDs := idx.children[meta.ParentSessionID] + if !slices.Contains(childIDs, meta.SessionID) { + idx.children[meta.ParentSessionID] = append(childIDs, meta.SessionID) + } + } + for parentID := range idx.children { + slices.Sort(idx.children[parentID]) + } + return idx +} + +func normalizeSearchLineageMetadata(meta session.Metadata) session.Metadata { + meta.SessionID = strings.TrimSpace(meta.SessionID) + meta.ParentSessionID = strings.TrimSpace(meta.ParentSessionID) + meta.LineageKind = strings.ToLower(strings.TrimSpace(meta.LineageKind)) + return meta +} + +func (idx searchLineageIndex) contextFor(sessionID string) SearchLineage { + sessionID = strings.TrimSpace(sessionID) + meta, ok := idx.bySession[sessionID] + if !ok { + return SearchLineage{Status: SearchLineageStatusUnavailable} + } + children := append([]string(nil), idx.children[sessionID]...) + return SearchLineage{ + ParentSessionID: meta.ParentSessionID, + LineageKind: searchLineageKind(meta), + ChildSessionIDs: children, + Status: idx.statusFor(sessionID), + } +} + +func searchLineageKind(meta session.Metadata) string { + if meta.LineageKind == "" { + return session.LineageKindPrimary + } + return meta.LineageKind +} + +func (idx searchLineageIndex) statusFor(sessionID string) string { + meta, ok := idx.bySession[sessionID] + if !ok { + return SearchLineageStatusUnavailable + } + seen := map[string]struct{}{sessionID: {}} + for current := meta.ParentSessionID; current != ""; { + if _, ok := seen[current]; ok { + return session.LineageStatusLoop + } + seen[current] = struct{}{} + + parent, ok := idx.bySession[current] + if !ok { + return session.LineageStatusOrphan + } + current = parent.ParentSessionID + } + return session.LineageStatusOK +} + func buildTurnSearchQuery(rawQuery string, sessionIDs, chatKeys []string, limit int, sessionsOnly bool) (string, []any) { var b strings.Builder args := make([]any, 0, len(sessionIDs)+len(chatKeys)+2) diff --git a/internal/memory/session_lineage_search_test.go b/internal/memory/session_lineage_search_test.go new file mode 100644 index 000000000..081a2822c --- /dev/null +++ b/internal/memory/session_lineage_search_test.go @@ -0,0 +1,200 @@ +package memory + +import ( + "context" + "testing" + + "github.com/TrebuchetDynamics/gormes-agent/internal/session" +) + +func TestSessionCatalog_SearchHitsIncludeLineageContext(t *testing.T) { + store, err := OpenSqlite(t.TempDir()+"/memory.db", 0, nil) + if err != nil { + t.Fatalf("OpenSqlite: %v", err) + } + defer func() { + if err := store.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + }() + + ctx := context.Background() + for _, turn := range []struct { + sessionID string + chatID string + content string + ts int64 + }{ + { + sessionID: "sess-root", + chatID: "telegram:42", + content: "Atlas root evidence", + ts: 100, + }, + { + sessionID: "sess-child", + chatID: "telegram:42", + content: "Atlas compressed continuation", + ts: 200, + }, + { + sessionID: "sess-orphan", + chatID: "telegram:42", + content: "Atlas orphaned branch", + ts: 300, + }, + { + sessionID: "sess-chat-only", + chatID: "telegram:42", + content: "Atlas legacy row matched only by chat key", + ts: 400, + }, + } { + if _, err := store.DB().ExecContext(ctx, + `INSERT INTO turns(session_id, role, content, ts_unix, chat_id) VALUES (?, ?, ?, ?, ?)`, + turn.sessionID, "user", turn.content, turn.ts, turn.chatID, + ); err != nil { + t.Fatalf("insert turn %s: %v", turn.sessionID, err) + } + } + + metas := []session.Metadata{ + {SessionID: "sess-root", Source: "telegram", ChatID: "42", UserID: "user-juan"}, + { + SessionID: "sess-child", + Source: "telegram", + ChatID: "42", + UserID: "user-juan", + ParentSessionID: "sess-root", + LineageKind: session.LineageKindCompression, + }, + { + SessionID: "sess-orphan", + Source: "telegram", + ChatID: "42", + UserID: "user-juan", + ParentSessionID: "sess-missing", + LineageKind: session.LineageKindFork, + }, + } + + messages, err := SearchMessages(ctx, store.DB(), metas, SearchFilter{ + UserID: "user-juan", + Sources: []string{"telegram"}, + Query: "Atlas", + }, 10) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if len(messages) != 4 { + t.Fatalf("SearchMessages len = %d, want 4", len(messages)) + } + messageBySession := make(map[string]MessageSearchHit, len(messages)) + for _, hit := range messages { + messageBySession[hit.SessionID] = hit + } + assertSearchLineage(t, "message root", messageBySession["sess-root"].Lineage, SearchLineage{ + LineageKind: session.LineageKindPrimary, + ChildSessionIDs: []string{"sess-child"}, + Status: session.LineageStatusOK, + }) + assertSearchLineage(t, "message child", messageBySession["sess-child"].Lineage, SearchLineage{ + ParentSessionID: "sess-root", + LineageKind: session.LineageKindCompression, + Status: session.LineageStatusOK, + }) + assertSearchLineage(t, "message orphan", messageBySession["sess-orphan"].Lineage, SearchLineage{ + ParentSessionID: "sess-missing", + LineageKind: session.LineageKindFork, + Status: session.LineageStatusOrphan, + }) + assertSearchLineage(t, "message chat-only", messageBySession["sess-chat-only"].Lineage, SearchLineage{ + Status: SearchLineageStatusUnavailable, + }) + + sessions, err := SearchSessions(ctx, store.DB(), metas, SearchFilter{ + UserID: "user-juan", + Sources: []string{"telegram"}, + Query: "Atlas", + }, 10) + if err != nil { + t.Fatalf("SearchSessions: %v", err) + } + if len(sessions) != 4 { + t.Fatalf("SearchSessions len = %d, want 4", len(sessions)) + } + sessionByID := make(map[string]SessionSearchHit, len(sessions)) + for _, hit := range sessions { + sessionByID[hit.SessionID] = hit + } + assertSearchLineage(t, "session root", sessionByID["sess-root"].Lineage, SearchLineage{ + LineageKind: session.LineageKindPrimary, + ChildSessionIDs: []string{"sess-child"}, + Status: session.LineageStatusOK, + }) + assertSearchLineage(t, "session child", sessionByID["sess-child"].Lineage, SearchLineage{ + ParentSessionID: "sess-root", + LineageKind: session.LineageKindCompression, + Status: session.LineageStatusOK, + }) + assertSearchLineage(t, "session orphan", sessionByID["sess-orphan"].Lineage, SearchLineage{ + ParentSessionID: "sess-missing", + LineageKind: session.LineageKindFork, + Status: session.LineageStatusOrphan, + }) + assertSearchLineage(t, "session chat-only", sessionByID["sess-chat-only"].Lineage, SearchLineage{ + Status: SearchLineageStatusUnavailable, + }) +} + +func TestSessionCatalog_LineageMetadataDoesNotWidenDefaultRecall(t *testing.T) { + _, p := openProviderWithRichGraph(t) + dir := session.NewMemMap() + ctx := context.Background() + if err := dir.PutMetadata(ctx, session.Metadata{ + SessionID: "s", + Source: "telegram", + ChatID: "42", + UserID: "user-juan", + }); err != nil { + t.Fatalf("PutMetadata root: %v", err) + } + if err := dir.PutMetadata(ctx, session.Metadata{ + SessionID: "s-compressed", + Source: "telegram", + ChatID: "42", + UserID: "user-juan", + ParentSessionID: "s", + LineageKind: session.LineageKindCompression, + }); err != nil { + t.Fatalf("PutMetadata child: %v", err) + } + + p = p.WithDirectory(dir) + out := p.GetContext(ctx, RecallInput{ + UserMessage: "Acme progress?", + ChatKey: "discord:7", + UserID: "user-juan", + }) + if out != "" { + t.Fatalf("default same-chat recall widened through lineage metadata; got %q", out) + } +} + +func assertSearchLineage(t *testing.T, label string, got, want SearchLineage) { + t.Helper() + if got.ParentSessionID != want.ParentSessionID || + got.LineageKind != want.LineageKind || + got.Status != want.Status { + t.Fatalf("%s lineage = %+v, want parent %q kind %q status %q", + label, got, want.ParentSessionID, want.LineageKind, want.Status) + } + if len(got.ChildSessionIDs) != len(want.ChildSessionIDs) { + t.Fatalf("%s children = %v, want %v", label, got.ChildSessionIDs, want.ChildSessionIDs) + } + for i := range want.ChildSessionIDs { + if got.ChildSessionIDs[i] != want.ChildSessionIDs[i] { + t.Fatalf("%s children = %v, want %v", label, got.ChildSessionIDs, want.ChildSessionIDs) + } + } +}