From abcf59b9038f9020ca2a569261c9573f21292ba1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 14:18:47 +0000 Subject: [PATCH 01/28] feat: keep GNB Event Lineage focus on a linked DAG node (v2.17.0) Opening a linked Event Lineage node from a GNB-focused popup keeps Event Lineage focused and names Keyman and evaluation next. A home-list DAG walk does not. No TEPP theta or cited post is invented. --- AGENTS.md | 2 ++ ARCHITECTURE.md | 3 +- ...17.0-event-lineage-node-keeps-gnb-focus.md | 5 +++ CHANGELOG.md | 10 ++++++ CLAUDE.md | 6 ++++ ...0097-event-lineage-node-keeps-gnb-focus.md | 35 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 36 +++++++++++++++++++ frontend/src/App.tsx | 10 +++++- pyproject.toml | 2 +- 10 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md create mode 100644 docs/adr/0097-event-lineage-node-keeps-gnb-focus.md diff --git a/AGENTS.md b/AGENTS.md index 31f89ec8d..25c482840 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,8 @@ Opening a Customer master related post uses the same focus path (ADR 0095). Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. Opening an Ask Agent cited post uses the same focus path (ADR 0096). Do not invent a cited post. +A linked Event Lineage node opened from that focused popup keeps the +originating flags (ADR 0097). Do not invent a cited post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ea41b6c5..5757a935c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -285,7 +285,8 @@ rebuild), the post list with a named Weekly VOC ISO-8601 week filter Calendar commitments use the same Event Lineage focus path (ADR 0094), Customer master related posts use the same Event Lineage focus path (ADR 0095). Ask Agent cited posts use the same Event Lineage focus path -(ADR 0096), and the full detail popup includes Korean +(ADR 0096). A linked Event Lineage node opened from a focused popup keeps +those flags (ADR 0097), and the full detail popup includes Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + diff --git a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..313ebc036 --- /dev/null +++ b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,5 @@ +# 2.17.0 A linked Event Lineage node keeps GNB focus + +Opening a linked Event Lineage DAG node from a GNB-focused popup keeps +Event Lineage focused and names Keyman and evaluation next. A home-list +DAG walk does not. No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef201405..c187505bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.17.0] - 2026-08-19 + +### Added + +- Opening a linked Event Lineage DAG node from a GNB-focused popup now + keeps Event Lineage focused and names Keyman and evaluation as the next + read. A home-list DAG walk does not add that focus or copy. No TEPP + theta is invented. No cited post, customer, week, or cutoff body is + invented (ADR 0097 / ADR 0096 / ADR 0016). + ## [2.16.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8675cec71..b989aa8b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,3 +99,9 @@ Open Ask Agent. After an authorized answer, cited posts are current. Open a cited post: Event Lineage takes focus and names Keyman and evaluation next (ADR 0096). A home-list open does not. Do not invent a theta or a cited post. + +## Event Lineage DAG walk (v2.17.0) + +From a GNB-focused popup, open a linked Event Lineage node: Event Lineage +stays focused and names the new post as current (ADR 0097). A home-list +DAG walk does not. Do not invent a theta. diff --git a/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..a31084961 --- /dev/null +++ b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,35 @@ +# ADR 0097: A linked Event Lineage node keeps GNB focus + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Opening a Board Weekly VOC post, Calendar commitment, Customer master +related post, Ask Agent cited post, or report member already focuses +Event Lineage (ADR 0093 / ADR 0094 / ADR 0095 / ADR 0096). Clicking a +linked Event Lineage DAG node then called `selectPost` without those +flags. The popup switched records and dropped the GNB focus contract: +Keyman and evaluation were no longer named next. + +## Decision + +A popup-internal Event Lineage DAG open reuses the originating GNB +flags (`fromReportMember`, `fromWeeklyVoc`, `fromCalendar`, +`fromCustomerMaster`, `fromAskAgent`): + +- The popup Event Lineage heading stays focused. +- The popup names the newly opened post as current in Event Lineage + and tells the buyer to read Keyman and evaluation next. + +A Board home-list DAG walk does not focus Event Lineage and does not +add that copy. Closing the popup still clears the originating flags. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). No +cited post, customer, week, or CalDAV event is invented. + +## Consequences + +- GNB destinations share one Event Lineage focus contract across the + first open and a linked DAG walk from that popup. +- A home-list DAG walk stays a home-list open. diff --git a/frontend/package.json b/frontend/package.json index 96c2b72ba..7a697d0c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.16.0", + "version": "2.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8e29ed032..b068c21a4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1997,6 +1997,42 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("opening a linked Event Lineage node from Ask Agent keeps GNB focus; a home-list DAG walk does not", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + await userEvent.click(within(ask).getByRole("button", { name: "Open cited post: Linked post" })); + + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Linked post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByLabelText("Open post: Public post")); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Public post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + const boardAfterAsk = screen.getByRole("region", { name: "Board" }); + await userEvent.click(within(boardAfterAsk).getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + await userEvent.click(screen.getByLabelText("Open post: Linked post")); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 936e0a564..420d73811 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4089,7 +4089,15 @@ function PostList({ } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} - onSelectPost={selectPost} + onSelectPost={(postId) => + selectPost(postId, { + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }) + } onSearch={searchBoard} /> )} diff --git a/pyproject.toml b/pyproject.toml index d8e57ef65..a651b6b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.16.0" +version = "2.17.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From 9fce9551c645fd86a1206473070c60d77f341535 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:16:33 +0900 Subject: [PATCH 02/28] fix(ui): preserve analysis-run cutoff across DAG navigation --- frontend/src/App.tsx | 29 ++++++++++++++++----- frontend/src/analysisRunNavigation.test.ts | 30 ++++++++++++++++++++++ frontend/src/analysisRunNavigation.ts | 17 ++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 frontend/src/analysisRunNavigation.test.ts create mode 100644 frontend/src/analysisRunNavigation.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 420d73811..fd5227023 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -99,6 +99,10 @@ import { useLocale, } from "./i18n"; import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; +import { + analysisRunTargetClock, + type AnalysisRunNavigationContext, +} from "./analysisRunNavigation"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -2451,6 +2455,7 @@ function analysisRunDigestPrefix(digest: string): string { type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; + analysisRunContext?: AnalysisRunNavigationContext; fromReportMember?: boolean; fromWeeklyVoc?: boolean; fromCalendar?: boolean; @@ -2616,10 +2621,13 @@ function analysisRunReportPeriod(run: AnalysisRun): string | null { * title is marked rewritten after this run. */ function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions { - const post = run.visible_posts?.find((item) => item.post_id === postId); - return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), + const analysisRunContext: AnalysisRunNavigationContext = { knowledgeCutoff: run.knowledge_cutoff, + visiblePosts: run.visible_posts ?? [], + }; + return { + ...analysisRunTargetClock(analysisRunContext, postId), + analysisRunContext, }; } @@ -3518,6 +3526,8 @@ function PostList({ const [selectedPostId, setSelectedPostId] = useState(null); const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + const [openedAnalysisRunContext, setOpenedAnalysisRunContext] = + useState(null); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); @@ -3583,6 +3593,7 @@ function PostList({ setFocusedGraph(null); setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedAnalysisRunContext(options?.analysisRunContext ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); @@ -3610,6 +3621,7 @@ function PostList({ setSelectedPostId(null); setOpenedAfterCutoff(false); setOpenedCutoffIso(null); + setOpenedAnalysisRunContext(null); setOpenedFromReportMember(false); setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); @@ -4089,15 +4101,20 @@ function PostList({ } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} - onSelectPost={(postId) => + onSelectPost={(postId) => { + const cutoffOptions = openedAnalysisRunContext + ? analysisRunTargetClock(openedAnalysisRunContext, postId) + : {}; selectPost(postId, { + ...cutoffOptions, + analysisRunContext: openedAnalysisRunContext ?? undefined, fromReportMember: openedFromReportMember, fromWeeklyVoc: openedFromWeeklyVoc, fromCalendar: openedFromCalendar, fromCustomerMaster: openedFromCustomerMaster, fromAskAgent: openedFromAskAgent, - }) - } + }); + }} onSearch={searchBoard} /> )} diff --git a/frontend/src/analysisRunNavigation.test.ts b/frontend/src/analysisRunNavigation.test.ts new file mode 100644 index 000000000..0bbbfff6c --- /dev/null +++ b/frontend/src/analysisRunNavigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { analysisRunTargetClock } from "./analysisRunNavigation"; + +describe("analysisRunTargetClock", () => { + const context = { + knowledgeCutoff: "2026-01-15T12:00:00Z", + visiblePosts: [ + { post_id: "unchanged", live_after_cutoff: false }, + { post_id: "rewritten", live_after_cutoff: true }, + ], + }; + + it("uses the selected DAG target's own live_after_cutoff value", () => { + expect(analysisRunTargetClock(context, "rewritten")).toEqual({ + liveAfterCutoff: true, + knowledgeCutoff: context.knowledgeCutoff, + }); + expect(analysisRunTargetClock(context, "unchanged")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); + + it("keeps the run cutoff and fails closed for a target absent from visible_posts", () => { + expect(analysisRunTargetClock(context, "missing")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); +}); diff --git a/frontend/src/analysisRunNavigation.ts b/frontend/src/analysisRunNavigation.ts new file mode 100644 index 000000000..541f8eb43 --- /dev/null +++ b/frontend/src/analysisRunNavigation.ts @@ -0,0 +1,17 @@ +/** Immutable analysis-run clock context carried across post navigation. */ +export type AnalysisRunNavigationContext = { + knowledgeCutoff: string; + visiblePosts: Array<{ post_id: string; live_after_cutoff?: boolean }>; +}; + +/** Resolve the selected target's own write-clock flag under the originating run cutoff. */ +export function analysisRunTargetClock( + context: AnalysisRunNavigationContext, + postId: string, +): { liveAfterCutoff: boolean; knowledgeCutoff: string } { + const target = context.visiblePosts.find((post) => post.post_id === postId); + return { + liveAfterCutoff: Boolean(target?.live_after_cutoff), + knowledgeCutoff: context.knowledgeCutoff, + }; +} From eb06d6da2c9f047d26c141999b9f2e3bf99b5e77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:43:33 +0900 Subject: [PATCH 03/28] fix: ignore stale post detail responses --- frontend/src/App.test.tsx | 41 +++++++++++++- frontend/src/App.tsx | 113 +++++++++++++++++++++++++++++++------- 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b068c21a4..91cedc656 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -83,6 +83,7 @@ describe("App, authenticated", () => { pendingTeppRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; + deferPostOneSummary?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -96,7 +97,10 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }[]; - }): ReturnType & { releaseMe: () => void } { + }): ReturnType & { + releaseMe: () => void; + releasePostOneSummary: () => void; + } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -127,6 +131,12 @@ describe("App, authenticated", () => { releaseMe = resolve; }) : Promise.resolve(); + let releasePostOneSummary = () => {}; + const postOneSummaryReady = options?.deferPostOneSummary + ? new Promise((resolve) => { + releasePostOneSummary = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -1146,7 +1156,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/summary")) { - return Promise.resolve( + return postOneSummaryReady.then(() => jsonResponse({ post_id: "post-1", korean_summary: "이것은 요약입니다.", @@ -1676,7 +1686,7 @@ describe("App, authenticated", () => { return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); - return Object.assign(fetchMock, { releaseMe }); + return Object.assign(fetchMock, { releaseMe, releasePostOneSummary }); } it("renders safe Ask Agent evidence under each cited post", async () => { @@ -1997,6 +2007,31 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("ignores a stale summary after Event Lineage navigation changes the selected post", async () => { + const fetchMock = stubBackend({ deferPostOneSummary: true }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click( + within(board).getByRole("button", { name: "View post: Public post" }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + + const linkedPosts = screen.getAllByLabelText("Open post: Linked post"); + await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(await screen.findByText("연결된 글입니다.")).toBeInTheDocument(); + + fetchMock.releasePostOneSummary(); + + await waitFor(() => { + expect(screen.getByText("연결된 글입니다.")).toBeInTheDocument(); + expect(screen.queryByText("이것은 요약입니다.")).not.toBeInTheDocument(); + }); + }); + it("opening a linked Event Lineage node from Ask Agent keeps GNB focus; a home-list DAG walk does not", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd5227023..c14cdfd3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1691,31 +1691,58 @@ function PostDetailPopup({ const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); + const detailRequestGeneration = useRef(0); + function reloadKeymen() { + const generation = detailRequestGeneration.current; fetchPostKeymen(accessToken, postId) .then((r) => { + if (detailRequestGeneration.current !== generation) return; setKeymen(r.keymen); setSourceAuthorContext(r.source_author_context ?? null); }) .catch(() => { + if (detailRequestGeneration.current !== generation) return; setKeymen([]); setSourceAuthorContext(null); }); fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + .then((r) => { + if (detailRequestGeneration.current === generation) setAffiliateTrees(r.trees); + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setAffiliateTrees([]); + }); + fetchPostVocEvidence(accessToken, postId) + .then((value) => { + if (detailRequestGeneration.current === generation) setVocEvidence(value); + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setVocEvidence(null); + }); reloadCounterparties(); } function reloadCounterparties() { + const generation = detailRequestGeneration.current; fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); + .then((r) => { + if (detailRequestGeneration.current === generation) { + setCounterparties(r.counterparties); + } + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setCounterparties([]); + }); } useEffect(() => { + const generation = detailRequestGeneration.current + 1; + detailRequestGeneration.current = generation; + const isCurrent = () => detailRequestGeneration.current === generation; + setPost(null); + setImageContent([]); setStructureUnits([]); setBookmarked(null); setBookmarkSaving(false); @@ -1735,55 +1762,101 @@ function PostDetailPopup({ setFocusEntity(null); setFocusTeam(null); const asOf = liveBodyWarning && knowledgeCutoff ? knowledgeCutoff : undefined; - fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err))); + fetchPost(accessToken, postId, asOf) + .then((value) => { + if (isCurrent()) setPost(value); + }) + .catch((err) => { + if (isCurrent()) setError(String(err)); + }); const reloadContent = () => fetchPostContent(accessToken, postId) .then((content) => { + if (!isCurrent()) return; setImageContent(content.images); setStructureUnits(content.units); }) .catch(() => { + if (!isCurrent()) return; setImageContent([]); setStructureUnits([]); }); - reloadContent(); + void reloadContent(); fetchPostBookmark(accessToken, postId) - .then((r) => setBookmarked(r.bookmarked)) + .then((r) => { + if (isCurrent()) setBookmarked(r.bookmarked); + }) .catch(() => { - setBookmarked(null); + if (isCurrent()) setBookmarked(null); }); fetchPostEvaluation(accessToken, postId) - .then((r) => setEvaluation(r.responses)) - .catch(() => setEvaluation([])); + .then((r) => { + if (isCurrent()) setEvaluation(r.responses); + }) + .catch(() => { + if (isCurrent()) setEvaluation([]); + }); fetchPostSummary(accessToken, postId) .then((value) => { + if (!isCurrent()) return; setSummary(value); - reloadContent(); + void reloadContent(); }) .catch((err) => { + if (!isCurrent()) return; setSummary(null); setSummaryError(summaryFetchError(err)); }); fetchPostFiveW1H(accessToken, postId) - .then(setFiveW1H) - .catch(() => setFiveW1H(null)); + .then((value) => { + if (isCurrent()) setFiveW1H(value); + }) + .catch(() => { + if (isCurrent()) setFiveW1H(null); + }); fetchPostKeymen(accessToken, postId) .then((r) => { + if (!isCurrent()) return; setKeymen(r.keymen); setSourceAuthorContext(r.source_author_context ?? null); }) .catch(() => { + if (!isCurrent()) return; setKeymen([]); setSourceAuthorContext(null); }); fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); - fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); + .then((r) => { + if (isCurrent()) setCounterparties(r.counterparties); + }) + .catch(() => { + if (isCurrent()) setCounterparties([]); + }); + fetchPostLineage(accessToken, postId) + .then((value) => { + if (isCurrent()) setLineage(value); + }) + .catch(() => { + if (isCurrent()) setLineage(null); + }); fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + .then((r) => { + if (isCurrent()) setAffiliateTrees(r.trees); + }) + .catch(() => { + if (isCurrent()) setAffiliateTrees([]); + }); + fetchPostVocEvidence(accessToken, postId) + .then((value) => { + if (isCurrent()) setVocEvidence(value); + }) + .catch(() => { + if (isCurrent()) setVocEvidence(null); + }); + + return () => { + if (isCurrent()) detailRequestGeneration.current = generation + 1; + }; }, [postId, accessToken, liveBodyWarning, knowledgeCutoff]); const permanentLink = (() => { From 222262ec741683b9991adfabb178962371be0470 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:29:55 +0000 Subject: [PATCH 04/28] feat: open Ask Agent cited posts onto Event Lineage (v2.16.0) Ask Agent names authorized cited posts as current after an answer and to open one to read Event Lineage. That open focuses the popup Event Lineage heading and names Keyman and evaluation next. Home-list opens do not. No TEPP theta or cited post is invented. --- AGENTS.md | 2 + ARCHITECTURE.md | 3 +- .../2.16.0-ask-agent-open-event-lineage.md | 5 ++ CHANGELOG.md | 10 ++++ CLAUDE.md | 7 +++ ...96-ask-agent-open-focuses-event-lineage.md | 36 +++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 52 +++++++++++++++++++ frontend/src/App.tsx | 35 +++++++++++-- frontend/src/i18n.test.ts | 1 + frontend/src/i18n.ts | 12 +++++ pyproject.toml | 2 +- 12 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md create mode 100644 docs/adr/0096-ask-agent-open-focuses-event-lineage.md diff --git a/AGENTS.md b/AGENTS.md index 3ec1221b6..31f89ec8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,8 @@ Opening a Calendar commitment uses the same focus path (ADR 0094). Do not invent a week, a theta, a cutoff body, or a CalDAV event. Opening a Customer master related post uses the same focus path (ADR 0095). Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. +Opening an Ask Agent cited post uses the same focus path (ADR 0096). Do not +invent a cited post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 78a4886e3..5ea41b6c5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -284,7 +284,8 @@ rebuild), the post list with a named Weekly VOC ISO-8601 week filter (ADR 0092; opening that filtered post focuses Event Lineage, ADR 0093), Calendar commitments use the same Event Lineage focus path (ADR 0094), Customer master related posts use the same Event Lineage focus path -(ADR 0095), and the full detail popup includes Korean +(ADR 0095). Ask Agent cited posts use the same Event Lineage focus path +(ADR 0096), and the full detail popup includes Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + diff --git a/CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md b/CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md new file mode 100644 index 000000000..d60ab9945 --- /dev/null +++ b/CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md @@ -0,0 +1,5 @@ +# 2.16.0 Opening an Ask Agent cited post focuses Event Lineage + +Ask Agent names authorized cited posts as current after an answer and to +open one to read Event Lineage. That open focuses the popup Event Lineage +heading. Home-list opens do not. No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5574c67..1ef201405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.16.0] - 2026-08-19 + +### Added + +- Opening an Ask Agent cited post now focuses Event Lineage and names Keyman + and evaluation as the next read. After an authorized answer, Ask Agent + names cited posts as current before that open. Home-list opens do not add + that focus or copy. No TEPP theta is invented. No cited post is invented +(ADR 0096 / ADR 0039 / ADR 0016). + ## [2.15.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a332cb5fc..8675cec71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,3 +92,10 @@ Open Customer master. Authorized customer entities are current. Open a related post: Event Lineage takes focus and names Keyman and evaluation next (ADR 0095). A home-list open does not. Do not invent a theta or a customer. + +## Ask Agent open (v2.16.0) + +Open Ask Agent. After an authorized answer, cited posts are current. Open +a cited post: Event Lineage takes focus and names Keyman and evaluation +next (ADR 0096). A home-list open does not. Do not invent a theta or a +cited post. diff --git a/docs/adr/0096-ask-agent-open-focuses-event-lineage.md b/docs/adr/0096-ask-agent-open-focuses-event-lineage.md new file mode 100644 index 000000000..5a323fc25 --- /dev/null +++ b/docs/adr/0096-ask-agent-open-focuses-event-lineage.md @@ -0,0 +1,36 @@ +# ADR 0096: Opening an Ask Agent cited post focuses Event Lineage + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Board Weekly VOC, Calendar, and Customer master opens already focus Event +Lineage (ADR 0093 / ADR 0094 / ADR 0095). Ask Agent is the remaining buyer +GNB destination that opens an authorized cited post. That open was a +home-list open: the popup body appeared and Event Lineage did not take +focus. + +## Decision + +Opening a cited post on Ask Agent is a `fromAskAgent` open. That open +reuses the Event Lineage focus path used by report-member, Weekly VOC, +Calendar, and Customer master opens: + +- After an authorized answer, Ask Agent names the next action: cited posts + are current; open a cited post to read Event Lineage. +- The popup Event Lineage heading takes focus. +- The popup names the opened post as current in Event Lineage and tells + the buyer to read Keyman and evaluation next. + +A Board home-list open does not focus Event Lineage and does not add that +copy. A `?post=` deep link is still a home-list open. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). Ask Agent +does not invent a cited post (ADR 0039). + +## Consequences + +- Ask Agent, Customer master, Calendar, Weekly VOC, and report-member + opens share one focus contract. +- Closing the popup clears the Ask Agent open flag. diff --git a/frontend/package.json b/frontend/package.json index f499c9871..96c2b72ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.15.0", + "version": "2.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8a6c6ed74..6649ec126 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -221,6 +221,9 @@ describe("App, authenticated", () => { if (url.endsWith("/api/posts/post-1/activity") && method === "GET") { return Promise.resolve(jsonResponse({ events })); } + if (url.endsWith("/api/posts/post-2/activity") && method === "GET") { + return Promise.resolve(jsonResponse({ events: [] })); + } if (url.endsWith("/api/posts/post-1/derive-commitment") && method === "POST") { if (options?.chatUnavailable) { return Promise.resolve( @@ -1208,6 +1211,17 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/posts/post-2/summary")) { + return Promise.resolve( + jsonResponse({ + post_id: "post-2", + korean_summary: "연결된 글입니다.", + key_events: [], + roles_and_responsibilities: [], + project_mentions: [], + }), + ); + } if (url.endsWith("/api/posts/post-1/keymen")) { return Promise.resolve( jsonResponse({ @@ -1518,6 +1532,15 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/posts/post-2/lineage")) { + return Promise.resolve( + jsonResponse({ + post_id: "post-2", + direct: [{ post_id: "post-1", post_title: "Public post" }], + indirect: [], + }), + ); + } if (url.endsWith("/api/posts/post-1/chat") && method === "GET") { return Promise.resolve( jsonResponse({ @@ -1997,6 +2020,35 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); + it("opening an Ask Agent cited post focuses Event Lineage; a home list open does not", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + expect(await within(ask).findByLabelText("Next action")).toHaveTextContent( + "Authorized cited posts are current. Open a cited post to read Event Lineage.", + ); + await userEvent.click(within(ask).getByRole("button", { name: "Open cited post: Linked post" })); + + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Linked post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + const boardAfterAsk = screen.getByRole("region", { name: "Board" }); + await userEvent.click(within(boardAfterAsk).getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe44acf7e..c09b0a9e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2473,6 +2473,7 @@ type SelectPostOptions = { fromWeeklyVoc?: boolean; fromCalendar?: boolean; fromCustomerMaster?: boolean; + fromAskAgent?: boolean; }; /** @@ -3517,6 +3518,7 @@ function PostList({ postIdToOpen = null, postOpenFromCalendar = false, postOpenFromCustomerMaster = false, + postOpenFromAskAgent = false, onPostOpened, }: { accessToken: string; @@ -3524,6 +3526,7 @@ function PostList({ postIdToOpen?: string | null; postOpenFromCalendar?: boolean; postOpenFromCustomerMaster?: boolean; + postOpenFromAskAgent?: boolean; onPostOpened?: () => void; }) { const [posts, setPosts] = useState(null); @@ -3545,6 +3548,7 @@ function PostList({ const [openedFromWeeklyVoc, setOpenedFromWeeklyVoc] = useState(false); const [openedFromCalendar, setOpenedFromCalendar] = useState(false); const [openedFromCustomerMaster, setOpenedFromCustomerMaster] = useState(false); + const [openedFromAskAgent, setOpenedFromAskAgent] = useState(false); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3601,6 +3605,7 @@ function PostList({ setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); setOpenedFromCustomerMaster(Boolean(options?.fromCustomerMaster)); + setOpenedFromAskAgent(Boolean(options?.fromAskAgent)); } useEffect(() => { @@ -3608,9 +3613,16 @@ function PostList({ selectPost(postIdToOpen, { fromCalendar: postOpenFromCalendar, fromCustomerMaster: postOpenFromCustomerMaster, + fromAskAgent: postOpenFromAskAgent, }); onPostOpened?.(); - }, [onPostOpened, postIdToOpen, postOpenFromCalendar, postOpenFromCustomerMaster]); + }, [ + onPostOpened, + postIdToOpen, + postOpenFromCalendar, + postOpenFromCustomerMaster, + postOpenFromAskAgent, + ]); function closeSelectedPost() { setSelectedPostId(null); @@ -3620,6 +3632,7 @@ function PostList({ setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); setOpenedFromCustomerMaster(false); + setOpenedFromAskAgent(false); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -4089,7 +4102,8 @@ function PostList({ openedFromReportMember || openedFromWeeklyVoc || openedFromCalendar || - openedFromCustomerMaster + openedFromCustomerMaster || + openedFromAskAgent } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} @@ -4550,11 +4564,18 @@ function AskAgentPanel({ {answer.next_action ?

