diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index cffb96bd8..97f33f948 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -176,6 +176,7 @@ async def visible_lineage_graph( can_see_post, limit: int = 500, focus_post_id: str | None = None, + include_isolated: bool = False, ) -> dict[str, Any]: """ABAC-filtered graph bounded for the browser's initial viewport. @@ -219,14 +220,11 @@ async def visible_lineage_graph( component_ids.add(current_id) frontier.extend(neighbors.get(current_id, set()) - component_ids) - # An isolated post has no DAG to render; the post-lineage endpoint - # still reports its empty direct/indirect lists. - if len(component_ids) <= 1: - visible = [] - else: - visible = [ - row for row in visible_all if str(row["post_id"]) in component_ids - ] + visible = ( + [row for row in visible_all if str(row["post_id"]) in component_ids] + if include_isolated or len(component_ids) > 1 + else [] + ) truncated = False visible_ids = {str(row["post_id"]) for row in visible} @@ -261,3 +259,42 @@ async def visible_lineage_graph( for row in visible_edges ] return {"nodes": nodes, "edges": edges, "truncated": truncated} + + +async def lineage_graphs_for_posts( + conn: asyncpg.Connection, + can_see_post, + post_ids: list[str], +) -> dict[str, Any]: + """Merge each post's full reconstructed thread into one ``LineageGraph``. + + An Ask Agent answer can cite several posts from unrelated reconstruct + threads -- e.g. two separate customer complaints that happen to share a + keyword. The frontend's ``LineageDag`` already renders one ``LineageGraph`` + as several independent git-branch-style figures, one per + ``reconstruct_group_key`` (see ``lineageLayout.ts``'s ``layoutLineageDag``); + merging every cited post's thread into a single graph is enough to get + that multi-graph rendering for free, no new frontend layout needed. + + ponytail: one ``visible_lineage_graph`` call per post (each a bounded + ``source_post`` + full ``post_lineage_edge`` scan) -- fine for the + existing citation cap (``_POST_CHAT_SOURCE_LIMIT`` = 8), revisit with a + single batched query if that cap grows materially. + """ + nodes_by_id: dict[str, dict[str, Any]] = {} + edges_by_key: dict[tuple[str, str], dict[str, Any]] = {} + truncated = False + for post_id in dict.fromkeys(post_ids): + graph = await visible_lineage_graph( + conn, can_see_post, focus_post_id=post_id, include_isolated=True + ) + truncated = truncated or graph["truncated"] + for node in graph["nodes"]: + nodes_by_id[node["id"]] = node + for edge in graph["edges"]: + edges_by_key[(edge["source"], edge["target"])] = edge + return { + "nodes": list(nodes_by_id.values()), + "edges": list(edges_by_key.values()), + "truncated": truncated, + } diff --git a/backend/app/main.py b/backend/app/main.py index c6367620e..0e6fb68f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -165,7 +165,11 @@ visible_mention_post_ids, visible_team_mention_post_ids, ) -from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph +from backend.app.lineage_ingestion import ( + lineage_graphs_for_posts, + rebuild_lineage, + visible_lineage_graph, +) from backend.app.post_chat_ingestion import ( cited_post_images, fetch_persisted_chat, @@ -2624,7 +2628,7 @@ async def ask_agent( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Answer a buyer question from authorized post and graph evidence.""" + """Answer a reader question from authorized post and graph evidence.""" question = request.question.strip() if not question: raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required") @@ -2649,6 +2653,7 @@ async def ask_agent( "cited_posts": [], "source_post_ids": [], "cited_post_evidence": [], + "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, "cited_post_images": [], "next_action": "No authorized source posts are available for this question.", } @@ -2661,6 +2666,11 @@ async def ask_agent( ) from exc cited_ids = list(answer.cited_post_ids) async with pool.acquire() as conn: + lineage_graph = await lineage_graphs_for_posts( + conn, + lambda row: _can_see_post(account, row), + cited_ids, + ) images = await cited_post_images(conn, cited_ids) return { "answer_text": answer.answer_text, @@ -2669,6 +2679,7 @@ async def ask_agent( "cited_post_evidence": cited_post_evidence(sources, cited_ids), "cited_post_images": images, "source_post_ids": [source.post_id for source in sources], + "lineage_graph": lineage_graph, } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index cb52559b7..065c8ef40 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -106,6 +106,7 @@ describe("App, authenticated", () => { customerEntityHierarchy?: boolean; staleSummary?: boolean; contentAfterSummary?: boolean; + askLineageGraph?: boolean; askImageCitation?: boolean; }): ReturnType & { releaseMe: () => void } { const statusLabel: Record = { @@ -1597,6 +1598,38 @@ describe("App, authenticated", () => { ] : [], source_post_ids: ["post-1", "post-2"], + lineage_graph: options?.askLineageGraph + ? { + nodes: [ + { + id: "post-2", + group: "thread-alpha", + label: "Linked post", + occurred_at: "2026-08-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + { + id: "post-3", + group: "thread-alpha", + label: "Follow-up post", + occurred_at: "2026-08-02T00:00:00Z", + is_root: false, + is_branch_point: false, + }, + { + id: "post-4", + group: "thread-beta", + label: "Unrelated thread post", + occurred_at: "2026-08-03T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + ], + edges: [{ source: "post-2", target: "post-3", fused_score: 0.7 }], + truncated: false, + } + : { nodes: [], edges: [], truncated: false }, }), ); } @@ -1710,6 +1743,35 @@ describe("App, authenticated", () => { expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); + it("renders every cited lineage thread as its own git-branch-style graph", async () => { + stubBackend({ askLineageGraph: true }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByLabelText("Reconstructed lineage")).toBeInTheDocument(); + // Two distinct reconstruct threads (thread-alpha, thread-beta) must + // render as two independent branch-tree figures, not merged into one. + expect(screen.getByRole("img", { name: "thread-alpha lineage" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "thread-beta lineage" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open post: Follow-up post" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open post: Unrelated thread post" })).toBeInTheDocument(); + }); + + it("shows no lineage graph section when the answer cites no reconstructed thread", async () => { + stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument(); + expect(screen.queryByLabelText("Reconstructed lineage")).not.toBeInTheDocument(); + }); + it("cites a cited post's persisted image evidence under that post", async () => { stubBackend({ askImageCitation: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b0fa8a1b..6aa467231 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4558,6 +4558,9 @@ function AskAgentPanel({ )} + {answer.lineage_graph && answer.lineage_graph.nodes.length > 0 ? ( + + ) : null} )} {evidenceLayerPostId && answer ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fe1438b6c..677ce0799 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -319,6 +319,7 @@ export interface AskAgentResponse { cited_post_images?: CitedPostImage[]; source_post_ids: string[]; next_action?: string; + lineage_graph?: LineageGraph; } export interface IssueTicket { diff --git a/tests/test_lineage_ingestion.py b/tests/test_lineage_ingestion.py index 9d1a75227..a644a4e5e 100644 --- a/tests/test_lineage_ingestion.py +++ b/tests/test_lineage_ingestion.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime, timezone import backend.app.lineage_ingestion as ingestion +from backend.app.lineage_ingestion import lineage_graphs_for_posts from lineageweave.fixtures import sample_records from lineageweave.lineage_persistence import lineage_edge_specs @@ -339,3 +340,106 @@ async def fetch(self, query: str): assert len(focused["edges"]) == 1 assert focused["truncated"] is False assert isolated == {"nodes": [], "edges": [], "truncated": False} + + +def test_lineage_graphs_for_posts_merges_distinct_threads_without_duplicates() -> None: + """Global Ask can cite posts from unrelated threads; the merged graph + must carry every cited thread (for LineageDag's per-thread git-branch + rendering) without duplicating a node/edge shared by two focus posts. + """ + + class FakeConnection: + posts = [ + { + "post_id": "post-a", + "post_title": "A", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "thread-a", + "created_at": datetime(2026, 1, 1), + }, + { + "post_id": "post-b", + "post_title": "B", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "thread-a", + "created_at": datetime(2026, 1, 2), + }, + { + "post_id": "post-c", + "post_title": "C", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "thread-c", + "created_at": datetime(2026, 1, 3), + }, + { + "post_id": "post-d", + "post_title": "D", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "thread-c", + "created_at": datetime(2026, 1, 4), + }, + { + "post_id": "post-isolated", + "post_title": "Isolated", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "thread-isolated", + "created_at": datetime(2026, 1, 5), + }, + ] + edges = [ + {"parent_post_id": "post-a", "child_post_id": "post-b", "fused_score": 0.8}, + {"parent_post_id": "post-c", "child_post_id": "post-d", "fused_score": 0.6}, + ] + + async def fetch(self, query: str): + return self.edges if "post_lineage_edge" in query else self.posts + + connection = FakeConnection() + merged = asyncio.run( + lineage_graphs_for_posts( + connection, + lambda row: True, + # "post-b" is cited alongside its own thread's root -- must not + # duplicate post-a/post-b/their edge in the merged output. + ["post-a", "post-b", "post-d", "post-isolated"], + ) + ) + + assert {node["id"] for node in merged["nodes"]} == { + "post-a", + "post-b", + "post-c", + "post-d", + "post-isolated", + } + assert {node["group"] for node in merged["nodes"]} == { + "thread-a", + "thread-c", + "thread-isolated", + } + assert len(merged["edges"]) == 2 + assert merged["truncated"] is False + + +def test_lineage_graphs_for_posts_with_no_citations_is_empty() -> None: + class FakeConnection: + async def fetch(self, query: str): + return [] + + merged = asyncio.run(lineage_graphs_for_posts(FakeConnection(), lambda row: True, [])) + assert merged == {"nodes": [], "edges": [], "truncated": False}