{t(answer.next_action)}

: null} {answer.cited_posts && answer.cited_posts.length > 0 && ( <> +

+ {t("Authorized cited posts are current. Open a cited post to read Event Lineage.")} +

{t("Cited posts")}

    {answer.cited_posts.map((post) => (
  • - {answer.cited_post_evidence?.find((item) => item.post_id === post.post_id)?.facts.length ? ( @@ -4590,6 +4611,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean }); const [postOpenFromCalendar, setPostOpenFromCalendar] = useState(false); const [postOpenFromCustomerMaster, setPostOpenFromCustomerMaster] = useState(false); + const [postOpenFromAskAgent, setPostOpenFromAskAgent] = useState(false); // Test-only compatibility for legacy analysis-panel coverage; this prop // never forces the panels open outside Vitest. In a real build the // advanced-review section (ADR 0037) is gated on PostList's own @@ -4671,10 +4693,12 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean postIdToOpen={postToOpen} postOpenFromCalendar={postOpenFromCalendar} postOpenFromCustomerMaster={postOpenFromCustomerMaster} + postOpenFromAskAgent={postOpenFromAskAgent} onPostOpened={() => { setPostToOpen(null); setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(false); + setPostOpenFromAskAgent(false); }} /> ) : null} @@ -4685,6 +4709,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostToOpen(postId); setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(true); + setPostOpenFromAskAgent(false); setDestination("board"); }} /> @@ -4697,6 +4722,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostToOpen(postId); setPostOpenFromCalendar(true); setPostOpenFromCustomerMaster(false); + setPostOpenFromAskAgent(false); setDestination("board"); }} /> @@ -4706,6 +4732,9 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean accessToken={accessToken} onOpenPost={(postId) => { setPostToOpen(postId); + setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(false); + setPostOpenFromAskAgent(true); setDestination("board"); }} /> diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index fd9341fc2..5bff6a284 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -40,6 +40,7 @@ describe("i18n", () => { "All weeks", "Authorized commitments are current. Open a commitment to read Event Lineage.", "Authorized customer entities are current. Open a related post to read Event Lineage.", + "Authorized cited posts are current. Open a cited post to read Event Lineage.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ab915b847..95ac0e6f5 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -128,6 +128,9 @@ const TRANSLATIONS: Partial>> = { "권한이 있는 일정이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 일정을 여세요.", "Authorized customer entities are current. Open a related post to read Event Lineage.": "권한이 있는 고객 엔터티가 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 관련 글을 여세요.", + "Authorized cited posts are current. Open a cited post to read Event Lineage.": + "권한이 있는 인용 글이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 인용 글을 여세요.", + "Open cited post:": "인용 글 열기:", "Filter by visibility": "공개 여부로 필터", "Sort posts": "글 정렬", "All VOC types": "모든 VOC 유형", @@ -461,6 +464,9 @@ const TRANSLATIONS: Partial>> = { "已授权承诺为当前内容。打开一项承诺阅读事件谱系。", "Authorized customer entities are current. Open a related post to read Event Lineage.": "已授权客户实体为当前内容。打开一篇相关文章阅读事件谱系。", + "Authorized cited posts are current. Open a cited post to read Event Lineage.": + "已授权引用文章为当前内容。打开一篇引用文章阅读事件谱系。", + "Open cited post:": "打开引用文章:", "Filter by visibility": "按公开状态筛选", "Sort posts": "排序文章", "All VOC types": "所有 VOC 类型", @@ -817,6 +823,9 @@ const TRANSLATIONS: Partial>> = { "権限のある約束が現在表示されています。イベント系譜を読むには約束を開いてください。", "Authorized customer entities are current. Open a related post to read Event Lineage.": "権限のある顧客エンティティが現在表示されています。イベント系譜を読むには関連投稿を開いてください。", + "Authorized cited posts are current. Open a cited post to read Event Lineage.": + "権限のある引用投稿が現在表示されています。イベント系譜を読むには引用投稿を開いてください。", + "Open cited post:": "引用投稿を開く:", "Filter by visibility": "公開状態で絞り込み", "Sort posts": "投稿を並べ替え", "All VOC types": "すべての VOC 種類", @@ -1149,6 +1158,9 @@ const TRANSLATIONS: Partial>> = { "Các cam kết được phép đang hiện tại. Hãy mở một cam kết để đọc Dòng sự kiện.", "Authorized customer entities are current. Open a related post to read Event Lineage.": "Các thực thể khách hàng được cấp quyền đang hiện tại. Hãy mở một bài liên quan để đọc Dòng sự kiện.", + "Authorized cited posts are current. Open a cited post to read Event Lineage.": + "Các bài được trích dẫn được cấp quyền đang hiện tại. Hãy mở một bài trích dẫn để đọc Dòng sự kiện.", + "Open cited post:": "Mở bài trích dẫn:", "Filter by visibility": "Lọc theo trạng thái hiển thị", "Sort posts": "Sắp xếp bài viết", "All VOC types": "Tất cả loại VOC", diff --git a/pyproject.toml b/pyproject.toml index c94310c83..f6634f3f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.15.0" +version = "2.16.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From e9fe3d7072615fab84ecc87ba463229ad2e8d1ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 14:18:47 +0000 Subject: [PATCH 05/28] feat: keep GNB Event Lineage focus on a linked DAG node (v2.17.0) Opening a linked Event Lineage node from a GNB-focused popup keeps Event Lineage focused and names Keyman and evaluation next. A home-list DAG walk does not. No TEPP theta or cited post is invented. --- AGENTS.md | 2 ++ ARCHITECTURE.md | 3 +- ...17.0-event-lineage-node-keeps-gnb-focus.md | 5 +++ CHANGELOG.md | 10 ++++++ CLAUDE.md | 6 ++++ ...0097-event-lineage-node-keeps-gnb-focus.md | 35 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 36 +++++++++++++++++++ frontend/src/App.tsx | 10 +++++- pyproject.toml | 2 +- 10 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md create mode 100644 docs/adr/0097-event-lineage-node-keeps-gnb-focus.md diff --git a/AGENTS.md b/AGENTS.md index 31f89ec8d..25c482840 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,8 @@ Opening a Customer master related post uses the same focus path (ADR 0095). Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. Opening an Ask Agent cited post uses the same focus path (ADR 0096). Do not invent a cited post. +A linked Event Lineage node opened from that focused popup keeps the +originating flags (ADR 0097). Do not invent a cited post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ea41b6c5..5757a935c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -285,7 +285,8 @@ rebuild), the post list with a named Weekly VOC ISO-8601 week filter Calendar commitments use the same Event Lineage focus path (ADR 0094), Customer master related posts use the same Event Lineage focus path (ADR 0095). Ask Agent cited posts use the same Event Lineage focus path -(ADR 0096), and the full detail popup includes Korean +(ADR 0096). A linked Event Lineage node opened from a focused popup keeps +those flags (ADR 0097), and the full detail popup includes Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + diff --git a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..313ebc036 --- /dev/null +++ b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,5 @@ +# 2.17.0 A linked Event Lineage node keeps GNB focus + +Opening a linked Event Lineage DAG node from a GNB-focused popup keeps +Event Lineage focused and names Keyman and evaluation next. A home-list +DAG walk does not. No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef201405..c187505bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.17.0] - 2026-08-19 + +### Added + +- Opening a linked Event Lineage DAG node from a GNB-focused popup now + keeps Event Lineage focused and names Keyman and evaluation as the next + read. A home-list DAG walk does not add that focus or copy. No TEPP + theta is invented. No cited post, customer, week, or cutoff body is + invented (ADR 0097 / ADR 0096 / ADR 0016). + ## [2.16.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8675cec71..b989aa8b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,3 +99,9 @@ Open Ask Agent. After an authorized answer, cited posts are current. Open a cited post: Event Lineage takes focus and names Keyman and evaluation next (ADR 0096). A home-list open does not. Do not invent a theta or a cited post. + +## Event Lineage DAG walk (v2.17.0) + +From a GNB-focused popup, open a linked Event Lineage node: Event Lineage +stays focused and names the new post as current (ADR 0097). A home-list +DAG walk does not. Do not invent a theta. diff --git a/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..a31084961 --- /dev/null +++ b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,35 @@ +# ADR 0097: A linked Event Lineage node keeps GNB focus + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Opening a Board Weekly VOC post, Calendar commitment, Customer master +related post, Ask Agent cited post, or report member already focuses +Event Lineage (ADR 0093 / ADR 0094 / ADR 0095 / ADR 0096). Clicking a +linked Event Lineage DAG node then called `selectPost` without those +flags. The popup switched records and dropped the GNB focus contract: +Keyman and evaluation were no longer named next. + +## Decision + +A popup-internal Event Lineage DAG open reuses the originating GNB +flags (`fromReportMember`, `fromWeeklyVoc`, `fromCalendar`, +`fromCustomerMaster`, `fromAskAgent`): + +- The popup Event Lineage heading stays focused. +- The popup names the newly opened post as current in Event Lineage + and tells the buyer to read Keyman and evaluation next. + +A Board home-list DAG walk does not focus Event Lineage and does not +add that copy. Closing the popup still clears the originating flags. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). No +cited post, customer, week, or CalDAV event is invented. + +## Consequences + +- GNB destinations share one Event Lineage focus contract across the + first open and a linked DAG walk from that popup. +- A home-list DAG walk stays a home-list open. diff --git a/frontend/package.json b/frontend/package.json index 96c2b72ba..7a697d0c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.16.0", + "version": "2.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 6649ec126..63505b008 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2049,6 +2049,42 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("opening a linked Event Lineage node from Ask Agent keeps GNB focus; a home-list DAG walk does not", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + await userEvent.click(within(ask).getByRole("button", { name: "Open cited post: Linked post" })); + + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Linked post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByLabelText("Open post: Public post")); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Public post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + const boardAfterAsk = screen.getByRole("region", { name: "Board" }); + await userEvent.click(within(boardAfterAsk).getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + await userEvent.click(screen.getByLabelText("Open post: Linked post")); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c09b0a9e2..d051905d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4107,7 +4107,15 @@ function PostList({ } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} - onSelectPost={selectPost} + onSelectPost={(postId) => + selectPost(postId, { + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }) + } onSearch={searchBoard} /> )} diff --git a/pyproject.toml b/pyproject.toml index f6634f3f1..467f0e149 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.16.0" +version = "2.17.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From c290748146b63f210cdefb72a56e84a0be78d08e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:16:33 +0900 Subject: [PATCH 06/28] fix(ui): preserve analysis-run cutoff across DAG navigation --- frontend/src/App.tsx | 29 ++++++++++++++++----- frontend/src/analysisRunNavigation.test.ts | 30 ++++++++++++++++++++++ frontend/src/analysisRunNavigation.ts | 17 ++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 frontend/src/analysisRunNavigation.test.ts create mode 100644 frontend/src/analysisRunNavigation.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d051905d2..e41475ec3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -99,6 +99,10 @@ import { useLocale, } from "./i18n"; import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; +import { + analysisRunTargetClock, + type AnalysisRunNavigationContext, +} from "./analysisRunNavigation"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -2469,6 +2473,7 @@ function analysisRunDigestPrefix(digest: string): string { type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; + analysisRunContext?: AnalysisRunNavigationContext; fromReportMember?: boolean; fromWeeklyVoc?: boolean; fromCalendar?: boolean; @@ -2634,10 +2639,13 @@ function analysisRunReportPeriod(run: AnalysisRun): string | null { * title is marked rewritten after this run. */ function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions { - const post = run.visible_posts?.find((item) => item.post_id === postId); - return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), + const analysisRunContext: AnalysisRunNavigationContext = { knowledgeCutoff: run.knowledge_cutoff, + visiblePosts: run.visible_posts ?? [], + }; + return { + ...analysisRunTargetClock(analysisRunContext, postId), + analysisRunContext, }; } @@ -3536,6 +3544,8 @@ function PostList({ const [selectedPostId, setSelectedPostId] = useState(null); const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + const [openedAnalysisRunContext, setOpenedAnalysisRunContext] = + useState(null); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); @@ -3601,6 +3611,7 @@ function PostList({ setFocusedGraph(null); setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedAnalysisRunContext(options?.analysisRunContext ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); @@ -3628,6 +3639,7 @@ function PostList({ setSelectedPostId(null); setOpenedAfterCutoff(false); setOpenedCutoffIso(null); + setOpenedAnalysisRunContext(null); setOpenedFromReportMember(false); setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); @@ -4107,15 +4119,20 @@ function PostList({ } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} - onSelectPost={(postId) => + onSelectPost={(postId) => { + const cutoffOptions = openedAnalysisRunContext + ? analysisRunTargetClock(openedAnalysisRunContext, postId) + : {}; selectPost(postId, { + ...cutoffOptions, + analysisRunContext: openedAnalysisRunContext ?? undefined, fromReportMember: openedFromReportMember, fromWeeklyVoc: openedFromWeeklyVoc, fromCalendar: openedFromCalendar, fromCustomerMaster: openedFromCustomerMaster, fromAskAgent: openedFromAskAgent, - }) - } + }); + }} onSearch={searchBoard} /> )} diff --git a/frontend/src/analysisRunNavigation.test.ts b/frontend/src/analysisRunNavigation.test.ts new file mode 100644 index 000000000..0bbbfff6c --- /dev/null +++ b/frontend/src/analysisRunNavigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { analysisRunTargetClock } from "./analysisRunNavigation"; + +describe("analysisRunTargetClock", () => { + const context = { + knowledgeCutoff: "2026-01-15T12:00:00Z", + visiblePosts: [ + { post_id: "unchanged", live_after_cutoff: false }, + { post_id: "rewritten", live_after_cutoff: true }, + ], + }; + + it("uses the selected DAG target's own live_after_cutoff value", () => { + expect(analysisRunTargetClock(context, "rewritten")).toEqual({ + liveAfterCutoff: true, + knowledgeCutoff: context.knowledgeCutoff, + }); + expect(analysisRunTargetClock(context, "unchanged")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); + + it("keeps the run cutoff and fails closed for a target absent from visible_posts", () => { + expect(analysisRunTargetClock(context, "missing")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); +}); diff --git a/frontend/src/analysisRunNavigation.ts b/frontend/src/analysisRunNavigation.ts new file mode 100644 index 000000000..541f8eb43 --- /dev/null +++ b/frontend/src/analysisRunNavigation.ts @@ -0,0 +1,17 @@ +/** Immutable analysis-run clock context carried across post navigation. */ +export type AnalysisRunNavigationContext = { + knowledgeCutoff: string; + visiblePosts: Array<{ post_id: string; live_after_cutoff?: boolean }>; +}; + +/** Resolve the selected target's own write-clock flag under the originating run cutoff. */ +export function analysisRunTargetClock( + context: AnalysisRunNavigationContext, + postId: string, +): { liveAfterCutoff: boolean; knowledgeCutoff: string } { + const target = context.visiblePosts.find((post) => post.post_id === postId); + return { + liveAfterCutoff: Boolean(target?.live_after_cutoff), + knowledgeCutoff: context.knowledgeCutoff, + }; +} From 77c86e4e33cbdcfe080ef106b62d5e927c54d41d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:43:33 +0900 Subject: [PATCH 07/28] fix: ignore stale post detail responses --- frontend/src/App.test.tsx | 36 +++++++++++- frontend/src/App.tsx | 113 ++++++++++++++++++++++++++++++-------- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 63505b008..c82ad1ebe 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -83,6 +83,7 @@ describe("App, authenticated", () => { pendingTeppRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; + deferPostOneSummary?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -101,6 +102,7 @@ describe("App, authenticated", () => { releaseMe: () => void; releaseGroupRelated: () => void; releaseDemoRelated: () => void; + releasePostOneSummary: () => void; } { const statusLabel: Record = { open: "Open", @@ -144,6 +146,12 @@ describe("App, authenticated", () => { releaseDemoRelated = resolve; }) : Promise.resolve(); + let releasePostOneSummary = () => {}; + const postOneSummaryReady = options?.deferPostOneSummary + ? new Promise((resolve) => { + releasePostOneSummary = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -1163,7 +1171,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/posts/post-1/summary")) { - return Promise.resolve( + return postOneSummaryReady.then(() => jsonResponse({ post_id: "post-1", korean_summary: "이것은 요약입니다.", @@ -1706,6 +1714,7 @@ describe("App, authenticated", () => { releaseMe, releaseGroupRelated, releaseDemoRelated, + releasePostOneSummary, }); } @@ -2049,6 +2058,31 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("ignores a stale summary after Event Lineage navigation changes the selected post", async () => { + const fetchMock = stubBackend({ deferPostOneSummary: true }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click( + within(board).getByRole("button", { name: "View post: Public post" }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + + const linkedPosts = screen.getAllByLabelText("Open post: Linked post"); + await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(await screen.findByText("연결된 글입니다.")).toBeInTheDocument(); + + fetchMock.releasePostOneSummary(); + + await waitFor(() => { + expect(screen.getByText("연결된 글입니다.")).toBeInTheDocument(); + expect(screen.queryByText("이것은 요약입니다.")).not.toBeInTheDocument(); + }); + }); + it("opening a linked Event Lineage node from Ask Agent keeps GNB focus; a home-list DAG walk does not", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e41475ec3..710b7d41a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1695,31 +1695,58 @@ function PostDetailPopup({ const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); + const detailRequestGeneration = useRef(0); + function reloadKeymen() { + const generation = detailRequestGeneration.current; fetchPostKeymen(accessToken, postId) .then((r) => { + if (detailRequestGeneration.current !== generation) return; setKeymen(r.keymen); setSourceAuthorContext(r.source_author_context ?? null); }) .catch(() => { + if (detailRequestGeneration.current !== generation) return; setKeymen([]); setSourceAuthorContext(null); }); fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + .then((r) => { + if (detailRequestGeneration.current === generation) setAffiliateTrees(r.trees); + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setAffiliateTrees([]); + }); + fetchPostVocEvidence(accessToken, postId) + .then((value) => { + if (detailRequestGeneration.current === generation) setVocEvidence(value); + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setVocEvidence(null); + }); reloadCounterparties(); } function reloadCounterparties() { + const generation = detailRequestGeneration.current; fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); + .then((r) => { + if (detailRequestGeneration.current === generation) { + setCounterparties(r.counterparties); + } + }) + .catch(() => { + if (detailRequestGeneration.current === generation) setCounterparties([]); + }); } useEffect(() => { + const generation = detailRequestGeneration.current + 1; + detailRequestGeneration.current = generation; + const isCurrent = () => detailRequestGeneration.current === generation; + setPost(null); + setImageContent([]); setStructureUnits([]); setBookmarked(null); setBookmarkSaving(false); @@ -1741,11 +1768,17 @@ function PostDetailPopup({ let disposed = false; let contentPollTimer: number | undefined; const asOf = liveBodyWarning && knowledgeCutoff ? knowledgeCutoff : undefined; - fetchPost(accessToken, postId, asOf).then(setPost).catch((err) => setError(String(err))); + fetchPost(accessToken, postId, asOf) + .then((value) => { + if (isCurrent()) setPost(value); + }) + .catch((err) => { + if (isCurrent()) setError(String(err)); + }); const reloadContent = () => fetchPostContent(accessToken, postId) .then((content) => { - if (disposed) return; + if (disposed || !isCurrent()) return; setImageContent(content.images); setStructureUnits(content.units); if (content.status === "processing" && contentPollTimer === undefined) { @@ -1756,51 +1789,87 @@ function PostDetailPopup({ } }) .catch(() => { - if (disposed) return; + if (disposed || !isCurrent()) return; setImageContent([]); setStructureUnits([]); }); - reloadContent(); + void reloadContent(); fetchPostBookmark(accessToken, postId) - .then((r) => setBookmarked(r.bookmarked)) + .then((r) => { + if (isCurrent()) setBookmarked(r.bookmarked); + }) .catch(() => { - setBookmarked(null); + if (isCurrent()) setBookmarked(null); }); fetchPostEvaluation(accessToken, postId) - .then((r) => setEvaluation(r.responses)) - .catch(() => setEvaluation([])); + .then((r) => { + if (isCurrent()) setEvaluation(r.responses); + }) + .catch(() => { + if (isCurrent()) setEvaluation([]); + }); fetchPostSummary(accessToken, postId) .then((value) => { + if (!isCurrent()) return; setSummary(value); - reloadContent(); + void reloadContent(); }) .catch((err) => { + if (!isCurrent()) return; setSummary(null); setSummaryError(summaryFetchError(err)); }); fetchPostFiveW1H(accessToken, postId) - .then(setFiveW1H) - .catch(() => setFiveW1H(null)); + .then((value) => { + if (isCurrent()) setFiveW1H(value); + }) + .catch(() => { + if (isCurrent()) setFiveW1H(null); + }); fetchPostKeymen(accessToken, postId) .then((r) => { + if (!isCurrent()) return; setKeymen(r.keymen); setSourceAuthorContext(r.source_author_context ?? null); }) .catch(() => { + if (!isCurrent()) return; setKeymen([]); setSourceAuthorContext(null); }); fetchPostCounterparties(accessToken, postId) - .then((r) => setCounterparties(r.counterparties)) - .catch(() => setCounterparties([])); - fetchPostLineage(accessToken, postId).then(setLineage).catch(() => setLineage(null)); + .then((r) => { + if (isCurrent()) setCounterparties(r.counterparties); + }) + .catch(() => { + if (isCurrent()) setCounterparties([]); + }); + fetchPostLineage(accessToken, postId) + .then((value) => { + if (isCurrent()) setLineage(value); + }) + .catch(() => { + if (isCurrent()) setLineage(null); + }); fetchPostAffiliateTree(accessToken, postId) - .then((r) => setAffiliateTrees(r.trees)) - .catch(() => setAffiliateTrees([])); - fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + .then((r) => { + if (isCurrent()) setAffiliateTrees(r.trees); + }) + .catch(() => { + if (isCurrent()) setAffiliateTrees([]); + }); + fetchPostVocEvidence(accessToken, postId) + .then((value) => { + if (isCurrent()) setVocEvidence(value); + }) + .catch(() => { + if (isCurrent()) setVocEvidence(null); + }); + return () => { disposed = true; if (contentPollTimer !== undefined) window.clearTimeout(contentPollTimer); + if (isCurrent()) detailRequestGeneration.current = generation + 1; }; }, [postId, accessToken, liveBodyWarning, knowledgeCutoff]); From f0c8b1e11971a2a05d9100b15c4640973295b8d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:18:21 -0700 Subject: [PATCH 08/28] test(red): reproduce stale Keyman drill state --- .../repair-post-detail-record-state.yml | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .github/workflows/repair-post-detail-record-state.yml diff --git a/.github/workflows/repair-post-detail-record-state.yml b/.github/workflows/repair-post-detail-record-state.yml new file mode 100644 index 000000000..519440568 --- /dev/null +++ b/.github/workflows/repair-post-detail-record-state.yml @@ -0,0 +1,154 @@ +name: One-shot post-detail record-state repair + +on: + push: + branches: + - feat/event-lineage-node-keeps-gnb-focus-v2170 + paths: + - .github/workflows/repair-post-detail-record-state.yml + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: repair-post-detail-record-state + cancel-in-progress: false + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - name: Checkout exact branch head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + fetch-depth: 0 + persist-credentials: true + + - name: Require the live branch to match this run + shell: bash + run: | + set -euo pipefail + git fetch origin feat/event-lineage-node-keeps-gnb-focus-v2170 + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$(git rev-parse origin/feat/event-lineage-node-keeps-gnb-focus-v2170)" = "$GITHUB_SHA" + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install the committed frontend lock + working-directory: frontend + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Add the stale-Keyman regression only + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.test.tsx") + text = path.read_text() + anchor = ' it("opening a linked Event Lineage node from Ask Agent keeps GNB focus; a home-list DAG walk does not", async () => {' + if text.count(anchor) != 1: + raise SystemExit("linked-DAG test anchor changed") + regression = ''' it("clears Keyman drill state when Event Lineage opens a different post", async () => { + stubBackend(); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click(within(board).getByRole("button", { name: "Weekly VOC" })); + await userEvent.click( + within(board).getByRole("button", { name: "View post: Public post" }), + ); + expect( + await screen.findByRole("heading", { name: "Related to Ada West" }), + ).toBeInTheDocument(); + + const linkedPosts = screen.getAllByLabelText("Open post: Linked post"); + await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect( + screen.getByText("The evidence panel should show exactly this text."), + ).toBeInTheDocument(), + ); + + expect( + screen.queryByRole("heading", { name: "Related to Ada West" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("status", { name: "Related next action" }), + ).not.toBeInTheDocument(); + }); + +''' + path.write_text(text.replace(anchor, regression + anchor, 1)) + PY + git diff --check -- frontend/src/App.test.tsx + + - name: Verify RED reproduces reused child state + working-directory: frontend + shell: bash + run: | + set -euo pipefail + set +e + pnpm exec vitest run src/App.test.tsx \ + -t "clears Keyman drill state when Event Lineage opens a different post" \ + 2>&1 | tee /tmp/post-record-red.log + status=${PIPESTATUS[0]} + set -e + if [ "$status" -eq 0 ]; then + echo "Regression unexpectedly passed before the production fix" >&2 + exit 1 + fi + grep -Eq "failed|FAIL" /tmp/post-record-red.log + + - name: Key the popup by selected record identity + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.tsx") + text = path.read_text() + old = ''' {selectedPostId && ( + Date: Thu, 20 Aug 2026 12:25:01 +0900 Subject: [PATCH 09/28] fix(semantics): scope organization name resolutions by context --- .../organization_name_resolution_ingestion.py | 25 +++++++--- docker/postgres-init/migrate.sh | 2 +- ...08-organization-abbreviation-resolution.md | 21 +++++++-- ...xt_scoped_organization_name_resolution.sql | 46 +++++++++++++++++++ tests/test_migration_replay.py | 12 +++++ ..._organization_name_resolution_ingestion.py | 14 +++++- 6 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 migrations/0051_context_scoped_organization_name_resolution.sql diff --git a/backend/app/organization_name_resolution_ingestion.py b/backend/app/organization_name_resolution_ingestion.py index 9300586c4..fe6520389 100644 --- a/backend/app/organization_name_resolution_ingestion.py +++ b/backend/app/organization_name_resolution_ingestion.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import hashlib import asyncpg @@ -17,6 +18,11 @@ ) +def _context_sha256(context_text: str) -> str: + """Return the cache key for context without persisting the source body.""" + return hashlib.sha256(context_text.encode("utf-8")).hexdigest() + + async def resolve_organization_name( conn: asyncpg.Connection, resolution_client: OrganizationNameResolutionClient, @@ -26,13 +32,19 @@ async def resolve_organization_name( ) -> str: """Return the corroborated canonical name, otherwise ``raw_name``. - Synchronous network adapters run in a worker thread so this async - ingestion path does not block unrelated requests. + The cache is scoped by the exact post context. A raw abbreviation is not + globally unambiguous, and only the digest is stored so the source body is + not duplicated in the resolution cache. Synchronous network adapters run + in a worker thread so this async ingestion path does not block unrelated + requests. """ + context_sha256 = _context_sha256(context_text) cached = await conn.fetchrow( "select resolved_organization_name, verification_status_code " - "from organization_name_resolution where raw_organization_name = $1", + "from organization_name_resolution " + "where raw_organization_name = $1 and context_sha256 = $2", raw_name, + context_sha256, ) if cached is not None: if cached["verification_status_code"] == STATUS_CORROBORATED: @@ -54,16 +66,17 @@ async def resolve_organization_name( await conn.execute( """ insert into organization_name_resolution - (raw_organization_name, resolved_organization_name, + (raw_organization_name, context_sha256, resolved_organization_name, verification_status_code, verification_evidence_url) - values ($1, $2, $3, $4) - on conflict (raw_organization_name) do update set + values ($1, $2, $3, $4, $5) + on conflict (raw_organization_name, context_sha256) do update set resolved_organization_name = excluded.resolved_organization_name, verification_status_code = excluded.verification_status_code, verification_evidence_url = excluded.verification_evidence_url, resolved_at = now() """, resolution.raw_organization_name, + context_sha256, resolution.resolved_organization_name, resolution.verification_status_code, resolution.verification_evidence_url, diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 87c825a2f..500643a24 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -17,7 +17,7 @@ done for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in - 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; + 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*|0051_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0008-organization-abbreviation-resolution.md b/docs/adr/0008-organization-abbreviation-resolution.md index 72b121253..59e4e8717 100644 --- a/docs/adr/0008-organization-abbreviation-resolution.md +++ b/docs/adr/0008-organization-abbreviation-resolution.md @@ -23,6 +23,9 @@ abbreviated name creates its own unmatched, un-linkable free-text string in `person_affiliation`/R&R -- the same organization looks like N different unknown entities across N posts, each failing to link into the corporate hierarchy a human reader would recognize instantly. +The reverse failure is equally unsafe: the same short name can refer to +different organizations in different post contexts, so a context-free cache +can link later mentions to the first model answer by accident. ## Decision @@ -62,11 +65,14 @@ itself already established: a wrong resolution corrupts every downstream Knowledge Graph link through it, so "did not resolve" must stay a real, distinguishable outcome from "resolved to X." -Persistence: a new `organization_name_resolution` cache table -(`migrations/0015_organization_name_resolution.sql`), keyed by -`raw_organization_name` -- the same abbreviation is resolved once, not -re-queried on every one of its (potentially many) mentions across -posts. `verification_status_code` reuses the existing +Persistence: the `organization_name_resolution` cache table +(`migrations/0015_organization_name_resolution.sql`, extended by a later +migration that adds `context_sha256`) is keyed by +`raw_organization_name` plus a SHA-256 digest of the bounded post context. +The context body is not persisted in the cache. Exact-context reprocessing +can reuse a result, while a homonymous abbreviation in a different context +gets a separate resolution instead of inheriting the first answer. +`verification_status_code` reuses the existing `relation_verification_status` lookup category rather than a near-duplicate one: a resolved name is corroborated/uncorroborated the exact same way a classified relationship already is. @@ -102,6 +108,9 @@ canonical form too rather than reintroducing the raw abbreviation. - Every channel here follows the existing pluggable-client discipline: `NullOrganizationNameResolutionClient`/an unavailable verification client degrade to "use the raw name," never a fabricated resolution. +- Context-sensitive caching follows entity-linking evidence that ambiguous + mentions must be disambiguated with document-level semantic context, not a + name-only lookup (Rama-Maneiro, Vidal, & Lama, 2020). ## Related @@ -117,4 +126,6 @@ Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization s Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in relational data. *ACM Transactions on Knowledge Discovery from Data*, 1(1), Article 5. https://doi.org/10.1145/1217299.1217304 +Rama-Maneiro, E., Vidal, J. C., & Lama, M. (2020). Collective disambiguation in entity linking based on topic coherence in semantic graphs. *Knowledge-Based Systems, 199*, Article 105967. https://doi.org/10.1016/j.knosys.2020.105967 + Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A large-scale dataset for fact extraction and VERification. In *Proceedings of the 2018 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies* (pp. 809–819). Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 diff --git a/migrations/0051_context_scoped_organization_name_resolution.sql b/migrations/0051_context_scoped_organization_name_resolution.sql new file mode 100644 index 000000000..b200cb333 --- /dev/null +++ b/migrations/0051_context_scoped_organization_name_resolution.sql @@ -0,0 +1,46 @@ +-- ADR 0008: the same short organization name may resolve differently in +-- different post contexts. Keep only a digest of the context, never the body. +alter table organization_name_resolution + add column if not exists context_sha256 text; + +update organization_name_resolution + set context_sha256 = '' + where context_sha256 is null; + +alter table organization_name_resolution + alter column context_sha256 set default '', + alter column context_sha256 set not null; + +alter table organization_name_resolution + drop constraint if exists organization_name_resolution_pkey; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'organization_name_resolution_context_pkey' + ) then + alter table organization_name_resolution + add constraint organization_name_resolution_context_pkey + primary key (raw_organization_name, context_sha256); + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'organization_name_resolution_context_sha256_check' + ) then + alter table organization_name_resolution + add constraint organization_name_resolution_context_sha256_check + check ( + context_sha256 = '' + or context_sha256 ~ '^[0-9a-f]{64}$' + ); + end if; +end +$$; diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29fe1c176..f55d53055 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -33,3 +33,15 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non ).read_text(encoding="utf-8") assert "0012_*" in script + + +def test_migrate_sh_replays_context_scoped_name_cache_migration() -> None: + """Existing volumes must receive the context-scoped resolution key.""" + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0051_*" in script diff --git a/tests/test_organization_name_resolution_ingestion.py b/tests/test_organization_name_resolution_ingestion.py index afdfa8f32..5a41a5db1 100644 --- a/tests/test_organization_name_resolution_ingestion.py +++ b/tests/test_organization_name_resolution_ingestion.py @@ -14,7 +14,7 @@ def __init__(self, cached: dict[str, str] | None = None) -> None: self.cached = cached self.executed: list[tuple[str, tuple[object, ...]]] = [] - async def fetchrow(self, _query: str, _raw_name: str): + async def fetchrow(self, _query: str, _raw_name: str, _context_sha256: str): return self.cached async def execute(self, query: str, *args: object) -> str: @@ -46,6 +46,18 @@ def test_cached_verified_name_is_returned_without_resolution() -> None: assert conn.executed == [] +def test_cache_key_includes_context_digest(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ingestion, "resolve_and_verify_organization_name", lambda *_args: _resolution(STATUS_CORROBORATED)) + conn = _Connection() + result = asyncio.run(ingestion.resolve_organization_name(conn, _Client(), _Client(), "AGP", "different context")) + + assert result == "Aurora Grid Power" + query, args = conn.executed[0] + assert "context_sha256" in query + assert args[0] == "AGP" + assert len(args[1]) == 64 + + def test_cached_unverified_name_stays_raw() -> None: conn = _Connection({"resolved_organization_name": "Aurora Grid Power", "verification_status_code": STATUS_UNCORROBORATED}) result = asyncio.run(ingestion.resolve_organization_name(conn, _UnavailableClient(), _Client(), "AGP", "context")) From 6898f5d041048b17f879a6efe288f01c30b70cb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:26:39 +0900 Subject: [PATCH 10/28] fix(authz): scope counterparty entity resolution --- backend/app/entity_relationship_ingestion.py | 22 +++++++++++++-- backend/app/five_w1h_ingestion.py | 7 +++-- backend/app/main.py | 5 +++- backend/tests/test_api.py | 29 ++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index 5426d073c..3dff70947 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -96,7 +96,11 @@ def attach_resolved_entity_ids( ] -async def fetch_post_counterparties(conn: asyncpg.Connection, post_id: str) -> list[dict[str, Any]]: +async def fetch_post_counterparties( + conn: asyncpg.Connection, + post_id: str, + authorized_corporate_entity_ids: Sequence[str] = (), +) -> list[dict[str, Any]]: """Classified counterparties with a cataloged org id when the name resolves. Unresolved names keep ``corporate_entity_id`` null -- a missing @@ -114,7 +118,15 @@ async def fetch_post_counterparties(conn: asyncpg.Connection, post_id: str) -> l """, post_id, ) - candidate_rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity") + candidate_rows = ( + await conn.fetch( + "select corporate_entity_id, entity_name from corporate_entity " + "where corporate_entity_id = any($1::uuid[])", + list(authorized_corporate_entity_ids), + ) + if authorized_corporate_entity_ids + else [] + ) candidates = [ CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) for row in candidate_rows @@ -192,7 +204,11 @@ async def fetch_relationship_network( """, list(corporate_entity_ids), ) - candidate_rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity") + candidate_rows = await conn.fetch( + "select corporate_entity_id, entity_name from corporate_entity " + "where corporate_entity_id = any($1::uuid[])", + list(corporate_entity_ids), + ) candidates = [ CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) for row in candidate_rows diff --git a/backend/app/five_w1h_ingestion.py b/backend/app/five_w1h_ingestion.py index 736a8ecd6..c1b21a64f 100644 --- a/backend/app/five_w1h_ingestion.py +++ b/backend/app/five_w1h_ingestion.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any import asyncpg @@ -18,6 +18,7 @@ async def load_five_w1h_slots( conn: asyncpg.Connection, post_id: str, can_see_post: Callable[[asyncpg.Record], bool], + authorized_corporate_entity_ids: Sequence[str] = (), ) -> dict[str, Any]: """Build 5W1H from stored projections and visible lineage only.""" summary = await fetch_persisted_summary(conn, post_id) or {} @@ -41,7 +42,9 @@ async def load_five_w1h_slots( ) linked_titles = [row["post_title"] for row in rows if can_see_post(row)] - counterparties = await fetch_post_counterparties(conn, post_id) + counterparties = await fetch_post_counterparties( + conn, post_id, authorized_corporate_entity_ids + ) slots = assemble_five_w1h_slots( roles=summary.get("roles_and_responsibilities", []), key_events=summary.get("key_events", []), diff --git a/backend/app/main.py b/backend/app/main.py index 8aa3ad0f9..dd3b1628e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1877,7 +1877,9 @@ async def read_post_counterparties( """ post = await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: - counterparties = await fetch_post_counterparties(conn, post_id) + counterparties = await fetch_post_counterparties( + conn, post_id, account.corporate_entity_ids + ) return { "post_id": str(post["post_id"]), "counterparties": counterparties, @@ -2434,6 +2436,7 @@ async def read_post_five_w1h( conn, post_id, lambda row: _can_see_post(account, row), + account.corporate_entity_ids, ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a8dd1bd5a..2fd7a0370 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -3407,6 +3407,35 @@ def test_counterparties_resolve_cataloged_org_ids(client, demo_analyst_token, se assert by_name["Northridge Grid"]["corporate_entity_id"] is None +def test_counterparties_do_not_expose_unauthorized_catalog_entity( + client, demo_analyst_token, seeded_db +) -> None: + """A public post must not resolve a name to a private catalog row.""" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_counterparty_entity " + "(post_id, counterparty_entity_name, relationship_type_code) " + "values (%s, 'Other Corp', 'rel_voc')", + (seeded_db["public_post_id"],), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/posts/{seeded_db['public_post_id']}/counterparties", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + row = next( + item for item in response.json()["counterparties"] + if item["counterparty_entity_name"] == "Other Corp" + ) + assert row["corporate_entity_id"] is None + + def test_counterparties_endpoint_is_empty_before_extraction(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/counterparties", From d52ae3e625861cb891231843813918945922694d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:28:04 +0900 Subject: [PATCH 11/28] fix(kg): merge catalog aliases in relationship network --- backend/app/entity_relationship_ingestion.py | 70 ++++++++++++++++---- tests/test_entity_relationship_ingestion.py | 44 +++++++++++- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index 3dff70947..4bca658d4 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -96,6 +96,62 @@ def attach_resolved_entity_ids( ] +def merge_relationship_network_rows( + rows: Sequence[Mapping[str, Any]], + candidates: Sequence[CorporateEntityCandidate], +) -> list[dict[str, Any]]: + """Merge raw-name variants only when they share one unique catalog id.""" + candidate_names = {candidate.corporate_entity_id: candidate.entity_name for candidate in candidates} + merged: dict[tuple[str, str], dict[str, Any]] = {} + for row in rows: + raw_name = str(row["counterparty_entity_name"]) + corporate_entity_id = resolve_corporate_entity(raw_name, candidates) + key = ("entity", corporate_entity_id) if corporate_entity_id else ("name", raw_name) + entry = merged.setdefault( + key, + { + "counterparty_entity_name": candidate_names.get(corporate_entity_id, raw_name), + "corporate_entity_id": corporate_entity_id, + "total_post_count": 0, + "relationship_counts": {}, + }, + ) + entry["total_post_count"] += int(row["total_post_count"]) + relationships = row["relationships"] + if isinstance(relationships, str): + relationships = json.loads(relationships) + for relationship in relationships: + relationship_key = ( + relationship["relationship_type_code"], + relationship["relationship_label"], + ) + counts = entry["relationship_counts"] + counts[relationship_key] = counts.get(relationship_key, 0) + int(relationship["post_count"]) + + result: list[dict[str, Any]] = [] + for entry in merged.values(): + relationships = [ + { + "relationship_type_code": code, + "relationship_label": label, + "post_count": post_count, + } + for (code, label), post_count in entry["relationship_counts"].items() + ] + relationships.sort(key=lambda relationship: (-relationship["post_count"], relationship["relationship_type_code"])) + result.append( + { + "counterparty_entity_name": entry["counterparty_entity_name"], + "corporate_entity_id": entry["corporate_entity_id"], + "total_post_count": entry["total_post_count"], + "relationships": relationships, + "multi_role": len(relationships) > 1, + } + ) + result.sort(key=lambda entry: (-entry["total_post_count"], entry["counterparty_entity_name"])) + return result[:_RELATIONSHIP_NETWORK_LIMIT] + + async def fetch_post_counterparties( conn: asyncpg.Connection, post_id: str, @@ -213,16 +269,4 @@ async def fetch_relationship_network( CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) for row in candidate_rows ] - network: list[dict[str, Any]] = [] - for row in rows: - relationships = json.loads(row["relationships"]) if isinstance(row["relationships"], str) else row["relationships"] - network.append( - { - "counterparty_entity_name": row["counterparty_entity_name"], - "corporate_entity_id": resolve_corporate_entity(row["counterparty_entity_name"], candidates), - "total_post_count": row["total_post_count"], - "relationships": relationships, - "multi_role": len(relationships) > 1, - } - ) - return network + return merge_relationship_network_rows(rows, candidates) diff --git a/tests/test_entity_relationship_ingestion.py b/tests/test_entity_relationship_ingestion.py index 81d6fff64..e98566b24 100644 --- a/tests/test_entity_relationship_ingestion.py +++ b/tests/test_entity_relationship_ingestion.py @@ -3,7 +3,11 @@ import asyncio from contextlib import asynccontextmanager -from backend.app.entity_relationship_ingestion import ingest_post_entity_relationships +from backend.app.entity_relationship_ingestion import ( + ingest_post_entity_relationships, + merge_relationship_network_rows, +) +from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate from lineageweave.entity_relationship_classification import OrganizationRelationship @@ -55,3 +59,41 @@ def test_relationship_ingestion_clears_rows_when_no_counterparty_remains() -> No assert conn.executed == [ ("delete from post_counterparty_entity where post_id = $1", ("post-2",)) ] + + +def test_relationship_network_merges_unique_catalog_aliases() -> None: + rows = [ + { + "counterparty_entity_name": "Synthetic Group", + "total_post_count": 1, + "relationships": [{ + "relationship_type_code": "rel_voc", + "relationship_label": "Customer", + "post_count": 1, + }], + }, + { + "counterparty_entity_name": "Synthetic Group.", + "total_post_count": 2, + "relationships": [{ + "relationship_type_code": "rel_voco", + "relationship_label": "Competitor", + "post_count": 2, + }], + }, + ] + result = merge_relationship_network_rows( + rows, + [CorporateEntityCandidate("entity-1", "Synthetic Group")], + ) + + assert result == [{ + "counterparty_entity_name": "Synthetic Group", + "corporate_entity_id": "entity-1", + "total_post_count": 3, + "relationships": [ + {"relationship_type_code": "rel_voco", "relationship_label": "Competitor", "post_count": 2}, + {"relationship_type_code": "rel_voc", "relationship_label": "Customer", "post_count": 1}, + ], + "multi_role": True, + }] From d425c2835b69ffec39cb690ad29173c4067e1f2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:28:43 +0900 Subject: [PATCH 12/28] fix(ontology): reject invalid summary actor types --- lineageweave/post_summary.py | 8 ++++++-- tests/test_post_summary.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index d3003e52e..b9f8738fc 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -576,12 +576,16 @@ def parse_summary_response(content: str) -> PostSummary | None: name = entry.get("actor_name") responsibility = entry.get("responsibility") actor_type_raw = entry.get("actor_type") - if actor_type_raw == "organization": + if actor_type_raw is None: + actor_type_code = ACTOR_TYPE_PERSON + elif actor_type_raw == "person": + actor_type_code = ACTOR_TYPE_PERSON + elif actor_type_raw == "organization": actor_type_code = ACTOR_TYPE_ORGANIZATION elif actor_type_raw == "team": actor_type_code = ACTOR_TYPE_TEAM else: - actor_type_code = ACTOR_TYPE_PERSON + continue affiliation_raw = entry.get("affiliated_organization_name") affiliated_organization_name = ( affiliation_raw.strip() diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 25cd62d76..3a352d1e2 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -159,6 +159,17 @@ def test_missing_actor_type_defaults_to_person() -> None: assert summary.roles_and_responsibilities[0].affiliated_organization_name is None +def test_explicit_unknown_actor_type_is_dropped() -> None: + summary = parse_summary_response( + '{"korean_summary": "요약", "roles_and_responsibilities": [{' + '"actor_name": "Synthetic Team", "responsibility": "검토", ' + '"actor_type": "department"}]}' + ) + + assert summary is not None + assert summary.roles_and_responsibilities == () + + def test_missing_korean_summary_returns_none() -> None: content = '{"key_events": [], "roles_and_responsibilities": []}' assert parse_summary_response(content) is None From 5e17a2240e800e56a016ebbc041df78db99086a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:37:22 +0900 Subject: [PATCH 13/28] fix(ask): return authorized event timeline --- backend/app/main.py | 24 ++++++++++++++++++++++++ backend/app/post_chat_ingestion.py | 16 ++++++++++++++++ frontend/src/App.test.tsx | 16 ++++++++++++++++ frontend/src/App.tsx | 20 ++++++++++++++++++++ frontend/src/api.ts | 8 ++++++++ lineageweave/post_chat.py | 6 +++++- tests/test_global_ask_sources.py | 11 +++++++++++ tests/test_post_chat.py | 2 ++ 8 files changed, 102 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index dd3b1628e..96c3e78d0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -70,6 +70,7 @@ NullOrganizationNameResolutionClient, ) from lineageweave.post_chat import ( + ChatSourceDocument, ContextualOrchestratorPostChatClient, NullPostChatClient, cited_post_evidence, @@ -2452,6 +2453,27 @@ class GlobalAskRequest(BaseModel): question: str +def global_ask_timeline(sources: list[ChatSourceDocument]) -> list[dict[str, str | None]]: + """Return every authorized Ask source in event order, not citation order.""" + ordered = sorted( + sources, + key=lambda source: ( + source.occurred_at is None, + source.occurred_at or "", + source.post_id, + ), + ) + return [ + { + "post_id": source.post_id, + "post_title": source.post_title, + "occurred_at": source.occurred_at, + "timeline_kind": source.timeline_kind, + } + for source in ordered + ] + + @app.get("/api/posts/{post_id}/chat") async def read_post_chat( post_id: str, @@ -2573,6 +2595,7 @@ async def ask_agent( "cited_posts": [], "source_post_ids": [], "cited_post_evidence": [], + "timeline": [], "next_action": "No authorized source posts are available for this question.", } try: @@ -2589,6 +2612,7 @@ async def ask_agent( "cited_posts": cited_post_summaries(sources, cited_ids), "cited_post_evidence": cited_post_evidence(sources, cited_ids), "source_post_ids": [source.post_id for source in sources], + "timeline": global_ask_timeline(sources), } diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 71c0f2053..1d3b077c2 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -178,6 +178,13 @@ def _source_hint_facts(row: Any) -> tuple[str, ...]: return tuple(facts) +def _timestamp_text(row: Any) -> str | None: + value = row.get("created_at") + if value is None: + return None + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + async def _semantic_facts_for_posts( conn: asyncpg.Connection, post_ids: list[str] ) -> dict[str, tuple[str, ...]]: @@ -504,6 +511,7 @@ async def gather_global_chat_sources( rows = await conn.fetch( """ select post_id, post_title, post_body, visibility_code, corporate_entity_id, + created_at, source_system_code, source_record_key, source_author_code, source_author_name, source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, source_sales_pool_code, source_sales_pool_name, @@ -548,6 +556,14 @@ async def gather_global_chat_sources( evidence_facts=_source_hint_facts(row) + semantic_facts.get(post_id, ()) + lineage_fact, + occurred_at=_timestamp_text(row), + timeline_kind=( + "lineage_neighbor" + if post_id in lineage_neighbor_id_set and anchor_is_visible + else "lineage_anchor" + if post_id == lineage_anchor_id + else "keyword_match" + ), ) ) return sources diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c82ad1ebe..06c6923e2 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1614,6 +1614,20 @@ describe("App, authenticated", () => { }, ], source_post_ids: ["post-1", "post-2"], + timeline: [ + { + post_id: "post-1", + post_title: "Public post", + occurred_at: "2026-01-01T00:00:00Z", + timeline_kind: "lineage_anchor", + }, + { + post_id: "post-2", + post_title: "Linked post", + occurred_at: "2026-01-02T00:00:00Z", + timeline_kind: "lineage_neighbor", + }, + ], }), ); } @@ -1729,6 +1743,8 @@ describe("App, authenticated", () => { expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument(); expect(screen.getByText("Semantic project", { exact: true })).toBeInTheDocument(); expect(screen.getByText(/project: Semantic project \| evidence: Body evidence/)).toBeInTheDocument(); + expect(screen.getByRole("list", { name: "Event Lineage timeline" })).toBeInTheDocument(); + expect(screen.getByText("2026-01-01T00:00:00Z")).toBeInTheDocument(); expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 710b7d41a..99882408a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4656,6 +4656,26 @@ function AskAgentPanel({

    {t("Answer")}

    {answer.answer_text ?

    {answer.answer_text}

    : null} {answer.next_action ?

    {t(answer.next_action)}

    : null} + {answer.timeline && answer.timeline.length > 0 ? ( + <> +

    Event Lineage timeline

    +
      + {answer.timeline.map((event) => ( +
    1. + +
    2. + ))} +
    + + ) : null} {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

    diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f24050f73..79631ae81 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -290,9 +290,17 @@ export interface AskAgentResponse { cited_posts?: CitedPostRef[]; cited_post_evidence?: CitedPostEvidence[]; source_post_ids: string[]; + timeline?: AskTimelineEntry[]; next_action?: string; } +export interface AskTimelineEntry { + post_id: string; + post_title: string; + occurred_at: string | null; + timeline_kind: string | null; +} + export interface IssueTicket { issue_ticket_id: string; post_id: string; diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index cb5c9ce0c..42587d149 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -64,6 +64,8 @@ class ChatSourceDocument: post_body: str graph_facts: tuple[str, ...] = field(default_factory=tuple) evidence_facts: tuple[str, ...] = field(default_factory=tuple) + occurred_at: str | None = None + timeline_kind: str | None = None @dataclass(frozen=True) @@ -226,9 +228,11 @@ def _render_sources_block(sources: list[ChatSourceDocument]) -> str: "raw source hints as resolved ontology assertions):\n" + "\n".join(f"- {fact}" for fact in source.evidence_facts) ) + occurred_block = f"Occurred at: {source.occurred_at}\n" if source.occurred_at else "" blocks.append( f"[Source {i}] (post_id={source.post_id})\n" - f"Title: {source.post_title}\n{body}{graph_block}{evidence_block}" + f"Title: {source.post_title}\n" + f"{occurred_block}{body}{graph_block}{evidence_block}" ) return "\n\n".join(blocks) diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index 9a41b507c..d873c263a 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +from datetime import datetime, timezone from backend.app.post_chat_ingestion import gather_global_chat_sources +from backend.app.main import global_ask_timeline def test_global_sources_apply_visibility_before_normalization() -> None: @@ -199,6 +201,7 @@ def test_global_sources_keep_lineage_expansion_within_requested_limit() -> None: "visibility_code": "public", "corporate_entity_id": None, "matched_in": "title", + "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc), } neighbor_ids = [f"neighbor-{index:02d}" for index in range(20)] source_call: tuple[str, tuple[object, ...]] | None = None @@ -281,6 +284,7 @@ def test_global_sources_expand_top_match_through_event_lineage() -> None: "visibility_code": "public", "corporate_entity_id": None, "matched_in": "title", + "created_at": datetime(2026, 1, 2, tzinfo=timezone.utc), } lineage_row = { "post_id": "event-1", @@ -288,6 +292,7 @@ def test_global_sources_expand_top_match_through_event_lineage() -> None: "post_body": "kickoff body", "visibility_code": "public", "corporate_entity_id": None, + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), } class FakeConnection: @@ -315,6 +320,12 @@ async def fetch(self, query: str, *args): "Event Lineage: reconstructed timeline neighbor of post_id=event-2" in fact for fact in sources[1].evidence_facts ) + assert sources[0].occurred_at == "2026-01-02T00:00:00+00:00" + assert sources[0].timeline_kind == "lineage_anchor" + assert sources[1].occurred_at == "2026-01-01T00:00:00+00:00" + assert sources[1].timeline_kind == "lineage_neighbor" + timeline = global_ask_timeline(sources) + assert [event["post_id"] for event in timeline] == ["event-1", "event-2"] def test_global_sources_do_not_leak_lineage_anchor_id_when_anchor_is_invisible() -> None: diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index f401e50d7..ca2e39afb 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -200,6 +200,7 @@ def test_chat_render_includes_persisted_graph_facts_with_source_evidence() -> No 'node_corporate_entity "Demo Corp" [evidence_post_id=post-graph]', ), evidence_facts=("source project code=PROJECT-HINT [hint_only]",), + occurred_at="2026-01-01T00:00:00+00:00", ) rendered = _render_sources_block([source]) @@ -209,6 +210,7 @@ def test_chat_render_includes_persisted_graph_facts_with_source_evidence() -> No assert "evidence_post_id=post-graph" in rendered assert "Persisted source/semantic evidence" in rendered assert "PROJECT-HINT" in rendered + assert "Occurred at: 2026-01-01T00:00:00+00:00" in rendered def test_graph_facts_are_hydrated_from_visible_evidence_posts(monkeypatch) -> None: From bbb88cfe68f736fa0edab38369e666d627cb64e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:41:54 +0900 Subject: [PATCH 14/28] fix(test): apply context resolution migration to API DB --- backend/tests/test_api.py | 6 ++++++ docker/postgres-init/migrate.sh | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 2fd7a0370..22206ff7a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -94,6 +94,11 @@ _POST_CONTENT_QUEUE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0050_post_content_ingestion_queue.sql" ) +_ORGANIZATION_CONTEXT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0051_context_scoped_organization_name_resolution.sql" +) def _postgres_available() -> bool: @@ -202,6 +207,7 @@ def seeded_db(demo_analyst_token): cur.execute(_IMAGE_REGION_MIGRATION.read_text()) cur.execute(_SUMMARY_FIVE_W1H_MIGRATION.read_text()) cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) + cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 500643a24..9f139a114 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -17,7 +17,8 @@ done for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in - 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*|0051_*) ;; + 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; + 0051_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" From 6b431e777f9bed8cb7a18893bcc77b4b4d0f2be1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:29:26 +0900 Subject: [PATCH 15/28] fix(ask): retain bounded global context and DOM order --- backend/app/activity_stream.py | 19 ++ backend/app/main.py | 80 +++++++- backend/app/post_chat_ingestion.py | 176 ++++++++++++++++++ backend/tests/test_api.py | 6 + docker/postgres-init/migrate.sh | 2 +- ...0-global-ask-lineage-timeline-expansion.md | 24 ++- docs/lineage-bi-research-notes.md | 17 +- frontend/src/App.tsx | 8 +- frontend/src/api.ts | 9 +- frontend/src/postBodyDisplay.test.ts | 13 ++ frontend/src/postBodyDisplay.ts | 20 +- lineageweave/chunking.py | 19 ++ lineageweave/post_chat.py | 83 ++++++++- migrations/0052_global_ask_context.sql | 36 ++++ tests/test_activity_stream.py | 29 ++- tests/test_chunking.py | 24 +++ tests/test_migration_replay.py | 11 ++ tests/test_post_chat.py | 34 ++++ 18 files changed, 583 insertions(+), 27 deletions(-) create mode 100644 migrations/0052_global_ask_context.sql diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index a47bf9343..066829ca8 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -29,6 +29,10 @@ def _stream_key(post_id: str) -> str: return f"activity:{post_id}" +def _operation_stream_key(account_id: str) -> str: + return f"operation:{account_id}" + + def ticket_created_summary(ticket_title: str) -> str: """The ``summary`` field ``ticket_created`` producers must share.""" return f"Ticket created: {ticket_title}" @@ -74,6 +78,21 @@ async def publish_activity_event( ) +async def publish_operation_event( + client: redis.Redis, + actor_account_id: str, + event_type: str, + summary: str, +) -> str: + """Register an account operation that has no single owning post.""" + return await client.xadd( + _operation_stream_key(actor_account_id), + _activity_fields(event_type, actor_account_id, summary), + maxlen=1000, + approximate=True, + ) + + def publish_activity_event_sync( client: Any, post_id: str, diff --git a/backend/app/main.py b/backend/app/main.py index 96c3e78d0..a0c4e77fc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -75,6 +75,7 @@ NullPostChatClient, cited_post_evidence, cited_post_summaries, + render_global_ask_context, ) from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_evaluation import ( @@ -114,6 +115,7 @@ create_valkey_client, get_valkey, publish_activity_event, + publish_operation_event, read_activity_events, ticket_created_summary, ticket_status_changed_summary, @@ -167,11 +169,15 @@ ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph from backend.app.post_chat_ingestion import ( + ensure_global_ask_session, fetch_persisted_chat, fetch_persisted_chats, find_linked_post_ids, gather_chat_sources, gather_global_chat_sources, + load_global_ask_context, + persist_global_ask_summary, + persist_global_ask_turn, persist_post_chat, ) from backend.app.post_summary_ingestion import ( @@ -2451,6 +2457,7 @@ class GlobalAskRequest(BaseModel): """JSON body for the buyer's source-grounded Global Ask Agent.""" question: str + session_id: str | None = None def global_ask_timeline(sources: list[ChatSourceDocument]) -> list[dict[str, str | None]]: @@ -2569,12 +2576,18 @@ async def ask_agent( request: GlobalAskRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Answer a buyer question from authorized post and graph evidence.""" question = request.question.strip() if not question: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") _require_post_read(account) + if request.session_id is not None: + try: + UUID(request.session_id) + except ValueError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") from None client = _post_chat_client() if not client.available: raise HTTPException( @@ -2582,14 +2595,59 @@ async def ask_agent( "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) async with pool.acquire() as conn: + session_id = await ensure_global_ask_session( + conn, account.user_account_id, request.session_id + ) + if session_id is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") + conversation = await load_global_ask_context(conn, session_id) sources = await gather_global_chat_sources( conn, lambda row: _can_see_post(account, row), account.corporate_entity_ids, question=question, ) + if conversation.compress_turns: + compressor = getattr(client, "compress_context", None) + if not callable(compressor): + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent conversation context compression is unavailable", + ) + try: + compressed = await asyncio.to_thread( + compressor, + conversation.summary, + list(conversation.compress_turns), + ) + async with pool.acquire() as conn: + await persist_global_ask_summary( + conn, + conversation.session_id, + compressed, + conversation.compress_turns[-1][0], + ) + conversation = await load_global_ask_context(conn, conversation.session_id) + except (HttpClientError, KeyError, OSError, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent conversation context compression is unavailable", + ) from exc + conversation_context = render_global_ask_context( + conversation.summary, + conversation.recent_turns, + ) if not sources: + async with pool.acquire() as conn: + await persist_global_ask_turn(conn, conversation.session_id, question, "", ()) + await publish_operation_event( + valkey, + account.user_account_id, + "global_ask_completed", + "Global Ask completed with no authorized source posts", + ) return { + "session_id": conversation.session_id, "answer_text": "", "cited_post_ids": [], "cited_posts": [], @@ -2599,14 +2657,34 @@ async def ask_agent( "next_action": "No authorized source posts are available for this question.", } try: - answer = await asyncio.to_thread(client.answer, question, sources) + answer = await asyncio.to_thread( + client.answer, + question, + sources, + conversation_context=conversation_context, + ) except (HttpClientError, KeyError, OSError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, f"Ask Agent is unavailable: {exc}", ) from exc cited_ids = list(answer.cited_post_ids) + async with pool.acquire() as conn: + await persist_global_ask_turn( + conn, + conversation.session_id, + question, + answer.answer_text, + cited_ids, + ) + await publish_operation_event( + valkey, + account.user_account_id, + "global_ask_completed", + f"Global Ask completed with {len(cited_ids)} cited source post(s)", + ) return { + "session_id": conversation.session_id, "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_post_summaries(sources, cited_ids), diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 1d3b077c2..a12f35fed 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -21,6 +21,7 @@ import re from dataclasses import dataclass from typing import Any, Callable, Iterable +from uuid import uuid4 import asyncpg @@ -57,6 +58,181 @@ class LinkedPostIds: indirect: frozenset[str] +@dataclass(frozen=True) +class GlobalAskContext: + """Account-scoped continuity context; source evidence is re-retrieved.""" + + session_id: str + summary: str | None + summary_through_ordinal: int + recent_turns: tuple[tuple[int, str, str], ...] + compress_turns: tuple[tuple[int, str, str], ...] + + +async def ensure_global_ask_session( + conn: asyncpg.Connection, + account_id: str, + session_id: str | None, +) -> str | None: + """Create or account-check one Global Ask session; hidden ids stay hidden.""" + if session_id is not None: + row = await conn.fetchrow( + """ + select global_ask_session_id + from global_ask_session + where global_ask_session_id = $1 + and user_account_id = $2 + """, + session_id, + account_id, + ) + return str(row["global_ask_session_id"]) if row is not None else None + created = str(uuid4()) + await conn.execute( + "insert into global_ask_session (global_ask_session_id, user_account_id) values ($1, $2)", + created, + account_id, + ) + return created + + +async def load_global_ask_context( + conn: asyncpg.Connection, + session_id: str, + *, + recent_limit: int = 6, + compression_batch: int = 4, +) -> GlobalAskContext: + """Load bounded continuity rows and the oldest batch eligible for compression.""" + session = await conn.fetchrow( + """ + select context_summary, context_summary_through_ordinal + from global_ask_session + where global_ask_session_id = $1 + """, + session_id, + ) + if session is None: + raise ValueError("global ask session not found") + through = int(session["context_summary_through_ordinal"]) + pending_count = int( + await conn.fetchval( + "select count(*) from global_ask_turn where global_ask_session_id = $1 and turn_ordinal > $2", + session_id, + through, + ) + ) + compress_count = min(compression_batch, max(0, pending_count - recent_limit)) + compress_rows = ( + await conn.fetch( + """ + select turn_ordinal, question_text, answer_text + from global_ask_turn + where global_ask_session_id = $1 + and turn_ordinal > $2 + order by turn_ordinal + limit $3 + """, + session_id, + through, + compress_count, + ) + if compress_count + else [] + ) + recent_rows = await conn.fetch( + """ + select turn_ordinal, question_text, answer_text + from global_ask_turn + where global_ask_session_id = $1 + and turn_ordinal > $2 + order by turn_ordinal desc + limit $3 + """, + session_id, + through, + recent_limit, + ) + return GlobalAskContext( + session_id=session_id, + summary=session["context_summary"], + summary_through_ordinal=through, + recent_turns=tuple( + (int(row["turn_ordinal"]), row["question_text"], row["answer_text"]) + for row in reversed(recent_rows) + ), + compress_turns=tuple( + (int(row["turn_ordinal"]), row["question_text"], row["answer_text"]) + for row in compress_rows + ), + ) + + +async def persist_global_ask_summary( + conn: asyncpg.Connection, + session_id: str, + summary: str, + through_ordinal: int, +) -> None: + """Replace the bounded continuity summary after orchestrator compression.""" + if not summary.strip() or through_ordinal <= 0: + raise ValueError("global ask summary requires covered turns") + await conn.execute( + """ + update global_ask_session + set context_summary = $2, + context_summary_through_ordinal = $3, + updated_at = now() + where global_ask_session_id = $1 + """, + session_id, + summary.strip(), + through_ordinal, + ) + + +async def persist_global_ask_turn( + conn: asyncpg.Connection, + session_id: str, + question: str, + answer: str, + cited_post_ids: Iterable[str], +) -> int: + """Append one serialized turn and its normalized citation references.""" + citations = list(dict.fromkeys(str(post_id) for post_id in cited_post_ids)) + async with conn.transaction(): + await conn.fetchrow( + "select global_ask_session_id from global_ask_session where global_ask_session_id = $1 for update", + session_id, + ) + ordinal = int( + await conn.fetchval( + "select coalesce(max(turn_ordinal), 0) + 1 from global_ask_turn where global_ask_session_id = $1", + session_id, + ) + ) + await conn.execute( + "insert into global_ask_turn (global_ask_session_id, turn_ordinal, question_text, answer_text) values ($1, $2, $3, $4)", + session_id, + ordinal, + question, + answer, + ) + for citation_ordinal, post_id in enumerate(citations): + await conn.execute( + "insert into global_ask_turn_citation (global_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id) values ($1, $2, $3, $4)", + session_id, + ordinal, + citation_ordinal, + post_id, + ) + await conn.execute( + "update global_ask_session set updated_at = now() where global_ask_session_id = $1", + session_id, + ) + return ordinal + + async def _normalize_post_body_text( body: str, vision_client: ImageContentClient, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 22206ff7a..9f891e48c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -99,6 +99,11 @@ / "migrations" / "0051_context_scoped_organization_name_resolution.sql" ) +_GLOBAL_ASK_CONTEXT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0052_global_ask_context.sql" +) def _postgres_available() -> bool: @@ -208,6 +213,7 @@ def seeded_db(demo_analyst_token): cur.execute(_SUMMARY_FIVE_W1H_MIGRATION.read_text()) cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_CONTEXT_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 9f139a114..a4938c4b1 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*) ;; + 0051_*|0052_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0090-global-ask-lineage-timeline-expansion.md b/docs/adr/0090-global-ask-lineage-timeline-expansion.md index da98d9388..ca2cc7c8a 100644 --- a/docs/adr/0090-global-ask-lineage-timeline-expansion.md +++ b/docs/adr/0090-global-ask-lineage-timeline-expansion.md @@ -68,12 +68,24 @@ sequence around it. - The source budget is no longer a fixed constant per request; callers reading `limit` as an upper bound on retrieved posts must account for the lineage-expansion addition. -- Global Ask still has no persisted multi-turn conversation state, so - there is no long-context-compression problem yet. Recursive dialogue - summarization (Wang et al., 2023) is recorded in - `docs/lineage-bi-research-notes.md` as the citation a future persisted - Global Ask conversation thread would build on, not as a claim that - conversation-level compression exists today. +- Global Ask conversation continuity is an explicit follow-on contract. An + account-owned `global_ask_session` and normalized `global_ask_turn` rows + retain the question, answer, and cited post ids; a browser may send the + session id back on the next turn. The server creates the id when omitted, + and every session lookup is scoped to the requesting account. +- The current authorized source set is rebuilt and ABAC-filtered on every + turn. Prior answers and a compressed conversation summary are continuity + context only, never evidence or citations; a changed authorization cannot + make an old answer reintroduce a hidden post. +- Once the retained turns exceed the bounded context budget, the older turns + are compressed through contextual-orchestrator using the Wang et al. (2023) + recursive-dialogue-summarization grounding. The compressed result records + the covered turn ordinal and is passed as explicitly non-evidentiary + context. A failed compression is unavailable, not silently replaced with a + guessed summary or an unbounded transcript. +- Every successful Global Ask turn returns the authorized Event Lineage + timeline for that request, including an empty timeline when no authorized + sources were found. ## Evidence and literature diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index 94f99d73d..8de7a2fbd 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -370,12 +370,11 @@ source limit -- expanding every keyword hit instead of only the top one was rejected because a loosely related term would otherwise drag in an unrelated lineage chain into the model's context. -Global Ask's chat turns are not yet persisted as a running conversation -- -each question is answered independently, so there is no multi-turn -context to compress. Recursive dialogue summarization (Wang et al., 2023) -is the grounding this repository would use if/when Global Ask grows a -persisted conversation thread that can exceed a bounded context window: -summarize-and-replace older turns instead of an unbounded transcript or a -hard truncation that silently drops earlier decisions. This is recorded -here as the citation this feature would build on, not as a claim that -conversation-level compression is implemented today. +Global Ask retains an account-owned normalized conversation thread. Current +authorized source retrieval still runs on every turn; prior answers are +continuity context, not evidence. When the bounded context budget is +exceeded, older turns are summarized and replaced through the +contextual-orchestrator rather than silently truncating an unbounded +transcript. Recursive dialogue summarization (Wang et al., 2023) grounds that +compression boundary; the stored summary records which turn ordinal it +covers and is explicitly excluded from citation evidence. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99882408a..c7bf4bc4f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4617,6 +4617,9 @@ function AskAgentPanel({ const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); + const [sessionId, setSessionId] = useState(() => + window.sessionStorage.getItem("lineageweave.globalAskSessionId") ?? undefined, + ); async function handleAsk() { const normalized = question.trim(); @@ -4624,7 +4627,10 @@ function AskAgentPanel({ setAsking(true); setError(null); try { - setAnswer(await askAgent(accessToken, normalized)); + const nextAnswer = await askAgent(accessToken, normalized, sessionId); + setAnswer(nextAnswer); + setSessionId(nextAnswer.session_id); + window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 79631ae81..55acb2aa3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -285,6 +285,7 @@ export interface ChatHistory { } export interface AskAgentResponse { + session_id: string; answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; @@ -862,10 +863,14 @@ export function askPostChat(accessToken: string, postId: string, question: strin }); } -export function askAgent(accessToken: string, question: string): Promise { +export function askAgent( + accessToken: string, + question: string, + sessionId?: string, +): Promise { return backendFetch("/api/ask", accessToken, { method: "POST", - body: JSON.stringify({ question }), + body: JSON.stringify({ question, ...(sessionId ? { session_id: sessionId } : {}) }), }); } diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 07ca9514d..32b46ce14 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -61,6 +61,19 @@ describe("splitPostBody", () => { ]); }); + it("keeps nested list items in source order with their list depth", () => { + expect( + splitPostBody( + "

    1. Outer
      • Inner
      After inner
    2. Sibling
    ", + ), + ).toEqual([ + { kind: "text", text: "Outer", indentLevel: 1 }, + { kind: "text", text: "Inner", indentLevel: 2 }, + { kind: "text", text: "After inner", indentLevel: 1 }, + { kind: "text", text: "Sibling", indentLevel: 1 }, + ]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index e84af2fb0..b9714c224 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -90,11 +90,27 @@ function indentMarker(width: number): string { } function stripHtmlTags(text: string): string { + const listIndentWidths: number[] = []; + let listIndent = 0; const withBoundaries = text .replace(BREAK_TAG, "\n") .replace(BLOCK_TAG, (tag) => { - if (/^<\//.test(tag)) return "\n\n"; - return `\n\n${indentMarker(declaredIndentWidth(tag))}`; + const closing = /^<\//.test(tag); + const tagName = tag.match(/^<\/?\s*([a-z0-9:]+)/i)?.[1]?.toLowerCase() ?? ""; + if (closing) { + if (tagName === "ul" || tagName === "ol") { + listIndent = Math.max(0, listIndent - (listIndentWidths.pop() ?? 4)); + return `\n\n${indentMarker(listIndent)}`; + } + return "\n\n"; + } + const width = declaredIndentWidth(tag); + if (tagName === "ul" || tagName === "ol") { + listIndentWidths.push(width); + listIndent += width; + } + const effectiveWidth = tagName === "li" ? Math.max(listIndent, width) : listIndent + width; + return `\n\n${indentMarker(effectiveWidth)}`; }) .replace(WORD_INDENT_TAG, (tag) => indentMarker(declaredIndentWidth(tag))); const withoutTags = withBoundaries.replace(HTML_TAG, (tag) => diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index a0c62c1b5..ee7191f25 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -65,6 +65,8 @@ "footer", "div", "p", + "ul", + "ol", "li", "tr", "blockquote", @@ -310,6 +312,8 @@ def __init__(self) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Collect relevant text state when an HTML start tag is encountered.""" if tag == "img": + if self._stack: + self._flush_current_buffer() src = next((value for name, value in attrs if name == "src" and value), None) if src: decoded = _decode_data_uri_image(src) @@ -338,6 +342,8 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None if any(entry[0] in _TABLE_ROW_TAGS for entry in self._stack): return if tag in _DOM_BLOCK_TAGS: + if self._stack: + self._flush_current_buffer() style = next((value for name, value in attrs if name == "style" and value), None) self._stack.append((tag, [], style, _declared_indent_width(tag, attrs))) @@ -354,6 +360,19 @@ def handle_endtag(self, tag: str) -> None: tag_name, buffer, style, _ = self._stack.pop() self._finish_block(tag_name, buffer, style, declared_width) + def _flush_current_buffer(self) -> None: + """Emit direct parent text before a nested block or embedded image.""" + tag_name, buffer, style, _ = self._stack[-1] + if not buffer: + return + self._stack[-1] = (tag_name, [], style, self._stack[-1][3]) + self._finish_block( + tag_name, + buffer, + style, + sum(entry[3] for entry in self._stack), + ) + def _finish_block( self, tag_name: str, buffer: list[str], style: str | None, declared_width: int ) -> None: diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 42587d149..ec3e23a73 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -144,7 +144,13 @@ class PostChatClient(Protocol): available: bool - def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer: + def answer( + self, + question: str, + sources: list[ChatSourceDocument], + *, + conversation_context: str = "", + ) -> ChatAnswer: """Answer ``question`` using only ``sources``, with citations. Implementations must raise if they cannot answer. Protocol stubs @@ -159,7 +165,13 @@ class NullPostChatClient: available = False - def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer: + def answer( + self, + question: str, + sources: list[ChatSourceDocument], + *, + conversation_context: str = "", + ) -> ChatAnswer: """Answer the question using the supplied source documents.""" raise RuntimeError("NullPostChatClient cannot answer; check .available first") @@ -183,6 +195,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer {sources_block} Question: {question} + +Conversation continuity (not source evidence; verify it against the numbered sources): +{conversation_context} """ _CODE_FENCE_PATTERN = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) @@ -198,6 +213,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer {sources_block} Question: {question} + +Conversation continuity (not source evidence; verify it against the numbered sources): +{conversation_context} """ @@ -237,6 +255,22 @@ def _render_sources_block(sources: list[ChatSourceDocument]) -> str: return "\n\n".join(blocks) +def render_global_ask_context( + summary: str | None, + turns: list[tuple[int, str, str]] | tuple[tuple[int, str, str], ...], +) -> str: + """Render account-owned continuity as explicitly non-evidentiary context.""" + blocks: list[str] = [] + if summary and summary.strip(): + blocks.append(f"Compressed prior context:\n{summary.strip()}") + for ordinal, question, answer in turns: + blocks.append( + f"Turn {ordinal} question: {question.strip()}\n" + f"Turn {ordinal} answer: {answer.strip()}" + ) + return "\n\n".join(blocks) + + def _parse_plain_chat_response( content: str, sources: list[ChatSourceDocument] ) -> ChatAnswer | None: @@ -309,10 +343,18 @@ def __init__( self._reasoning_effort = reasoning_effort self._timeout = timeout - def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer: + def answer( + self, + question: str, + sources: list[ChatSourceDocument], + *, + conversation_context: str = "", + ) -> ChatAnswer: """Answer the question using the supplied source documents.""" prompt = _CHAT_REQUEST_PROMPT_TEMPLATE.format( - sources_block=_render_sources_block(sources), question=question + sources_block=_render_sources_block(sources), + question=question, + conversation_context=conversation_context, ) body = post_json( f"{self._base_url}/v1/chat/completions", @@ -329,3 +371,36 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer if answer is None: raise ValueError(f"chat response did not match the required format: {content!r}") return answer + + def compress_context( + self, + previous_summary: str | None, + turns: list[tuple[int, str, str]], + ) -> str: + """Compress older Global Ask turns through the orchestrator boundary.""" + turn_block = "\n\n".join( + f"Turn {ordinal}\nQuestion: {question}\nAnswer: {answer}" + for ordinal, question, answer in turns + ) + prompt = ( + "Compress the prior Global Ask conversation into a short factual continuity summary. " + "Keep unresolved questions, decisions, dates, and requested follow-ups. " + "Do not add facts, names, or conclusions not present in the supplied context. " + "This is continuity context, not source evidence; return only the summary text.\n\n" + f"Existing compressed context:\n{previous_summary or '(none)'}\n\n" + f"Older turns to incorporate:\n{turn_block}" + ) + body = post_json( + f"{self._base_url}/v1/chat/completions", + { + "messages": [{"role": "user", "content": prompt}], + "mode": "auto", + "reasoning_effort": self._reasoning_effort, + }, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + content = body["choices"][0]["message"]["content"] + if not isinstance(content, str) or not content.strip(): + raise ValueError("Global Ask context compression returned no summary") + return content.strip() diff --git a/migrations/0052_global_ask_context.sql b/migrations/0052_global_ask_context.sql new file mode 100644 index 000000000..7a7d6a094 --- /dev/null +++ b/migrations/0052_global_ask_context.sql @@ -0,0 +1,36 @@ +-- Account-owned Global Ask continuity. Evidence is always retrieved again; +-- these rows only retain conversation context and citation references. + +create table if not exists global_ask_session ( + global_ask_session_id uuid primary key, + user_account_id uuid not null references user_account (user_account_id) on delete cascade, + context_summary text, + context_summary_through_ordinal integer not null default 0 + check (context_summary_through_ordinal >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists global_ask_session_account_idx + on global_ask_session (user_account_id, updated_at desc); + +create table if not exists global_ask_turn ( + global_ask_session_id uuid not null + references global_ask_session (global_ask_session_id) on delete cascade, + turn_ordinal integer not null check (turn_ordinal > 0), + question_text text not null, + answer_text text not null, + created_at timestamptz not null default now(), + primary key (global_ask_session_id, turn_ordinal) +); + +create table if not exists global_ask_turn_citation ( + global_ask_session_id uuid not null, + turn_ordinal integer not null, + citation_ordinal integer not null check (citation_ordinal >= 0), + cited_post_id uuid not null references source_post (post_id) on delete cascade, + primary key (global_ask_session_id, turn_ordinal, citation_ordinal), + foreign key (global_ask_session_id, turn_ordinal) + references global_ask_turn (global_ask_session_id, turn_ordinal) + on delete cascade +); diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index 16bcedb52..2efcea47f 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -7,28 +7,38 @@ from __future__ import annotations +import asyncio + from backend.app.activity_stream import ( publish_activity_event_sync, ticket_created_summary, ticket_status_changed_summary, + publish_operation_event, ) class _FakeStream: def __init__(self) -> None: self.entries: list[tuple[str, dict[str, str]]] = [] + self.keys: list[str] = [] def xrevrange(self, key: str, count: int = 50): del key return list(reversed(self.entries[-count:])) def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): - del key, maxlen, approximate + self.keys.append(key) + del maxlen, approximate entry_id = f"1-{len(self.entries)}" self.entries.append((entry_id, dict(fields))) return entry_id +class _AsyncFakeStream(_FakeStream): + async def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): + return super().xadd(key, fields, maxlen=maxlen, approximate=approximate) + + def test_ticket_created_summary_matches_the_live_api_wording() -> None: assert ticket_created_summary("Send Northridge Grid the revised quote") == ( "Ticket created: Send Northridge Grid the revised quote" @@ -63,3 +73,20 @@ def test_publish_activity_event_sync_skips_a_matching_summary() -> None: assert len(client.entries) == 1 assert client.entries[0][1]["event_type"] == "ticket_created" assert "Send Northridge Grid the revised quote" in client.entries[0][1]["summary"] + + +def test_global_ask_registers_an_account_operation_stream_event() -> None: + client = _AsyncFakeStream() + + entry_id = asyncio.run( + publish_operation_event( + client, + "acct-1", + "global_ask_completed", + "Global Ask completed with 2 cited source post(s)", + ) + ) + + assert entry_id == "1-0" + assert client.keys == ["operation:acct-1"] + assert client.entries[0][1]["actor_account_id"] == "acct-1" diff --git a/tests/test_chunking.py b/tests/test_chunking.py index f76b11e5a..cfa921852 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -103,6 +103,17 @@ def test_chunk_by_dom_keeps_nested_table_cell_blocks_in_their_row() -> None: assert [(chunk.label, chunk.text) for chunk in chunks] == [("tr", "No. | Company")] +def test_chunk_by_dom_preserves_nested_list_order_and_depth() -> None: + chunks = chunk_by_dom( + "
    1. Outer
      • Inner
      After inner
    2. " + "
    3. Sibling
    " + ) + + assert [chunk.text for chunk in chunks] == ["Outer", "Inner", "After inner", "Sibling"] + assert [chunk.indent_width for chunk in chunks] == [4, 8, 4, 4] + + + def test_chunk_by_dom_labels_markerless_footnotes() -> None: chunks = chunk_by_dom("

    Body text

    *Tier 2: follow-up note

    ") assert [(chunk.label, chunk.text) for chunk in chunks] == [ @@ -246,6 +257,19 @@ def test_chunk_by_dom_interleaves_images_with_text_in_document_order() -> None: assert chunks[2].text == "After the picture." +def test_chunk_by_dom_interleaves_image_inside_a_block_with_text() -> None: + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + html = f'

    Before the picture.After the picture.

    ' + + chunks = chunk_by_dom(html) + + assert [chunk.unit_type for chunk in chunks] == ["dom", "image", "dom"] + assert [chunk.text for chunk in chunks if chunk.unit_type == "dom"] == [ + "Before the picture.", + "After the picture.", + ] + + def test_chunk_by_dom_labels_text_chunks_with_their_tag_name() -> None: html = "

    A paragraph.

    " chunks = chunk_by_dom(html) diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index f55d53055..29098f0a5 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -45,3 +45,14 @@ def test_migrate_sh_replays_context_scoped_name_cache_migration() -> None: ).read_text(encoding="utf-8") assert "0051_*" in script + + +def test_migrate_sh_replays_global_ask_context_migration() -> None: + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0052_*" in script diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index ca2e39afb..7c7cd46be 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -37,6 +37,7 @@ cited_post_summaries, normalize_chat_question, parse_chat_response, + render_global_ask_context, ) @@ -356,3 +357,36 @@ def fake_post_json(url, payload, *, headers, timeout): assert observed["payload"]["reasoning_effort"] == "auto" assert observed["payload"]["mode"] == "auto" assert "CITED SOURCES" in observed["payload"]["messages"][0]["content"] + + +def test_global_ask_context_is_explicitly_non_evidentiary() -> None: + rendered = render_global_ask_context( + "Earlier synthetic decision", + ((3, "Synthetic question", "Synthetic answer"),), + ) + + assert "Compressed prior context" in rendered + assert "Turn 3 question: Synthetic question" in rendered + assert "Turn 3 answer: Synthetic answer" in rendered + + +def test_contextual_orchestrator_compresses_global_ask_turns(monkeypatch) -> None: + observed = {} + + def fake_post_json(url, payload, *, headers, timeout): + observed["url"] = url + observed["payload"] = payload + return {"choices": [{"message": {"content": "Synthetic compressed context"}}]} + + monkeypatch.setattr("lineageweave.post_chat.post_json", fake_post_json) + summary = ContextualOrchestratorPostChatClient( + "https://orchestrator.test", "token" + ).compress_context( + "Earlier synthetic context", + [(1, "Synthetic question", "Synthetic answer")], + ) + + assert summary == "Synthetic compressed context" + assert observed["url"].endswith("/v1/chat/completions") + assert observed["payload"]["mode"] == "auto" + assert "Synthetic question" in observed["payload"]["messages"][0]["content"] From f0680f473ecf149fd3a6c1d5b505aa3f8a5093f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:40:37 +0900 Subject: [PATCH 16/28] fix(events): register account operation mutations --- backend/app/main.py | 49 +++++++++++++++++++ .../0099-valkey-account-operation-events.md | 35 +++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/adr/0099-valkey-account-operation-events.md diff --git a/backend/app/main.py b/backend/app/main.py index a0c4e77fc..70e1a6024 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -640,6 +640,7 @@ async def update_me_preferences( preference: LocalePreferenceRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, str]: """Persist member preferences without putting them in browser-only state.""" async with pool.acquire() as conn: @@ -648,6 +649,12 @@ async def update_me_preferences( preference.preferred_locale, account.user_account_id, ) + await publish_operation_event( + valkey, + account.user_account_id, + "preferences_updated", + "Locale preference updated", + ) return {"preferred_locale": preference.preferred_locale} @@ -1013,6 +1020,7 @@ async def resolve_customer_master_hint( request: CustomerHintResolveRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Resolve one observed customer-hint code to a real corporate_entity. @@ -1047,6 +1055,12 @@ async def resolve_customer_master_hint( status.HTTP_422_UNPROCESSABLE_ENTITY, "this hint could not be resolved to a corroborated organization name", ) + await publish_operation_event( + valkey, + account.user_account_id, + "customer_hint_resolved", + "Customer hint resolved", + ) return resolution @@ -1072,6 +1086,7 @@ async def read_lineage_graph( async def rebuild_lineage_graph( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Run reconstruct over every source_post and persist post_lineage_edge. @@ -1081,6 +1096,12 @@ async def rebuild_lineage_graph( async with pool.acquire() as conn: async with conn.transaction(): edges = await rebuild_lineage(conn) + await publish_operation_event( + valkey, + account.user_account_id, + "lineage_rebuilt", + "Lineage rebuilt", + ) return {"edge_count": len(edges)} @@ -2325,6 +2346,7 @@ async def rebuild_period_report_endpoint( period_code: str, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Refit or FIPC-score every group in the period. post_admin only.""" _require_post_admin(account) @@ -2337,6 +2359,12 @@ async def rebuild_period_report_endpoint( async with pool.acquire() as conn: async with conn.transaction(): reports = await rebuild_period_reports(conn, grouping_kind, period_code) + await publish_operation_event( + valkey, + account.user_account_id, + "period_report_rebuilt", + "Period report rebuilt", + ) return { "grouping_kind": grouping_kind, "period_code": period_code, @@ -2720,6 +2748,7 @@ async def write_post_bookmark( request: PostBookmarkRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: @@ -2739,6 +2768,13 @@ async def write_post_bookmark( account.user_account_id, post_id, ) + await publish_activity_event( + valkey, + post_id, + "bookmark_changed", + account.user_account_id, + "Post bookmark added" if request.bookmarked else "Post bookmark removed", + ) return {"post_id": post_id, "bookmarked": request.bookmarked} @@ -2987,6 +3023,7 @@ async def create_analysis_run( request: CreateAnalysisRunRequest, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: """Record a Pending lineage run on an authorized cutoff capture. @@ -3011,6 +3048,12 @@ async def create_analysis_run( ) except AnalysisRunCreateError as exc: raise HTTPException(exc.status_code, exc.detail) from exc + await publish_operation_event( + valkey, + account.user_account_id, + "analysis_run_created", + "Analysis run created", + ) return created @@ -3056,6 +3099,12 @@ async def start_analysis_run( work_kind_code=str(queued.get("run_kind_code") or ""), request_sha256=request_digest, ) + await publish_operation_event( + valkey, + account.user_account_id, + "analysis_run_start_requested", + "Analysis run start requested", + ) async with pool.acquire() as conn: async with conn.transaction(): try: diff --git a/docs/adr/0099-valkey-account-operation-events.md b/docs/adr/0099-valkey-account-operation-events.md new file mode 100644 index 000000000..6fb545d82 --- /dev/null +++ b/docs/adr/0099-valkey-account-operation-events.md @@ -0,0 +1,35 @@ +# ADR 0099: Register account operation events in Valkey + +- Status: Accepted +- Date: 2026-08-20 +- Related: [0023](0023-analysis-run-outbox.md), [0098](0098-valkey-backed-post-content-ingestion.md) + +## Context + +Valkey already carries post activity and durable worker wake-ups, but several +successful account-scoped mutations had no operation event. That makes the +operation stream incomplete even though PostgreSQL remains the source of +truth. + +## Decision + +1. Successful account-scoped mutations without one owning post publish a + bounded event to `operation:{account_id}` through + `publish_operation_event`. +2. Post-scoped bookmark changes publish through the existing post activity + stream. Existing ticket, chat, extraction, evaluation, and verification + events keep their current event types. +3. Events contain only an operation type, actor account id, and short generic + summary. They do not carry source bodies, model output, credentials, or + unbounded identifiers. +4. PostgreSQL remains the durable source of truth; a Valkey write is a + notification and does not replace the database mutation or its recovery + path. + +## Consequences + +Operation consumers can account for preference, catalog, lineage, report, +bookmark, and analysis-run actions consistently. Valkey failure has the same +runtime behavior as the existing activity stream: the durable database write +must be recovered or retried by the caller rather than silently presented as +an observed event. From 957d88365b7f7a382eeafbfc2a315ab2e9d2c9f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:43:16 +0900 Subject: [PATCH 17/28] fix(semantics): label numbered footnotes --- frontend/src/postBodyDisplay.test.ts | 5 +++-- frontend/src/postBodyDisplay.ts | 2 +- lineageweave/chunking.py | 2 +- tests/test_chunking.py | 8 ++++++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 32b46ce14..94a2da6c3 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -47,16 +47,17 @@ describe("splitPostBody", () => { ]); }); - it("reads CSS box shorthand indentation and markerless footnotes", () => { + it("reads CSS box shorthand indentation and numbered footnotes", () => { expect( splitPostBody( '
    • Outer
    ' + '
    • Nested
    ' + - "

    *Tier 2: note

    ", + "

    [1] Source note

    *Tier 2: note

    ", ), ).toEqual([ { kind: "text", text: "Outer", indentLevel: 7 }, { kind: "text", text: "Nested", indentLevel: 10 }, + { kind: "text", text: "[1] Source note", role: "footnote" }, { kind: "text", text: "*Tier 2: note", role: "footnote" }, ]); }); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index b9714c224..7eb13c0e1 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -20,7 +20,7 @@ const BLOCK_TAG = /<\/?(?:article|blockquote|div|h[1-6]|li|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul|w:p|w:tbl|w:tr|w:tc)\b[^>]*>/gi; const WORD_INDENT_TAG = /]*\/?\s*>/gi; const LIST_ITEM_START = /^\s*(?:[-*•·]\s+|[*†‡](?=\S)|(?:\d{1,3}|[A-Za-z가-힣])[.)]\s+|[①-⑳]\s+)/; -const FOOTNOTE_START = /^\s*[*†‡](?=\S)/; +const FOOTNOTE_START = /^\s*(?:[*†‡](?=\S)|\[\d{1,3}\]\s+\S)/; const INDENT_MARKER = "\u0001lw-indent:"; const INDENT_MARKER_END = "\u0002"; const INDENT_MARKER_PATTERN = /lw-indent:(\d+)/g; diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index ee7191f25..905df2ea5 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -97,7 +97,7 @@ _LIST_ITEM_START = re.compile( r"^(?:[-*•·]\s+|[*†‡](?=\S)|(?:\d{1,3}|[A-Za-z가-힣])[.)]\s+|[①-⑳]\s+)" ) -_FOOTNOTE_START = re.compile(r"^[*†‡](?=\S)") +_FOOTNOTE_START = re.compile(r"^(?:[*†‡](?=\S)|\[\d{1,3}\]\s+\S)") def normalize_semantic_text(text: str) -> str: diff --git a/tests/test_chunking.py b/tests/test_chunking.py index cfa921852..ccfd02978 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -115,9 +115,13 @@ def test_chunk_by_dom_preserves_nested_list_order_and_depth() -> None: def test_chunk_by_dom_labels_markerless_footnotes() -> None: - chunks = chunk_by_dom("

    Body text

    *Tier 2: follow-up note

    ") + chunks = chunk_by_dom( + "

    Body text[1]

    " + "

    [1] Source note

    *Tier 2: follow-up note

    " + ) assert [(chunk.label, chunk.text) for chunk in chunks] == [ - ("p", "Body text"), + ("p", "Body text[1]"), + ("footnote", "[1] Source note"), ("footnote", "*Tier 2: follow-up note"), ] From 557d45118c314c14cbbe91c9f3a427c28e06e3c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:45:42 +0900 Subject: [PATCH 18/28] fix(ask): preserve authorized lower-ranked sources --- backend/app/post_chat_ingestion.py | 5 +-- tests/test_global_ask_sources.py | 53 ++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index a12f35fed..e60b00aa5 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -652,6 +652,7 @@ async def gather_global_chat_sources( for row in candidate_rows: post_id = str(row["post_id"]) candidate_scores[post_id] = candidate_scores.get(post_id, 0.0) + _MATCH_WEIGHT[row["matched_in"]] + candidate_budget = min(_POST_CHAT_CANDIDATE_LIMIT, max(limit, limit * 4)) candidate_ids = sorted(candidate_scores, key=lambda post_id: candidate_scores[post_id], reverse=True) # A keyword match only proves one post's text is relevant -- the @@ -679,9 +680,9 @@ async def gather_global_chat_sources( ) candidate_ids = list( dict.fromkeys([lineage_anchor_id, *lineage_neighbor_ids, *candidate_ids[1:]]) - )[:limit] + )[:candidate_budget] else: - candidate_ids = [] + candidate_ids = candidate_ids[:candidate_budget] lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) rows = await conn.fetch( diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index d873c263a..ba31767f9 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -243,13 +243,14 @@ async def fetch(self, query: str, *args): assert source_call is not None _query, source_args = source_call assert source_args[2] == 4 - assert list(source_args[1]) == [ + assert list(source_args[1])[:4] == [ "anchor-post", "neighbor-00", "neighbor-01", "neighbor-02", ] - assert [source.post_id for source in sources] == list(source_args[1]) + assert len(source_args[1]) == 16 + assert [source.post_id for source in sources] == list(source_args[1])[:4] assert len(sources) == 4 @@ -369,3 +370,51 @@ async def fetch(self, query: str, *args): assert [source.post_id for source in sources] == ["visible-neighbor"] assert sources[0].evidence_facts == () + + +def test_global_sources_overfetch_before_abac_so_visible_hits_are_not_dropped() -> None: + hidden_rows = [ + { + "post_id": f"hidden-{index}", + "post_title": "Restricted match", + "post_body": "restricted body", + "visibility_code": "private", + "corporate_entity_id": "corp-other", + "matched_in": "title", + } + for index in range(3) + ] + visible_row = { + "post_id": "visible-match", + "post_title": "Authorized match", + "post_body": "authorized body", + "visibility_code": "public", + "corporate_entity_id": None, + "matched_in": "title", + } + rows_by_id = {row["post_id"]: row for row in [*hidden_rows, visible_row]} + + class FakeConnection: + async def fetch(self, query: str, *args): + if "matched_in" in query: + return [*hidden_rows, visible_row] + if "post_lineage_edge" in query: + return [] + if "array_position($2::uuid[], post_id)" in query: + return [ + rows_by_id[post_id] + for post_id in args[1] + if rows_by_id[post_id]["visibility_code"] == "public" + ][: args[2]] + return [] + + sources = asyncio.run( + gather_global_chat_sources( + FakeConnection(), + lambda row: row["visibility_code"] == "public", + question="match", + limit=1, + ) + ) + + assert [source.post_id for source in sources] == ["visible-match"] From 85013814fd65114c5a75d04297f04e342129b8d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:04:46 +0900 Subject: [PATCH 19/28] fix: reject generic relation verification hits --- backend/app/entity_relationship_ingestion.py | 3 +++ ...ence.md => 0100-internal-relation-evidence.md} | 2 +- lineageweave/relation_verification.py | 6 ++++++ tests/test_relation_verification.py | 15 +++++++++++++++ uv.lock | 2 +- 5 files changed, 26 insertions(+), 2 deletions(-) rename docs/adr/{0097-internal-relation-evidence.md => 0100-internal-relation-evidence.md} (96%) diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index 3026c7262..fb9182dd4 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -22,6 +22,9 @@ OrganizationRelationship, ) +# Keep the relationship-network response bounded independently of frontend caps. +_RELATIONSHIP_NETWORK_LIMIT = 100 + async def ingest_post_entity_relationships( conn: asyncpg.Connection, diff --git a/docs/adr/0097-internal-relation-evidence.md b/docs/adr/0100-internal-relation-evidence.md similarity index 96% rename from docs/adr/0097-internal-relation-evidence.md rename to docs/adr/0100-internal-relation-evidence.md index 53ce197a2..6c9a5f43c 100644 --- a/docs/adr/0097-internal-relation-evidence.md +++ b/docs/adr/0100-internal-relation-evidence.md @@ -1,4 +1,4 @@ -# ADR 0097: Preserve authorized internal evidence for relation verification +# ADR 0100: Preserve authorized internal evidence for relation verification - Status: Accepted - Date: 2026-08-18 diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 9f242e498..398edd047 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -59,6 +59,12 @@ "foundation", "the", "and", + "fictitious", + "nonexistent", + "synthetic", + "sample", + "example", + "unknown", } ) diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f515d5d25..5068bfcba 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -133,6 +133,21 @@ def test_legal_suffix_alone_is_not_corroboration() -> None: ) +def test_generic_nonexistence_words_are_not_corroboration() -> None: + """Search hits for generic fixture wording do not verify an org name.""" + assert ( + corroborating_evidence_url( + "Zzqxvthorp Fictitious Nonexistent Org", + { + "url": "https://www.example.com/about-fictitious-organizations", + "title": "Fictitious organizations", + "content": "A generic example about nonexistent organizations.", + }, + ) + is None + ) + + def test_hangul_org_name_token_is_corroboration() -> None: assert ( corroborating_evidence_url( diff --git a/uv.lock b/uv.lock index 10bcf9ff1..7062700b7 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.6" +version = "2.17.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 50c8b8aa54650a2d6bc3d439999d292369d213a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:10:21 +0900 Subject: [PATCH 20/28] fix: align embedded image accessibility assertion --- frontend/src/App.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dfaf46e4e..a4cab7e1e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2248,7 +2248,7 @@ describe("App, authenticated", () => { render(); await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); - const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); + const image = await screen.findByRole("img", { name: "Embedded image" }); expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); expect(screen.getByText("Quote attached.")).toBeInTheDocument(); expect(screen.getByText("Please confirm.")).toBeInTheDocument(); From 41558dc7f4ff02980b26f501d63c1d2f4714ccf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:32:54 +0900 Subject: [PATCH 21/28] test: cover current content schema and embedding agent contract --- backend/tests/test_api.py | 4 ++++ tests/test_contextual_orchestrator_start.py | 20 +++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cb0746490..1f1827f46 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -88,6 +88,9 @@ _IMAGE_REGION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0045_post_content_image_regions.sql" ) +_CONTENT_STRUCTURE_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0046_post_content_structure_evidence.sql" +) _IMAGE_REGION_EMBEDDING_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0047_post_content_image_region_embeddings.sql" ) @@ -213,6 +216,7 @@ def seeded_db(demo_analyst_token): ) cur.execute(_MEMBER_LOCALE_MIGRATION.read_text()) cur.execute(_IMAGE_REGION_MIGRATION.read_text()) + cur.execute(_CONTENT_STRUCTURE_MIGRATION.read_text()) cur.execute(_IMAGE_REGION_EMBEDDING_MIGRATION.read_text()) cur.execute(_SUMMARY_FIVE_W1H_MIGRATION.read_text()) cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py index 698d33339..b70fcdfd2 100644 --- a/tests/test_contextual_orchestrator_start.py +++ b/tests/test_contextual_orchestrator_start.py @@ -52,7 +52,7 @@ def test_gateway_api_key_accepts_local_compatibility_alias(monkeypatch) -> None: assert module._pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY") == "compatibility-key" -def test_bootstrap_passes_embedding_provider_url_before_deleting_secrets(monkeypatch) -> None: +def test_bootstrap_configures_embedding_agent_before_deleting_secrets(monkeypatch) -> None: module = _load_start_module() captured: dict[str, object] = {} @@ -94,5 +94,19 @@ def serve() -> None: argv = captured["argv"] assert isinstance(argv, list) - assert argv[argv.index("--embedding-provider-url") + 1] == "https://gateway.example/v1" - assert argv[argv.index("--embedding-model") + 1] == "embedding-model" + assert "--embedding-provider-url" not in argv + assert "--embedding-model" not in argv + agents = captured["agents"] + assert isinstance(agents, dict) + embedding_agents = [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] + assert embedding_agents == [ + { + "id": "llm_gateway_embedding_agent", + "model": "embedding-model", + "base_url": "https://gateway.example/v1", + "credential_key": "LLM_GATEWAY_API_KEY", + "provider_protocol": "auto", + "tags": ["embedding"], + "priority": 0, + } + ] From f07ceb344664c7e4c137ab3735e2d76aace656f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:29:55 +0900 Subject: [PATCH 22/28] fix: restore unique ADR numbering --- docs/adr/0101-enrichment-timeout-does-not-block-summary.md | 2 +- ...r-processor.md => 0102-major-event-requester-processor.md} | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename docs/adr/{0100-major-event-requester-processor.md => 0102-major-event-requester-processor.md} (97%) diff --git a/docs/adr/0101-enrichment-timeout-does-not-block-summary.md b/docs/adr/0101-enrichment-timeout-does-not-block-summary.md index 11c2242cd..cab8985ee 100644 --- a/docs/adr/0101-enrichment-timeout-does-not-block-summary.md +++ b/docs/adr/0101-enrichment-timeout-does-not-block-summary.md @@ -47,4 +47,4 @@ post. - [ADR 0010](0010-corporate-hierarchy-auto-creation.md) - [ADR 0026](0026-tied-organization-similarity.md) -- [ADR 0100](0100-major-event-requester-processor.md) +- [ADR 0102](0102-major-event-requester-processor.md) diff --git a/docs/adr/0100-major-event-requester-processor.md b/docs/adr/0102-major-event-requester-processor.md similarity index 97% rename from docs/adr/0100-major-event-requester-processor.md rename to docs/adr/0102-major-event-requester-processor.md index c6e9d49b9..600c48893 100644 --- a/docs/adr/0100-major-event-requester-processor.md +++ b/docs/adr/0102-major-event-requester-processor.md @@ -1,4 +1,4 @@ -# ADR 0100 — Major event actions retain requester and processor evidence +# ADR 0102 — Major event actions retain requester and processor evidence **Decision status:** Accepted **Date:** 2026-08-20 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f0aefe1d2..d81ac34bc 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -64,7 +64,7 @@ claims that an unmerged PR or historical runtime observation is live behavior. | FR-08 | LLM, structured output, embedding, and Vision work crosses contextual-orchestrator with one post session and bounded provenance; provider/model/protocol selection stays upstream. | ADR 0030, 0045, 0052, 0070-0077, 0079, 0081-0088 | Orchestrator clients, Compose boundary, historical gateway observations | | FR-09 | Period reports use real fast-mlsirm results; missing cells remain missing and leftover pairs are residual-derived and navigable. | ADR 0003, 0034-0035, 0048-0050 | Historical authenticated report rebuilds; report tests and schema | | FR-10 | Standard provenance uses normalized PROV-O relations; qualified influence implies its unqualified relation and KG edges remain a navigation projection. | ADR 0011, 0065 | PROV-O implementation matrices, ontology, CI contract | -| FR-11 | Post summaries expose evidence-bearing events and R&R. Requester/processor actions are nullable and may only name actors already bound to the same post summary. | ADR 0052, ADR 0100 | Commit `15e1a378` is on PR #258 and the schema exists locally; the current database has zero populated action rows, so buyer-data acceptance remains unproven | +| FR-11 | Post summaries expose evidence-bearing events and R&R. Requester/processor actions are nullable and may only name actors already bound to the same post summary. | ADR 0052, ADR 0102 | Commit `15e1a378` is on PR #258 and the schema exists locally; the current database has zero populated action rows, so buyer-data acceptance remains unproven | | FR-12 | A hierarchy-enrichment timeout leaves the source-grounded summary readable and the actor unbound; it never creates a guessed catalog identity. | ADR 0101, ADR 0010, ADR 0026 | Commit `1c260f20` contains the boundary, ADR, and focused test; independent review, protected-main merge, and fresh runtime evidence remain pending | ## TRD @@ -216,7 +216,7 @@ the exact-head disposition. | P0 | PR #258 is not review/CI complete at its exact current head | #258 is mergeable but BLOCKED: 14 of 22 checks queued, no approval, and four unresolved scanner threads on two SQL modules | Classify each finding against the literal SQL and bound arguments; fix a real flow or add a narrow documented suppression for a false positive, resolve threads, obtain independent approval, and re-check the exact head | | P1 | Requirements were implicit across ADRs and architecture phases | No prior PRD/TRD/requirement traceability baseline existed | Keep FR/NFR IDs in this document linked from ADR index; require new product PRs to name affected IDs and runtime evidence | | P1 | Active PR topology obscures release truth | 8 blocked, 8 unstable, and 2 dirty; many bases are other open branches | Publish a dependency order, retire obsolete/duplicate branches, and avoid version claims until their base chain reaches main | -| P1 | ADR 0100 schema exists but current data does not exercise it | Commit `15e1a378` is on PR #258 and the table exists, but 95 summaries yield zero requester/processor action rows | Regenerate an authorized bounded sample, report aggregate accepted/dropped/absent counts, verify source evidence and actor FKs, then exercise the buyer popup without exposing record content | +| P1 | ADR 0102 schema exists but current data does not exercise it | Commit `15e1a378` is on PR #258 and the table exists, but 95 summaries yield zero requester/processor action rows | Regenerate an authorized bounded sample, report aggregate accepted/dropped/absent counts, verify source evidence and actor FKs, then exercise the buyer popup without exposing record content | | P1 | ADR 0101 is active-PR behavior but not protected-main behavior | Commit `1c260f20` contains the corrected ADR link, boundary, and focused tests; independent review and protected-main merge remain pending | Re-audit the exact head, obtain independent approval, pass required checks, merge normally, and collect fresh runtime evidence | | P1 | ADR status vocabulary is inconsistent and sometimes stale | Several ADRs say “Accepted on this active PR; not protected-main truth” even after branch evolution | Add a mechanical ADR status/link audit that distinguishes Proposed, Accepted-on-PR, Accepted-on-main, and Superseded | | P2 | ADR numbering skips 0031 and 0093-0097 while file 0092 titles itself ADR 0031 | File identity and displayed identity differ | Correct the 0092 title or document an intentional alias; reserve or explain skipped numbers in the index | From 2f780fec29540ccf3a02b0486bd797ada5b02280 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:30:29 +0900 Subject: [PATCH 23/28] fix: keep Valkey ADR numbering unique --- ...ration-events.md => 0103-valkey-account-operation-events.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0099-valkey-account-operation-events.md => 0103-valkey-account-operation-events.md} (96%) diff --git a/docs/adr/0099-valkey-account-operation-events.md b/docs/adr/0103-valkey-account-operation-events.md similarity index 96% rename from docs/adr/0099-valkey-account-operation-events.md rename to docs/adr/0103-valkey-account-operation-events.md index 6fb545d82..4b0cc3d7a 100644 --- a/docs/adr/0099-valkey-account-operation-events.md +++ b/docs/adr/0103-valkey-account-operation-events.md @@ -1,4 +1,4 @@ -# ADR 0099: Register account operation events in Valkey +# ADR 0103: Register account operation events in Valkey - Status: Accepted - Date: 2026-08-20 From 335dd08140b6cc3206ca4836aaa0b278aff5b92c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:39:00 +0900 Subject: [PATCH 24/28] fix: keep Weekly VOC test option typed --- frontend/src/App.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 99c2acd3f..ee265c948 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -100,12 +100,6 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }[]; - }): ReturnType & { - releaseMe: () => void; - releaseSecondAsk: () => void; - releaseGroupRelated: () => void; - releaseDemoRelated: () => void; - releasePostOneSummary: () => void; latestVocPost?: { post_id: string; post_title: string; @@ -115,6 +109,12 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }; + }): ReturnType & { + releaseMe: () => void; + releaseSecondAsk: () => void; + releaseGroupRelated: () => void; + releaseDemoRelated: () => void; + releasePostOneSummary: () => void; } { const statusLabel: Record = { open: "Open", From bcdc4594abe467627c71378adeceb61624d02d67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:10:39 +0900 Subject: [PATCH 25/28] fix: recover expired ask sessions and localize timeline --- ...17.0-event-lineage-node-keeps-gnb-focus.md | 4 +++ frontend/src/App.test.tsx | 31 +++++++++++++++++++ frontend/src/App.tsx | 31 +++++++++++++++---- frontend/src/i18n.test.ts | 1 + frontend/src/i18n.ts | 4 +++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md index 313ebc036..f6db4cff0 100644 --- a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md +++ b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md @@ -3,3 +3,7 @@ Opening a linked Event Lineage DAG node from a GNB-focused popup keeps Event Lineage focused and names Keyman and evaluation next. A home-list DAG walk does not. No TEPP theta is invented. + +Ask Agent now replaces an expired saved session once, then stores the new +session. The Event Lineage timeline heading and accessibility label are +translated in the supported locales. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 9b85028b4..bc838b48d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -87,6 +87,7 @@ describe("App, authenticated", () => { deferMe?: boolean; deferPostOneSummary?: boolean; deferSecondAsk?: boolean; + invalidAskSessionOnce?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -1649,6 +1650,15 @@ describe("App, authenticated", () => { } if (url.endsWith("/api/ask") && method === "POST") { askRequestCount += 1; + const requestBody = JSON.parse(String(init?.body ?? "{}")) as { session_id?: string }; + if (options?.invalidAskSessionOnce && askRequestCount === 1 && requestBody.session_id) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "Global Ask session not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + } const ready = options?.deferSecondAsk && askRequestCount === 2 ? secondAskReady @@ -1656,6 +1666,7 @@ describe("App, authenticated", () => { return ready.then(() => Promise.resolve( jsonResponse({ + session_id: "session-1", answer_text: "The cited project is supported by the stored semantic evidence.", cited_post_ids: ["post-2"], cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], @@ -1841,6 +1852,26 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); + it("replaces an invalid saved Ask session without requiring storage cleanup", async () => { + window.sessionStorage.setItem("lineageweave.globalAskSessionId", "stale-session"); + const fetchMock = stubBackend({ invalidAskSessionOnce: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + const ask = await screen.findByRole("region", { name: "Ask Agent" }); + await userEvent.type(within(ask).getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(within(ask).getByRole("button", { name: "Ask" })); + + expect( + await within(ask).findByText("The cited project is supported by the stored semantic evidence."), + ).toBeInTheDocument(); + const askBodies = fetchMock.mock.calls + .filter(([url]) => String(url).endsWith("/api/ask")) + .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as { session_id?: string }); + expect(askBodies.map((body) => body.session_id)).toEqual(["stale-session", undefined]); + expect(window.sessionStorage.getItem("lineageweave.globalAskSessionId")).toBe("session-1"); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c550914ef..e6d321f64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -106,6 +106,8 @@ import { import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; +const GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId"; + function orchestratorUnavailableMessage(err: unknown, action: string): string { if (err instanceof BackendError && err.status === 503) { return `${action} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`; @@ -4513,7 +4515,7 @@ function AskAgentPanel({ const [error, setError] = useState(null); const [asking, setAsking] = useState(false); const [sessionId, setSessionId] = useState(() => - window.sessionStorage.getItem("lineageweave.globalAskSessionId") ?? undefined, + window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY) ?? undefined, ); async function handleAsk() { @@ -4523,10 +4525,20 @@ function AskAgentPanel({ setError(null); setAnswer(null); try { - const nextAnswer = await askAgent(accessToken, normalized, sessionId); + let nextAnswer: AskAgentResponse; + try { + nextAnswer = await askAgent(accessToken, normalized, sessionId); + } catch (err) { + if (!(err instanceof BackendError) || err.status !== 404 || !sessionId) { + throw err; + } + setSessionId(undefined); + window.sessionStorage.removeItem(GLOBAL_ASK_SESSION_STORAGE_KEY); + nextAnswer = await askAgent(accessToken, normalized); + } setAnswer(nextAnswer); setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, nextAnswer.session_id); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4560,8 +4572,8 @@ function AskAgentPanel({ {answer.next_action ?

    {t(answer.next_action)}

    : null} {answer.timeline && answer.timeline.length > 0 ? ( <> -

    Event Lineage timeline

    -
      +

      {t("Event Lineage timeline")}

      +
        {answer.timeline.map((event) => (
      1. + { "Authorized commitments are current. Open a commitment to read Event Lineage.", "Authorized customer entities are current. Open a related post to read Event Lineage.", "Authorized cited posts are current. Open a cited post to read Event Lineage.", + "Event Lineage timeline", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 393aaa673..ba12c24d4 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -319,6 +319,7 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Ask 인용 다음 작업", "Evidence next action": "근거 다음 작업", "Event Lineage next action": "이벤트 계보 다음 작업", + "Event Lineage timeline": "이벤트 계보 타임라인", "Related next action": "관련 대상 다음 작업", "Ask next action": "Ask 다음 작업", "Keyman next action": "Keyman 다음 작업", @@ -660,6 +661,7 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Ask 引用操作", "Evidence next action": "证据操作", "Event Lineage next action": "事件谱系操作", + "Event Lineage timeline": "事件谱系时间线", "Related next action": "相关节点操作", "Ask next action": "Ask 操作", "Keyman next action": "关键联系人操作", @@ -1001,6 +1003,7 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Ask引用の操作", "Evidence next action": "証拠の操作", "Event Lineage next action": "イベント系譜の操作", + "Event Lineage timeline": "イベント系譜タイムライン", "Related next action": "関連ノードの操作", "Ask next action": "Askの操作", "Keyman next action": "キーパーソンの操作", @@ -1342,6 +1345,7 @@ const TRANSLATIONS: Partial>> = { "Ask citation next action": "Thao tác trích dẫn Ask", "Evidence next action": "Thao tác bằng chứng", "Event Lineage next action": "Thao tác Dòng sự kiện", + "Event Lineage timeline": "Dòng sự kiện theo thời gian", "Related next action": "Thao tác nút liên quan", "Ask next action": "Thao tác Ask", "Keyman next action": "Thao tác người liên hệ chính", From 8832216fcd2b0a1dcb486ea83269e25695ce378a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:20:28 +0900 Subject: [PATCH 26/28] fix: localize event lineage post actions --- CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md | 4 ++-- frontend/src/i18n.test.ts | 1 + frontend/src/i18n.ts | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md index f6db4cff0..7edaedcfb 100644 --- a/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md +++ b/CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md @@ -5,5 +5,5 @@ Event Lineage focused and names Keyman and evaluation next. A home-list DAG walk does not. No TEPP theta is invented. Ask Agent now replaces an expired saved session once, then stores the new -session. The Event Lineage timeline heading and accessibility label are -translated in the supported locales. +session. The Event Lineage timeline heading, list, and post-button +accessibility labels are translated in the supported locales. diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index d9b23db20..80d6d5085 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -42,6 +42,7 @@ describe("i18n", () => { "Authorized customer entities are current. Open a related post to read Event Lineage.", "Authorized cited posts are current. Open a cited post to read Event Lineage.", "Event Lineage timeline", + "Open timeline post:", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ba12c24d4..dca426b06 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -320,6 +320,7 @@ const TRANSLATIONS: Partial>> = { "Evidence next action": "근거 다음 작업", "Event Lineage next action": "이벤트 계보 다음 작업", "Event Lineage timeline": "이벤트 계보 타임라인", + "Open timeline post:": "타임라인 글 열기:", "Related next action": "관련 대상 다음 작업", "Ask next action": "Ask 다음 작업", "Keyman next action": "Keyman 다음 작업", @@ -662,6 +663,7 @@ const TRANSLATIONS: Partial>> = { "Evidence next action": "证据操作", "Event Lineage next action": "事件谱系操作", "Event Lineage timeline": "事件谱系时间线", + "Open timeline post:": "打开时间线文章:", "Related next action": "相关节点操作", "Ask next action": "Ask 操作", "Keyman next action": "关键联系人操作", @@ -1004,6 +1006,7 @@ const TRANSLATIONS: Partial>> = { "Evidence next action": "証拠の操作", "Event Lineage next action": "イベント系譜の操作", "Event Lineage timeline": "イベント系譜タイムライン", + "Open timeline post:": "タイムラインの投稿を開く:", "Related next action": "関連ノードの操作", "Ask next action": "Askの操作", "Keyman next action": "キーパーソンの操作", @@ -1346,6 +1349,7 @@ const TRANSLATIONS: Partial>> = { "Evidence next action": "Thao tác bằng chứng", "Event Lineage next action": "Thao tác Dòng sự kiện", "Event Lineage timeline": "Dòng sự kiện theo thời gian", + "Open timeline post:": "Mở bài viết trên dòng thời gian:", "Related next action": "Thao tác nút liên quan", "Ask next action": "Thao tác Ask", "Keyman next action": "Thao tác người liên hệ chính", From 7ff36d4c639d37c44fc5bf99ac3b61501986a43f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:20:59 +0900 Subject: [PATCH 27/28] fix: preserve semantic image and summary boundaries --- .../2.17.0-image-and-summary-boundaries.md | 8 +++++++ frontend/src/App.test.tsx | 11 ++++++++++ frontend/src/App.tsx | 11 ---------- lineageweave/chunking.py | 12 +++++----- tests/test_chunking.py | 22 +++++++++++++++++++ 5 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.d/2.17.0-image-and-summary-boundaries.md diff --git a/CHANGELOG.d/2.17.0-image-and-summary-boundaries.md b/CHANGELOG.d/2.17.0-image-and-summary-boundaries.md new file mode 100644 index 000000000..a88842f8f --- /dev/null +++ b/CHANGELOG.d/2.17.0-image-and-summary-boundaries.md @@ -0,0 +1,8 @@ +# 2.17.0 — Preserve table image boundaries and avoid duplicate summaries + +## Fixed + +- Inline or invalid images no longer split a table row or surrounding + paragraph into disconnected semantic units. +- Opening a post requests its summary once; the explicit retry action remains + the only path that requests another summary. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fae84b865..39909fdd9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2596,6 +2596,17 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "Retry summary refresh" })).toBeInTheDocument(); }); + it("requests one summary per post open and keeps retry as the only second request", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("이것은 요약입니다.")).toBeInTheDocument()); + expect( + fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/posts/post-1/summary")), + ).toHaveLength(1); + }); + it("refreshes newly processed source content after summary generation", async () => { stubBackend({ contentAfterSummary: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d87dd9aa9..52075e2cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1833,17 +1833,6 @@ function PostDetailPopup({ .catch(() => { if (isCurrent()) setEvaluation([]); }); - fetchPostSummary(accessToken, postId) - .then((value) => { - if (!isCurrent()) return; - setSummary(value); - void reloadContent(); - }) - .catch((err) => { - if (!isCurrent()) return; - setSummary(null); - setSummaryError(summaryFetchError(err)); - }); fetchPostFiveW1H(accessToken, postId) .then((value) => { if (isCurrent()) setFiveW1H(value); diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index 6967ddbfd..bec3ff9e0 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -352,13 +352,13 @@ def __init__(self) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Collect relevant text state when an HTML start tag is encountered.""" if tag == "img": - if self._stack: - self._flush_current_buffer() src = next((value for name, value in attrs if name == "src" and value), None) - if src: - decoded = _decode_data_uri_image(src) - if decoded is not None: - self._finished.append(("image", decoded, "", None, 0, 0)) + decoded = _decode_data_uri_image(src) if src else None + if decoded is None: + return + if self._stack and not any(entry[0] in _TABLE_ROW_TAGS for entry in self._stack): + self._flush_current_buffer() + self._finished.append(("image", decoded, "", None, 0, 0)) return if tag in {"br", "w:br"} and self._stack: self._stack[-1][1].append("\n") diff --git a/tests/test_chunking.py b/tests/test_chunking.py index b95e05026..6fc9dfc2b 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -348,6 +348,28 @@ def test_chunk_by_dom_interleaves_image_inside_a_block_with_text() -> None: ] +def test_chunk_by_dom_keeps_an_inline_table_image_from_splitting_the_row() -> None: + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + html = ( + "
        Before " + f'' + "AfterSecond cell
        " + ) + + chunks = chunk_by_dom(html) + + assert [chunk.text for chunk in chunks if chunk.unit_type == "dom"] == [ + "Before After | Second cell", + ] + assert [chunk.unit_type for chunk in chunks].count("image") == 1 + + +def test_chunk_by_dom_does_not_split_text_for_an_undecodable_inline_image() -> None: + chunks = chunk_by_dom('

        BeforeAfter

        ') + + assert [(chunk.unit_type, chunk.text) for chunk in chunks] == [("dom", "BeforeAfter")] + + def test_chunk_by_dom_labels_text_chunks_with_their_tag_name() -> None: html = "

        A paragraph.

        " chunks = chunk_by_dom(html) From 9f24b6846029fbe64969aaa5ceebab153831da6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:24:41 +0900 Subject: [PATCH 28/28] fix: keep ADR numbers unique --- ...ration-events.md => 0126-valkey-account-operation-events.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0125-valkey-account-operation-events.md => 0126-valkey-account-operation-events.md} (96%) diff --git a/docs/adr/0125-valkey-account-operation-events.md b/docs/adr/0126-valkey-account-operation-events.md similarity index 96% rename from docs/adr/0125-valkey-account-operation-events.md rename to docs/adr/0126-valkey-account-operation-events.md index b9aabce41..722214942 100644 --- a/docs/adr/0125-valkey-account-operation-events.md +++ b/docs/adr/0126-valkey-account-operation-events.md @@ -1,4 +1,4 @@ -# ADR 0125: Register account operation events in Valkey +# ADR 0126: Register account operation events in Valkey - Status: Accepted - Date: 2026-08-20