From 943ab636599a606685852abddc00a66c26b7ccbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:26:22 -0700 Subject: [PATCH 01/63] ci: run buyer-surface cutoff repair test-first --- .github/workflows/tests.yml | 216 ++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cad1f17c..00d29c35d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,222 @@ concurrency: cancel-in-progress: true jobs: + repair_analysis_run_dag_context: + name: Repair target-specific cutoff context + if: >- + github.event_name == 'pull_request' && + github.head_ref == 'feat/event-lineage-node-keeps-gnb-focus-v2170' + runs-on: ubuntu-latest + permissions: + contents: write + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + steps: + - name: Checkout pull request branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Enable Corepack + run: corepack enable + + - name: Install locked frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Add the target-specific cutoff regression + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.test.tsx") + text = path.read_text() + old = ''' 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("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + ''' + new = ''' 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("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + const publicPosts = screen.getAllByLabelText("Open post: Public post"); + await userEvent.click(publicPosts[publicPosts.length - 1]); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); + expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + ''' + count = text.count(old) + if count != 1: + raise SystemExit(f"expected one regression insertion point, found {count}") + path.write_text(text.replace(old, new, 1)) + PY + + - name: Prove the regression fails before the fix + working-directory: frontend + run: | + set +e + pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" > /tmp/red.log 2>&1 + status=$? + set -e + cat /tmp/red.log + if [ "$status" -eq 0 ]; then + echo "Expected the new regression to fail before the production fix." >&2 + exit 1 + fi + grep -F "Live body warning" /tmp/red.log + + - name: Preserve the analysis run while walking the DAG + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.tsx") + text = path.read_text() + + def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, found {count}") + text = text.replace(old, new, 1) + + replace_once( + '''type SelectPostOptions = { + liveAfterCutoff?: boolean; + knowledgeCutoff?: string; + fromReportMember?: boolean; + ''', + '''type SelectPostOptions = { + liveAfterCutoff?: boolean; + knowledgeCutoff?: string; + analysisRun?: AnalysisRun; + fromReportMember?: boolean; + ''', + "SelectPostOptions", + ) + replace_once( + ''' return { + liveAfterCutoff: Boolean(post?.live_after_cutoff), + knowledgeCutoff: run.knowledge_cutoff, + }; + ''', + ''' return { + liveAfterCutoff: Boolean(post?.live_after_cutoff), + knowledgeCutoff: run.knowledge_cutoff, + analysisRun: run, + }; + ''', + "analysisRunPostOpenOptions", + ) + replace_once( + ''' const [selectedPostId, setSelectedPostId] = useState(null); + const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); + const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + ''', + ''' const [selectedPostId, setSelectedPostId] = useState(null); + const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); + const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + const [openedAnalysisRun, setOpenedAnalysisRun] = useState(null); + ''', + "analysis run state", + ) + replace_once( + ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); + setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedFromReportMember(Boolean(options?.fromReportMember)); + ''', + ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); + setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedAnalysisRun(options?.analysisRun ?? null); + setOpenedFromReportMember(Boolean(options?.fromReportMember)); + ''', + "selectPost analysis run state", + ) + replace_once( + ''' setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + setOpenedFromReportMember(false); + ''', + ''' setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + setOpenedAnalysisRun(null); + setOpenedFromReportMember(false); + ''', + "closeSelectedPost analysis run state", + ) + replace_once( + ''' onSelectPost={(postId) => + selectPost(postId, { + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }) + } + ''', + ''' onSelectPost={(postId) => + selectPost(postId, { + ...(openedAnalysisRun + ? analysisRunPostOpenOptions(openedAnalysisRun, postId) + : {}), + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }) + } + ''', + "PostDetailPopup DAG callback", + ) + + path.write_text(text) + PY + + - name: Verify the focused regression + working-directory: frontend + run: pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" + + - name: Run complete frontend validation + working-directory: frontend + run: | + pnpm run lint + pnpm run test + pnpm run build + pnpm run build-storybook + + - name: Restore canonical workflows and publish the fix + run: | + git show "${BASE_SHA}:.github/workflows/tests.yml" > .github/workflows/tests.yml + rm -f .github/workflows/repair-analysis-run-dag-context.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/src/App.tsx frontend/src/App.test.tsx .github/workflows/tests.yml .github/workflows/repair-analysis-run-dag-context.yml + git diff --cached --check + git commit -m "fix: preserve analysis cutoff across DAG navigation" + git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 + pytest: name: Full test suite runs-on: ubuntu-latest From 8e5be3ec864c714d715c12d593108afdbf0441e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:30:50 -0700 Subject: [PATCH 02/63] ci: cover direct and DAG cutoff navigation --- .github/workflows/tests.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 00d29c35d..adcbf5bc4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -140,6 +140,23 @@ jobs: ''', "analysisRunPostOpenOptions", ) + replace_once( + ''' onClick={() => + onSelectPost(post.post_id, { + liveAfterCutoff: Boolean(post.live_after_cutoff), + knowledgeCutoff: selected.knowledge_cutoff, + }) + } + ''', + ''' onClick={() => + onSelectPost( + post.post_id, + analysisRunPostOpenOptions(selected, post.post_id), + ) + } + ''', + "visible analysis-run post opener", + ) replace_once( ''' const [selectedPostId, setSelectedPostId] = useState(null); const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); From 3f4c41ed1d0f432123efb98f7c00c8e1509a22bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:37:41 -0700 Subject: [PATCH 03/63] ci: restore canonical tests workflow --- .github/workflows/tests.yml | 233 ------------------------------------ 1 file changed, 233 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index adcbf5bc4..1cad1f17c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,239 +13,6 @@ concurrency: cancel-in-progress: true jobs: - repair_analysis_run_dag_context: - name: Repair target-specific cutoff context - if: >- - github.event_name == 'pull_request' && - github.head_ref == 'feat/event-lineage-node-keeps-gnb-focus-v2170' - runs-on: ubuntu-latest - permissions: - contents: write - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - steps: - - name: Checkout pull request branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Enable Corepack - run: corepack enable - - - name: Install locked frontend dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Add the target-specific cutoff regression - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.test.tsx") - text = path.read_text() - old = ''' 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("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - ''' - new = ''' 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("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - const publicPosts = screen.getAllByLabelText("Open post: Public post"); - await userEvent.click(publicPosts[publicPosts.length - 1]); - await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); - expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( - "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", - ); - expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); - expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - ''' - count = text.count(old) - if count != 1: - raise SystemExit(f"expected one regression insertion point, found {count}") - path.write_text(text.replace(old, new, 1)) - PY - - - name: Prove the regression fails before the fix - working-directory: frontend - run: | - set +e - pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" > /tmp/red.log 2>&1 - status=$? - set -e - cat /tmp/red.log - if [ "$status" -eq 0 ]; then - echo "Expected the new regression to fail before the production fix." >&2 - exit 1 - fi - grep -F "Live body warning" /tmp/red.log - - - name: Preserve the analysis run while walking the DAG - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.tsx") - text = path.read_text() - - def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, found {count}") - text = text.replace(old, new, 1) - - replace_once( - '''type SelectPostOptions = { - liveAfterCutoff?: boolean; - knowledgeCutoff?: string; - fromReportMember?: boolean; - ''', - '''type SelectPostOptions = { - liveAfterCutoff?: boolean; - knowledgeCutoff?: string; - analysisRun?: AnalysisRun; - fromReportMember?: boolean; - ''', - "SelectPostOptions", - ) - replace_once( - ''' return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), - knowledgeCutoff: run.knowledge_cutoff, - }; - ''', - ''' return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), - knowledgeCutoff: run.knowledge_cutoff, - analysisRun: run, - }; - ''', - "analysisRunPostOpenOptions", - ) - replace_once( - ''' onClick={() => - onSelectPost(post.post_id, { - liveAfterCutoff: Boolean(post.live_after_cutoff), - knowledgeCutoff: selected.knowledge_cutoff, - }) - } - ''', - ''' onClick={() => - onSelectPost( - post.post_id, - analysisRunPostOpenOptions(selected, post.post_id), - ) - } - ''', - "visible analysis-run post opener", - ) - replace_once( - ''' const [selectedPostId, setSelectedPostId] = useState(null); - const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); - const [openedCutoffIso, setOpenedCutoffIso] = useState(null); - ''', - ''' const [selectedPostId, setSelectedPostId] = useState(null); - const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); - const [openedCutoffIso, setOpenedCutoffIso] = useState(null); - const [openedAnalysisRun, setOpenedAnalysisRun] = useState(null); - ''', - "analysis run state", - ) - replace_once( - ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); - setOpenedCutoffIso(options?.knowledgeCutoff ?? null); - setOpenedFromReportMember(Boolean(options?.fromReportMember)); - ''', - ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); - setOpenedCutoffIso(options?.knowledgeCutoff ?? null); - setOpenedAnalysisRun(options?.analysisRun ?? null); - setOpenedFromReportMember(Boolean(options?.fromReportMember)); - ''', - "selectPost analysis run state", - ) - replace_once( - ''' setOpenedAfterCutoff(false); - setOpenedCutoffIso(null); - setOpenedFromReportMember(false); - ''', - ''' setOpenedAfterCutoff(false); - setOpenedCutoffIso(null); - setOpenedAnalysisRun(null); - setOpenedFromReportMember(false); - ''', - "closeSelectedPost analysis run state", - ) - replace_once( - ''' onSelectPost={(postId) => - selectPost(postId, { - fromReportMember: openedFromReportMember, - fromWeeklyVoc: openedFromWeeklyVoc, - fromCalendar: openedFromCalendar, - fromCustomerMaster: openedFromCustomerMaster, - fromAskAgent: openedFromAskAgent, - }) - } - ''', - ''' onSelectPost={(postId) => - selectPost(postId, { - ...(openedAnalysisRun - ? analysisRunPostOpenOptions(openedAnalysisRun, postId) - : {}), - fromReportMember: openedFromReportMember, - fromWeeklyVoc: openedFromWeeklyVoc, - fromCalendar: openedFromCalendar, - fromCustomerMaster: openedFromCustomerMaster, - fromAskAgent: openedFromAskAgent, - }) - } - ''', - "PostDetailPopup DAG callback", - ) - - path.write_text(text) - PY - - - name: Verify the focused regression - working-directory: frontend - run: pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" - - - name: Run complete frontend validation - working-directory: frontend - run: | - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - - name: Restore canonical workflows and publish the fix - run: | - git show "${BASE_SHA}:.github/workflows/tests.yml" > .github/workflows/tests.yml - rm -f .github/workflows/repair-analysis-run-dag-context.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/App.test.tsx .github/workflows/tests.yml .github/workflows/repair-analysis-run-dag-context.yml - git diff --cached --check - git commit -m "fix: preserve analysis cutoff across DAG navigation" - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 - pytest: name: Full test suite runs-on: ubuntu-latest From 4b80c871bb750176357bffd7ee8a89399a04f8e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:37:52 -0700 Subject: [PATCH 04/63] ci: remove temporary cutoff repair workflow --- .../repair-analysis-run-dag-context.yml | 222 ------------------ 1 file changed, 222 deletions(-) delete mode 100644 .github/workflows/repair-analysis-run-dag-context.yml diff --git a/.github/workflows/repair-analysis-run-dag-context.yml b/.github/workflows/repair-analysis-run-dag-context.yml deleted file mode 100644 index 360796091..000000000 --- a/.github/workflows/repair-analysis-run-dag-context.yml +++ /dev/null @@ -1,222 +0,0 @@ -name: Repair analysis-run DAG cutoff context - -on: - push: - branches: - - "feat/event-lineage-node-keeps-gnb-focus-v2170" - paths: - - ".github/workflows/repair-analysis-run-dag-context.yml" - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: repair-analysis-run-dag-context-${{ github.ref }} - cancel-in-progress: false - -jobs: - repair: - name: Preserve target-specific cutoff context - runs-on: ubuntu-latest - steps: - - name: Checkout pull request branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Enable Corepack - run: corepack enable - - - name: Install locked frontend dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Add the target-specific cutoff regression - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.test.tsx") - text = path.read_text() - old = ''' await userEvent.click(linkedPosts[linkedPosts.length - 1]); - await waitFor(() => - expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), - ); - expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - ''' - new = ''' await userEvent.click(linkedPosts[linkedPosts.length - 1]); - await waitFor(() => - expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), - ); - expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText("Open post: Public post")); - await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); - expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( - "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", - ); - expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); - expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - ''' - if text.count(old) != 1: - raise SystemExit(f"expected one regression insertion point, found {text.count(old)}") - path.write_text(text.replace(old, new, 1)) - PY - - - name: Prove the regression fails before the fix - working-directory: frontend - run: | - set +e - pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" > /tmp/red.log 2>&1 - status=$? - set -e - cat /tmp/red.log - if [ "$status" -eq 0 ]; then - echo "Expected the new regression to fail before the production fix." >&2 - exit 1 - fi - grep -F "Live body warning" /tmp/red.log - grep -F "warns that a cutoff-rewritten title opens the live body, not a snapshot" /tmp/red.log - - - name: Preserve the analysis run while walking the DAG - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.tsx") - text = path.read_text() - - def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, found {count}") - text = text.replace(old, new, 1) - - replace_once( - '''type SelectPostOptions = { - liveAfterCutoff?: boolean; - knowledgeCutoff?: string; - fromReportMember?: boolean; - ''', - '''type SelectPostOptions = { - liveAfterCutoff?: boolean; - knowledgeCutoff?: string; - analysisRun?: AnalysisRun; - fromReportMember?: boolean; - ''', - "SelectPostOptions", - ) - replace_once( - ''' return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), - knowledgeCutoff: run.knowledge_cutoff, - }; - ''', - ''' return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), - knowledgeCutoff: run.knowledge_cutoff, - analysisRun: run, - }; - ''', - "analysisRunPostOpenOptions", - ) - replace_once( - ''' const [selectedPostId, setSelectedPostId] = useState(null); - const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); - const [openedCutoffIso, setOpenedCutoffIso] = useState(null); - ''', - ''' const [selectedPostId, setSelectedPostId] = useState(null); - const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); - const [openedCutoffIso, setOpenedCutoffIso] = useState(null); - const [openedAnalysisRun, setOpenedAnalysisRun] = useState(null); - ''', - "analysis run state", - ) - replace_once( - ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); - setOpenedCutoffIso(options?.knowledgeCutoff ?? null); - setOpenedFromReportMember(Boolean(options?.fromReportMember)); - ''', - ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); - setOpenedCutoffIso(options?.knowledgeCutoff ?? null); - setOpenedAnalysisRun(options?.analysisRun ?? null); - setOpenedFromReportMember(Boolean(options?.fromReportMember)); - ''', - "selectPost analysis run state", - ) - replace_once( - ''' setOpenedAfterCutoff(false); - setOpenedCutoffIso(null); - setOpenedFromReportMember(false); - ''', - ''' setOpenedAfterCutoff(false); - setOpenedCutoffIso(null); - setOpenedAnalysisRun(null); - setOpenedFromReportMember(false); - ''', - "closeSelectedPost analysis run state", - ) - replace_once( - ''' onSelectPost={(postId) => - selectPost(postId, { - fromReportMember: openedFromReportMember, - fromWeeklyVoc: openedFromWeeklyVoc, - fromCalendar: openedFromCalendar, - fromCustomerMaster: openedFromCustomerMaster, - fromAskAgent: openedFromAskAgent, - }) - } - ''', - ''' onSelectPost={(postId) => - selectPost(postId, { - ...(openedAnalysisRun - ? analysisRunPostOpenOptions(openedAnalysisRun, postId) - : {}), - fromReportMember: openedFromReportMember, - fromWeeklyVoc: openedFromWeeklyVoc, - fromCalendar: openedFromCalendar, - fromCustomerMaster: openedFromCustomerMaster, - fromAskAgent: openedFromAskAgent, - }) - } - ''', - "PostDetailPopup DAG callback", - ) - - path.write_text(text) - PY - - - name: Verify the focused regression - working-directory: frontend - run: pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" - - - name: Run complete frontend validation - working-directory: frontend - run: | - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - - name: Remove the one-shot workflow and publish the fix - run: | - rm .github/workflows/repair-analysis-run-dag-context.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/App.test.tsx .github/workflows/repair-analysis-run-dag-context.yml - git diff --cached --check - git commit -m "fix: preserve analysis cutoff across DAG navigation" - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From c96fa288adf5a8848632533dbda06b5accf2bb83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:43:23 -0700 Subject: [PATCH 05/63] ci: repair analysis-run DAG cutoff context --- .../repair-analysis-run-dag-context-v2.yml | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 .github/workflows/repair-analysis-run-dag-context-v2.yml diff --git a/.github/workflows/repair-analysis-run-dag-context-v2.yml b/.github/workflows/repair-analysis-run-dag-context-v2.yml new file mode 100644 index 000000000..02c6f5eda --- /dev/null +++ b/.github/workflows/repair-analysis-run-dag-context-v2.yml @@ -0,0 +1,215 @@ +name: Repair analysis-run DAG cutoff context v2 + +on: + push: + branches: + - "feat/event-lineage-node-keeps-gnb-focus-v2170" + paths: + - ".github/workflows/repair-analysis-run-dag-context-v2.yml" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: repair-analysis-run-dag-context-v2-${{ github.ref }} + cancel-in-progress: false + +jobs: + repair: + name: Preserve selected target cutoff context + runs-on: ubuntu-latest + steps: + - name: Checkout exact feature branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Enable Corepack and install locked frontend dependencies + run: | + corepack enable + cd frontend + pnpm install --frozen-lockfile + + - name: Add target-specific cutoff regression + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.test.tsx") + text = path.read_text() + old = ''' await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + ''' + new = ''' await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText("Open post: Public post")); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); + expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); + ''' + if text.count(old) != 1: + raise SystemExit(f"test insertion point drifted: {text.count(old)} matches") + path.write_text(text.replace(old, new, 1)) + PY + + - name: Prove regression is red + working-directory: frontend + run: | + set +e + pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" > /tmp/red.log 2>&1 + status=$? + set -e + cat /tmp/red.log + if [ "$status" -eq 0 ]; then + echo "Expected DAG cutoff regression to fail before implementation." >&2 + exit 1 + fi + grep -F "Live body warning" /tmp/red.log + + - name: Preserve analysis-run context through DAG navigation + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.tsx") + text = path.read_text() + + def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, found {count}") + text = text.replace(old, new, 1) + + replace_once( + '''type SelectPostOptions = { + liveAfterCutoff?: boolean; + knowledgeCutoff?: string; + fromReportMember?: boolean; + ''', + '''type SelectPostOptions = { + liveAfterCutoff?: boolean; + knowledgeCutoff?: string; + analysisRun?: AnalysisRun; + fromReportMember?: boolean; + ''', + "SelectPostOptions", + ) + replace_once( + ''' return { + liveAfterCutoff: Boolean(post?.live_after_cutoff), + knowledgeCutoff: run.knowledge_cutoff, + }; + ''', + ''' return { + liveAfterCutoff: Boolean(post?.live_after_cutoff), + knowledgeCutoff: run.knowledge_cutoff, + analysisRun: run, + }; + ''', + "analysisRunPostOpenOptions", + ) + replace_once( + ''' const [selectedPostId, setSelectedPostId] = useState(null); + const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); + const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + const [canRebuild, setCanRebuild] = useState(false); + ''', + ''' const [selectedPostId, setSelectedPostId] = useState(null); + const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); + const [openedCutoffIso, setOpenedCutoffIso] = useState(null); + const [openedAnalysisRun, setOpenedAnalysisRun] = useState(null); + const [canRebuild, setCanRebuild] = useState(false); + ''', + "analysis run state", + ) + replace_once( + ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); + setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedFromReportMember(Boolean(options?.fromReportMember)); + ''', + ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); + setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedAnalysisRun(options?.analysisRun ?? null); + setOpenedFromReportMember(Boolean(options?.fromReportMember)); + ''', + "selectPost analysis run state", + ) + replace_once( + ''' setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + setOpenedFromReportMember(false); + ''', + ''' setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + setOpenedAnalysisRun(null); + setOpenedFromReportMember(false); + ''', + "closeSelectedPost analysis run state", + ) + replace_once( + ''' onSelectPost={(postId) => + selectPost(postId, { + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }) + } + ''', + ''' onSelectPost={(postId) => + selectPost(postId, { + ...(openedAnalysisRun + ? analysisRunPostOpenOptions(openedAnalysisRun, postId) + : {}), + fromReportMember: openedFromReportMember, + fromWeeklyVoc: openedFromWeeklyVoc, + fromCalendar: openedFromCalendar, + fromCustomerMaster: openedFromCustomerMaster, + fromAskAgent: openedFromAskAgent, + }) + } + ''', + "PostDetailPopup DAG callback", + ) + + path.write_text(text) + PY + + - name: Verify focused and full frontend contracts + working-directory: frontend + run: | + pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" + pnpm run lint + pnpm run test + pnpm run build + pnpm run build-storybook + + - name: Remove one-shot workflow and publish verified fix + run: | + rm .github/workflows/repair-analysis-run-dag-context-v2.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix: preserve analysis cutoff across DAG navigation" + git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From c23b81a769eeb7707111ac63ce43eb038416dedb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:43:44 -0700 Subject: [PATCH 06/63] ci: repair analysis-run cutoff navigation --- .github/workflows/fix-264-cutoff-context.yml | 218 +++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 .github/workflows/fix-264-cutoff-context.yml diff --git a/.github/workflows/fix-264-cutoff-context.yml b/.github/workflows/fix-264-cutoff-context.yml new file mode 100644 index 000000000..268f2b33f --- /dev/null +++ b/.github/workflows/fix-264-cutoff-context.yml @@ -0,0 +1,218 @@ +name: Repair PR 264 cutoff context + +on: + push: + branches: + - feat/event-lineage-node-keeps-gnb-focus-v2170 + paths: + - .github/workflows/fix-264-cutoff-context.yml + +permissions: + contents: write + +jobs: + red-green-fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: true + fetch-depth: 0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '24' + - name: Enable pnpm + run: corepack enable + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: RED - target-specific cutoff context is not implemented yet + shell: bash + run: | + cat > frontend/src/analysisRunNavigation.test.ts <<'EOF' + 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 cutoff flag", () => { + 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 while failing closed for an unknown target", () => { + expect(analysisRunTargetClock(context, "missing")).toEqual({ + liveAfterCutoff: false, + knowledgeCutoff: context.knowledgeCutoff, + }); + }); + }); + EOF + set +e + (cd frontend && pnpm exec vitest run src/analysisRunNavigation.test.ts) + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "RED unexpectedly passed; refusing to patch without a reproducing regression" >&2 + exit 1 + fi + - name: GREEN - preserve analysis-run context across DAG navigation + shell: bash + run: | + cat > frontend/src/analysisRunNavigation.ts <<'EOF' + export type AnalysisRunNavigationContext = { + knowledgeCutoff: string; + visiblePosts: Array<{ post_id: string; live_after_cutoff?: boolean }>; + }; + + 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, + }; + } + EOF + python - <<'PY' + from pathlib import Path + path = Path("frontend/src/App.tsx") + text = path.read_text() + + def replace_once(old: str, new: str) -> None: + global text + if text.count(old) != 1: + raise SystemExit(f"expected one match, found {text.count(old)} for {old[:80]!r}") + text = text.replace(old, new, 1) + + replace_once( + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' + 'import {\n' + ' analysisRunTargetClock,\n' + ' type AnalysisRunNavigationContext,\n' + '} from "./analysisRunNavigation";\n', + ) + replace_once( + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n', + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n' + ' analysisRunContext?: AnalysisRunNavigationContext;\n', + ) + replace_once( + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' + ' return {\n' + ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' };\n' + '}\n', + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const analysisRunContext: AnalysisRunNavigationContext = {\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' visiblePosts: run.visible_posts ?? [],\n' + ' };\n' + ' return {\n' + ' ...analysisRunTargetClock(analysisRunContext, postId),\n' + ' analysisRunContext,\n' + ' };\n' + '}\n', + ) + replace_once( + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' + ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' + ' useState(null);\n', + ) + replace_once( + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' + ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', + ) + replace_once( + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedFromReportMember(false);\n', + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedAnalysisRunContext(null);\n' + ' setOpenedFromReportMember(false);\n', + ) + replace_once( + ' onClick={() =>\n' + ' onSelectPost(post.post_id, {\n' + ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' + ' knowledgeCutoff: selected.knowledge_cutoff,\n' + ' })\n' + ' }\n', + ' onClick={() =>\n' + ' onSelectPost(\n' + ' post.post_id,\n' + ' analysisRunPostOpenOptions(selected, post.post_id),\n' + ' )\n' + ' }\n', + ) + replace_once( + ' onSelectPost={(postId) =>\n' + ' selectPost(postId, {\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' })\n' + ' }\n', + ' onSelectPost={(postId) => {\n' + ' const cutoffOptions = openedAnalysisRunContext\n' + ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' + ' : {};\n' + ' selectPost(postId, {\n' + ' ...cutoffOptions,\n' + ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' });\n' + ' }}\n', + ) + path.write_text(text) + PY + - name: Verify focused regression and frontend + working-directory: frontend + run: | + pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx + pnpm run lint + pnpm run build + - name: Commit validated fix and remove one-shot workflow + shell: bash + run: | + rm .github/workflows/fix-264-cutoff-context.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/src/App.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/fix-264-cutoff-context.yml + git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" + git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From b78b8c570db09ab7828e1c7ae596d1677eddba0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:44:17 -0700 Subject: [PATCH 07/63] chore: remove one-shot repair workflow --- .github/workflows/fix-264-cutoff-context.yml | 218 ------------------- 1 file changed, 218 deletions(-) delete mode 100644 .github/workflows/fix-264-cutoff-context.yml diff --git a/.github/workflows/fix-264-cutoff-context.yml b/.github/workflows/fix-264-cutoff-context.yml deleted file mode 100644 index 268f2b33f..000000000 --- a/.github/workflows/fix-264-cutoff-context.yml +++ /dev/null @@ -1,218 +0,0 @@ -name: Repair PR 264 cutoff context - -on: - push: - branches: - - feat/event-lineage-node-keeps-gnb-focus-v2170 - paths: - - .github/workflows/fix-264-cutoff-context.yml - -permissions: - contents: write - -jobs: - red-green-fix: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - persist-credentials: true - fetch-depth: 0 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 - with: - node-version: '24' - - name: Enable pnpm - run: corepack enable - - name: Install frontend dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - name: RED - target-specific cutoff context is not implemented yet - shell: bash - run: | - cat > frontend/src/analysisRunNavigation.test.ts <<'EOF' - 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 cutoff flag", () => { - 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 while failing closed for an unknown target", () => { - expect(analysisRunTargetClock(context, "missing")).toEqual({ - liveAfterCutoff: false, - knowledgeCutoff: context.knowledgeCutoff, - }); - }); - }); - EOF - set +e - (cd frontend && pnpm exec vitest run src/analysisRunNavigation.test.ts) - rc=$? - set -e - if [ "$rc" -eq 0 ]; then - echo "RED unexpectedly passed; refusing to patch without a reproducing regression" >&2 - exit 1 - fi - - name: GREEN - preserve analysis-run context across DAG navigation - shell: bash - run: | - cat > frontend/src/analysisRunNavigation.ts <<'EOF' - export type AnalysisRunNavigationContext = { - knowledgeCutoff: string; - visiblePosts: Array<{ post_id: string; live_after_cutoff?: boolean }>; - }; - - 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, - }; - } - EOF - python - <<'PY' - from pathlib import Path - path = Path("frontend/src/App.tsx") - text = path.read_text() - - def replace_once(old: str, new: str) -> None: - global text - if text.count(old) != 1: - raise SystemExit(f"expected one match, found {text.count(old)} for {old[:80]!r}") - text = text.replace(old, new, 1) - - replace_once( - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' - 'import {\n' - ' analysisRunTargetClock,\n' - ' type AnalysisRunNavigationContext,\n' - '} from "./analysisRunNavigation";\n', - ) - replace_once( - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n', - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n' - ' analysisRunContext?: AnalysisRunNavigationContext;\n', - ) - replace_once( - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' - ' return {\n' - ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' };\n' - '}\n', - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const analysisRunContext: AnalysisRunNavigationContext = {\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' visiblePosts: run.visible_posts ?? [],\n' - ' };\n' - ' return {\n' - ' ...analysisRunTargetClock(analysisRunContext, postId),\n' - ' analysisRunContext,\n' - ' };\n' - '}\n', - ) - replace_once( - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' - ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' - ' useState(null);\n', - ) - replace_once( - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' - ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', - ) - replace_once( - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedFromReportMember(false);\n', - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedAnalysisRunContext(null);\n' - ' setOpenedFromReportMember(false);\n', - ) - replace_once( - ' onClick={() =>\n' - ' onSelectPost(post.post_id, {\n' - ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' - ' knowledgeCutoff: selected.knowledge_cutoff,\n' - ' })\n' - ' }\n', - ' onClick={() =>\n' - ' onSelectPost(\n' - ' post.post_id,\n' - ' analysisRunPostOpenOptions(selected, post.post_id),\n' - ' )\n' - ' }\n', - ) - replace_once( - ' onSelectPost={(postId) =>\n' - ' selectPost(postId, {\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' })\n' - ' }\n', - ' onSelectPost={(postId) => {\n' - ' const cutoffOptions = openedAnalysisRunContext\n' - ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' - ' : {};\n' - ' selectPost(postId, {\n' - ' ...cutoffOptions,\n' - ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' });\n' - ' }}\n', - ) - path.write_text(text) - PY - - name: Verify focused regression and frontend - working-directory: frontend - run: | - pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx - pnpm run lint - pnpm run build - - name: Commit validated fix and remove one-shot workflow - shell: bash - run: | - rm .github/workflows/fix-264-cutoff-context.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/fix-264-cutoff-context.yml - git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From be0a544d115288e2d22de5e3207a2ce629d2de02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:45:54 -0700 Subject: [PATCH 08/63] chore: remove duplicate cutoff repair workflow --- .../repair-analysis-run-dag-context-v2.yml | 215 ------------------ 1 file changed, 215 deletions(-) delete mode 100644 .github/workflows/repair-analysis-run-dag-context-v2.yml diff --git a/.github/workflows/repair-analysis-run-dag-context-v2.yml b/.github/workflows/repair-analysis-run-dag-context-v2.yml deleted file mode 100644 index 02c6f5eda..000000000 --- a/.github/workflows/repair-analysis-run-dag-context-v2.yml +++ /dev/null @@ -1,215 +0,0 @@ -name: Repair analysis-run DAG cutoff context v2 - -on: - push: - branches: - - "feat/event-lineage-node-keeps-gnb-focus-v2170" - paths: - - ".github/workflows/repair-analysis-run-dag-context-v2.yml" - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: repair-analysis-run-dag-context-v2-${{ github.ref }} - cancel-in-progress: false - -jobs: - repair: - name: Preserve selected target cutoff context - runs-on: ubuntu-latest - steps: - - name: Checkout exact feature branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - fetch-depth: 0 - persist-credentials: true - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Enable Corepack and install locked frontend dependencies - run: | - corepack enable - cd frontend - pnpm install --frozen-lockfile - - - name: Add target-specific cutoff regression - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.test.tsx") - text = path.read_text() - old = ''' await userEvent.click(linkedPosts[linkedPosts.length - 1]); - await waitFor(() => - expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), - ); - expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - ''' - new = ''' await userEvent.click(linkedPosts[linkedPosts.length - 1]); - await waitFor(() => - expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), - ); - expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByLabelText("Open post: Public post")); - await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); - expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( - "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", - ); - expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); - expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); - ''' - if text.count(old) != 1: - raise SystemExit(f"test insertion point drifted: {text.count(old)} matches") - path.write_text(text.replace(old, new, 1)) - PY - - - name: Prove regression is red - working-directory: frontend - run: | - set +e - pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" > /tmp/red.log 2>&1 - status=$? - set -e - cat /tmp/red.log - if [ "$status" -eq 0 ]; then - echo "Expected DAG cutoff regression to fail before implementation." >&2 - exit 1 - fi - grep -F "Live body warning" /tmp/red.log - - - name: Preserve analysis-run context through DAG navigation - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.tsx") - text = path.read_text() - - def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, found {count}") - text = text.replace(old, new, 1) - - replace_once( - '''type SelectPostOptions = { - liveAfterCutoff?: boolean; - knowledgeCutoff?: string; - fromReportMember?: boolean; - ''', - '''type SelectPostOptions = { - liveAfterCutoff?: boolean; - knowledgeCutoff?: string; - analysisRun?: AnalysisRun; - fromReportMember?: boolean; - ''', - "SelectPostOptions", - ) - replace_once( - ''' return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), - knowledgeCutoff: run.knowledge_cutoff, - }; - ''', - ''' return { - liveAfterCutoff: Boolean(post?.live_after_cutoff), - knowledgeCutoff: run.knowledge_cutoff, - analysisRun: run, - }; - ''', - "analysisRunPostOpenOptions", - ) - replace_once( - ''' const [selectedPostId, setSelectedPostId] = useState(null); - const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); - const [openedCutoffIso, setOpenedCutoffIso] = useState(null); - const [canRebuild, setCanRebuild] = useState(false); - ''', - ''' const [selectedPostId, setSelectedPostId] = useState(null); - const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); - const [openedCutoffIso, setOpenedCutoffIso] = useState(null); - const [openedAnalysisRun, setOpenedAnalysisRun] = useState(null); - const [canRebuild, setCanRebuild] = useState(false); - ''', - "analysis run state", - ) - replace_once( - ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); - setOpenedCutoffIso(options?.knowledgeCutoff ?? null); - setOpenedFromReportMember(Boolean(options?.fromReportMember)); - ''', - ''' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); - setOpenedCutoffIso(options?.knowledgeCutoff ?? null); - setOpenedAnalysisRun(options?.analysisRun ?? null); - setOpenedFromReportMember(Boolean(options?.fromReportMember)); - ''', - "selectPost analysis run state", - ) - replace_once( - ''' setOpenedAfterCutoff(false); - setOpenedCutoffIso(null); - setOpenedFromReportMember(false); - ''', - ''' setOpenedAfterCutoff(false); - setOpenedCutoffIso(null); - setOpenedAnalysisRun(null); - setOpenedFromReportMember(false); - ''', - "closeSelectedPost analysis run state", - ) - replace_once( - ''' onSelectPost={(postId) => - selectPost(postId, { - fromReportMember: openedFromReportMember, - fromWeeklyVoc: openedFromWeeklyVoc, - fromCalendar: openedFromCalendar, - fromCustomerMaster: openedFromCustomerMaster, - fromAskAgent: openedFromAskAgent, - }) - } - ''', - ''' onSelectPost={(postId) => - selectPost(postId, { - ...(openedAnalysisRun - ? analysisRunPostOpenOptions(openedAnalysisRun, postId) - : {}), - fromReportMember: openedFromReportMember, - fromWeeklyVoc: openedFromWeeklyVoc, - fromCalendar: openedFromCalendar, - fromCustomerMaster: openedFromCustomerMaster, - fromAskAgent: openedFromAskAgent, - }) - } - ''', - "PostDetailPopup DAG callback", - ) - - path.write_text(text) - PY - - - name: Verify focused and full frontend contracts - working-directory: frontend - run: | - pnpm exec vitest run src/App.test.tsx -t "warns that a cutoff-rewritten title opens the live body, not a snapshot" - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - - name: Remove one-shot workflow and publish verified fix - run: | - rm .github/workflows/repair-analysis-run-dag-context-v2.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix: preserve analysis cutoff across DAG navigation" - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From aad290b0bde2d9c370a59a5b883552895c8ec13f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:46:32 -0700 Subject: [PATCH 09/63] ci: execute PR 264 cutoff repair --- .github/workflows/fix-264-cutoff-context.yml | 230 +++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 .github/workflows/fix-264-cutoff-context.yml diff --git a/.github/workflows/fix-264-cutoff-context.yml b/.github/workflows/fix-264-cutoff-context.yml new file mode 100644 index 000000000..592f2ad9a --- /dev/null +++ b/.github/workflows/fix-264-cutoff-context.yml @@ -0,0 +1,230 @@ +name: Repair PR 264 cutoff context + +on: + pull_request: + types: [synchronize] + +permissions: + contents: write + +jobs: + red-green-fix: + if: github.event.pull_request.number == 264 && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + persist-credentials: true + fetch-depth: 0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: '24' + - name: Enable Corepack + run: corepack enable + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: RED - selected DAG target needs its own cutoff clock + shell: bash + run: | + cat > frontend/src/analysisRunNavigation.test.ts <<'EOF' + 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, + }); + }); + }); + EOF + set +e + (cd frontend && pnpm exec vitest run src/analysisRunNavigation.test.ts) > /tmp/red.log 2>&1 + rc=$? + set -e + cat /tmp/red.log + if [ "$rc" -eq 0 ]; then + echo "RED unexpectedly passed; refusing to patch without reproducing the missing contract" >&2 + exit 1 + fi + grep -Eq 'analysisRunNavigation|Cannot find|Failed to resolve|ENOENT' /tmp/red.log + - name: GREEN - preserve run context and resolve target-specific clock + shell: bash + run: | + cat > frontend/src/analysisRunNavigation.ts <<'EOF' + /** 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, + }; + } + EOF + python - <<'PY' + from pathlib import Path + path = Path("frontend/src/App.tsx") + text = path.read_text() + + def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, got {count}") + text = text.replace(old, new, 1) + + replace_once( + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' + 'import {\n' + ' analysisRunTargetClock,\n' + ' type AnalysisRunNavigationContext,\n' + '} from "./analysisRunNavigation";\n', + "navigation import", + ) + replace_once( + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n', + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n' + ' analysisRunContext?: AnalysisRunNavigationContext;\n', + "select options", + ) + replace_once( + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' + ' return {\n' + ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' };\n' + '}\n', + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const analysisRunContext: AnalysisRunNavigationContext = {\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' visiblePosts: run.visible_posts ?? [],\n' + ' };\n' + ' return {\n' + ' ...analysisRunTargetClock(analysisRunContext, postId),\n' + ' analysisRunContext,\n' + ' };\n' + '}\n', + "analysis run open options", + ) + replace_once( + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' + ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' + ' useState(null);\n', + "analysis run state", + ) + replace_once( + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' + ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', + "select state", + ) + replace_once( + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedFromReportMember(false);\n', + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedAnalysisRunContext(null);\n' + ' setOpenedFromReportMember(false);\n', + "close state", + ) + replace_once( + ' onClick={() =>\n' + ' onSelectPost(post.post_id, {\n' + ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' + ' knowledgeCutoff: selected.knowledge_cutoff,\n' + ' })\n' + ' }\n', + ' onClick={() =>\n' + ' onSelectPost(\n' + ' post.post_id,\n' + ' analysisRunPostOpenOptions(selected, post.post_id),\n' + ' )\n' + ' }\n', + "visible post open", + ) + replace_once( + ' onSelectPost={(postId) =>\n' + ' selectPost(postId, {\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' })\n' + ' }\n', + ' onSelectPost={(postId) => {\n' + ' const cutoffOptions = openedAnalysisRunContext\n' + ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' + ' : {};\n' + ' selectPost(postId, {\n' + ' ...cutoffOptions,\n' + ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' });\n' + ' }}\n', + "popup navigation", + ) + path.write_text(text) + PY + - name: Verify GREEN and full frontend contract + working-directory: frontend + run: | + pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx + pnpm run lint + pnpm run build + - name: Commit only the validated repair + shell: bash + run: | + rm .github/workflows/fix-264-cutoff-context.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/src/App.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/fix-264-cutoff-context.yml + git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" + git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From 8cf34912eded988d9f08c5995324613abfaa777a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:04:30 -0700 Subject: [PATCH 10/63] ci: run PR 264 cutoff repair on branch push --- .github/workflows/fix-264-cutoff-context.yml | 53 ++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/fix-264-cutoff-context.yml b/.github/workflows/fix-264-cutoff-context.yml index 592f2ad9a..43666db7f 100644 --- a/.github/workflows/fix-264-cutoff-context.yml +++ b/.github/workflows/fix-264-cutoff-context.yml @@ -1,15 +1,21 @@ name: Repair PR 264 cutoff context on: - pull_request: - types: [synchronize] + push: + branches: + - feat/event-lineage-node-keeps-gnb-focus-v2170 + workflow_dispatch: permissions: contents: write +concurrency: + group: repair-pr-264-cutoff-context + cancel-in-progress: false + jobs: red-green-fix: - if: github.event.pull_request.number == 264 && github.event.pull_request.head.repo.full_name == github.repository + if: github.ref == 'refs/heads/feat/event-lineage-node-keeps-gnb-focus-v2170' runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 @@ -94,6 +100,7 @@ jobs: EOF python - <<'PY' from pathlib import Path + path = Path("frontend/src/App.tsx") text = path.read_text() @@ -212,19 +219,57 @@ jobs: "popup navigation", ) path.write_text(text) + + test_path = Path("frontend/src/App.test.tsx") + test_text = test_path.read_text() + old_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); +''' + new_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); + + const publicPosts = screen.getAllByLabelText("Open post: Public post"); + await userEvent.click(publicPosts[publicPosts.length - 1]); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); + expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); +''' + count = test_text.count(old_test) + if count != 1: + raise SystemExit(f"cutoff navigation regression: expected exactly one match, got {count}") + test_path.write_text(test_text.replace(old_test, new_test, 1)) PY - name: Verify GREEN and full frontend contract working-directory: frontend run: | pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx pnpm run lint + pnpm run test pnpm run build + pnpm run build-storybook - name: Commit only the validated repair shell: bash run: | rm .github/workflows/fix-264-cutoff-context.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/fix-264-cutoff-context.yml + git add frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/fix-264-cutoff-context.yml + git diff --cached --check git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From 4e4dae5c6d329fcedbb67dbfcfa5c8d7e1e3cefb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:05:40 -0700 Subject: [PATCH 11/63] ci: repair ontology-grounded semantic paths on PR 264 --- .../fix-264-ontology-semantic-path.yml | 765 ++++++++++++++++++ 1 file changed, 765 insertions(+) create mode 100644 .github/workflows/fix-264-ontology-semantic-path.yml diff --git a/.github/workflows/fix-264-ontology-semantic-path.yml b/.github/workflows/fix-264-ontology-semantic-path.yml new file mode 100644 index 000000000..14fb0025f --- /dev/null +++ b/.github/workflows/fix-264-ontology-semantic-path.yml @@ -0,0 +1,765 @@ +name: Repair PR 264 ontology semantic path + +on: + pull_request: + types: [synchronize] + +permissions: + contents: write + +jobs: + red-green-fix: + if: github.event.pull_request.number == 264 && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 + steps: + - name: Checkout PR branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + persist-credentials: true + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install locked dependencies + run: | + corepack enable + uv sync --frozen --extra dev --extra backend + pnpm --dir frontend install --frozen-lockfile + + - name: Write RED regressions + run: | + cat > tests/test_semantic_relationship_paths.py <<'PY' + """Regressions for ontology-aligned, buyer-visible KG relationship paths.""" + + from rdflib.namespace import OWL, RDFS + + from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_CO_MENTION, + EDGE_MENTION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + KnowledgeGraphEdgeSpec, + node_key, + semantic_paths_from_edges, + ) + from lineageweave.ontology import LW, load_ontology, relationship_annotations + + + def test_person_mention_ontology_matches_canonical_edge_direction() -> None: + """The relational edge is Person -> Post, so OWL must say the same.""" + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph + assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph + assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph + + + def test_relationship_annotations_select_the_inverse_buyer_label() -> None: + assert relationship_annotations(EDGE_MENTION) == { + "relationship_iri": str(LW.mentions), + "relationship_label": "mentioned in post", + } + assert relationship_annotations(EDGE_MENTION, reverse=True) == { + "relationship_iri": str(LW.postMentionsPerson), + "relationship_label": "mentions person", + } + assert relationship_annotations(EDGE_CO_MENTION, reverse=True) == { + "relationship_iri": str(LW.coMentionedWith), + "relationship_label": "co-mentioned with", + } + assert relationship_annotations("not_a_real_lookup_code") == {} + + + def test_semantic_paths_are_shortest_directional_and_deterministic() -> None: + edges = [ + KnowledgeGraphEdgeSpec( + NODE_PERSON, + "person-a", + NODE_POST, + "post-1", + EDGE_MENTION, + ), + KnowledgeGraphEdgeSpec( + NODE_PERSON, + "person-b", + NODE_POST, + "post-1", + EDGE_MENTION, + ), + KnowledgeGraphEdgeSpec( + NODE_PERSON, + "person-a", + NODE_CORPORATE_ENTITY, + "corp-1", + EDGE_AFFILIATION, + ), + ] + start = node_key(NODE_PERSON, "person-a") + paths = semantic_paths_from_edges(list(reversed(edges)), start) + + post_path = paths[node_key(NODE_POST, "post-1")] + assert [(hop.edge_type_code, hop.traversal_direction) for hop in post_path] == [ + (EDGE_MENTION, "forward") + ] + + other_person_path = paths[node_key(NODE_PERSON, "person-b")] + assert [(hop.edge_type_code, hop.traversal_direction) for hop in other_person_path] == [ + (EDGE_MENTION, "forward"), + (EDGE_MENTION, "reverse"), + ] + assert paths[node_key(NODE_CORPORATE_ENTITY, "corp-1")][0].edge_type_code == EDGE_AFFILIATION + PY + + cat > frontend/src/components/RelatedSemanticPath.test.tsx <<'TS' + import { render, screen } from "@testing-library/react"; + import { describe, expect, it } from "vitest"; + import { RelatedSemanticPath, semanticPathText } from "./RelatedSemanticPath"; + + describe("RelatedSemanticPath", () => { + it("renders the ontology relationship path instead of a generic graph label", () => { + const semanticPath = [ + { + from_node_type_code: "node_person", + to_node_type_code: "node_post", + edge_type_code: "edge_mention", + traversal_direction: "forward" as const, + relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", + relationship_label: "mentioned in post", + }, + { + from_node_type_code: "node_post", + to_node_type_code: "node_person", + edge_type_code: "edge_mention", + traversal_direction: "reverse" as const, + relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#postMentionsPerson", + relationship_label: "mentions person", + }, + ]; + render(); + expect(screen.getByText("mentioned in post → mentions person")).toBeInTheDocument(); + expect(semanticPathText(undefined)).toBe("Graph relation"); + }); + }); + TS + + - name: Prove RED failures + run: | + set +e + uv run --frozen python -m pytest -q tests/test_semantic_relationship_paths.py + py_status=$? + pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx + ui_status=$? + set -e + if [ "$py_status" -eq 0 ] || [ "$ui_status" -eq 0 ]; then + echo "A RED regression unexpectedly passed before implementation." >&2 + exit 1 + fi + + - name: Write idempotent repair program + run: | + cat > "$RUNNER_TEMP/apply_semantic_patch.py" <<'PY' + from pathlib import Path + + + def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + + def write(path: str, content: str) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + + def replace_once(path: str, old: str, new: str) -> None: + text = read(path) + if new in text: + return + if old not in text: + raise SystemExit(f"repair anchor missing in {path}: {old[:80]!r}") + write(path, text.replace(old, new, 1)) + + + ttl = "docs/ontology/lineageweave-kg.ttl" + replace_once( + ttl, + ''':mentions a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Person ; + rdfs:label "mentions" ; + rdfs:comment "A post names a person (post_person_mention)." ; + :lookupCode "edge_mention" . + ''', + ''':mentions a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "Canonical knowledge_graph_edge direction for post_person_mention: the cataloged person points to the evidence post." ; + owl:inverseOf :postMentionsPerson ; + :lookupCode "edge_mention" . + + :postMentionsPerson a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Person ; + rdfs:label "mentions person" ; + rdfs:comment "Buyer-facing inverse of :mentions: a post names a cataloged person." ; + owl:inverseOf :mentions . + ''', + ) + replace_once( + ttl, + ''':affiliatedWith a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :CorporateEntity ; + rdfs:label "affiliated with" ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; + :lookupCode "edge_affiliation" . + ''', + ''':affiliatedWith a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :CorporateEntity ; + rdfs:label "affiliated with" ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; + owl:inverseOf :hasAffiliatedPerson ; + :lookupCode "edge_affiliation" . + + :hasAffiliatedPerson a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Person ; + rdfs:label "has affiliated person" ; + owl:inverseOf :affiliatedWith . + ''', + ) + replace_once( + ttl, + ''':mentionsTeam a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; + :lookupCode "edge_mention_team" . + ''', + ''':mentionsTeam a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; + owl:inverseOf :postMentionsTeam ; + :lookupCode "edge_mention_team" . + + :postMentionsTeam a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Team ; + rdfs:label "mentions team" ; + owl:inverseOf :mentionsTeam . + ''', + ) + replace_once( + ttl, + ''':teamAffiliatedWith a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :CorporateEntity ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + :lookupCode "edge_team_affiliation" . + ''', + ''':teamAffiliatedWith a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :CorporateEntity ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + owl:inverseOf :hasAffiliatedTeam ; + :lookupCode "edge_team_affiliation" . + + :hasAffiliatedTeam a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Team ; + rdfs:label "has affiliated team" ; + owl:inverseOf :teamAffiliatedWith . + ''', + ) + replace_once( + ttl, + ''':mentionsOrganization a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; + :lookupCode "edge_mention_organization" . + ''', + ''':mentionsOrganization a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; + owl:inverseOf :postMentionsOrganization ; + :lookupCode "edge_mention_organization" . + + :postMentionsOrganization a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:label "mentions organization" ; + owl:inverseOf :mentionsOrganization . + ''', + ) + + ontology = "lineageweave/ontology.py" + replace_once( + ontology, + '''def all_declared_lookup_codes() -> set[str]: + ''', + '''def relationship_annotations(lookup_code: str, *, reverse: bool = False) -> dict[str, str]: + """Return the OWL relationship IRI and buyer-facing label. + + ``knowledge_graph_edge`` stores one canonical direction. When a + graph walk traverses that edge in reverse, the declared + ``owl:inverseOf`` property supplies the correct semantic label. + Symmetric properties retain their own IRI and label. Missing + ontology terms stay missing rather than receiving guessed copy. + """ + subject = _term_subject(lookup_code) + if subject is None: + return {} + relationship = subject + if reverse and (subject, RDF.type, OWL.SymmetricProperty) not in ONTOLOGY: + relationship = ONTOLOGY.value(subject, OWL.inverseOf) + if relationship is None: + relationship = next(ONTOLOGY.subjects(OWL.inverseOf, subject), None) + if relationship is None: + relationship = subject + fields = {"relationship_iri": str(relationship)} + label = ONTOLOGY.value(relationship, RDFS.label) + if label is not None: + fields["relationship_label"] = str(label) + return fields + + + def all_declared_lookup_codes() -> set[str]: + ''', + ) + replace_once( + ontology, + ''' "ontology_annotations", + ] + ''', + ''' "ontology_annotations", + "relationship_annotations", + ] + ''', + ) + + graph = "lineageweave/knowledge_graph.py" + replace_once(graph, "from collections import defaultdict\n", "from collections import defaultdict, deque\n") + replace_once(graph, "from typing import Sequence\n", "from typing import Literal, Sequence\n") + replace_once( + graph, + '''def node_key(node_type_code: str, node_id: str) -> str: + ''', + '''@dataclass(frozen=True) + class KnowledgeGraphPathHop: + """One ontology-bearing traversal step through a stored KG edge.""" + + from_node_type_code: str + from_node_id: str + edge_type_code: str + to_node_type_code: str + to_node_id: str + traversal_direction: Literal["forward", "reverse"] + edge_weight: float = 1.0 + + + def node_key(node_type_code: str, node_id: str) -> str: + ''', + ) + replace_once( + graph, + '''def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: + ''', + '''def semantic_paths_from_edges( + edges: Sequence[KnowledgeGraphEdgeSpec], + start_node: str, + ) -> dict[str, tuple[KnowledgeGraphPathHop, ...]]: + """Deterministic shortest semantic path from ``start_node``. + + RWR determines *which* nodes are relevant. This breadth-first + projection explains *why* each reachable node is connected, + using the same positive evidence-gated edges. Ties prefer higher + weight, then stable edge/type/id ordering; the path is an + explanation of stored relationships, not a causal claim. + """ + incident: dict[str, list[tuple[str, KnowledgeGraphPathHop]]] = defaultdict(list) + for edge in edges: + weight = float(edge.edge_weight) + if not (weight > 0 and weight == weight): + continue + source = node_key(edge.source_node_type_code, edge.source_node_id) + target = node_key(edge.target_node_type_code, edge.target_node_id) + incident[source].append( + ( + target, + KnowledgeGraphPathHop( + edge.source_node_type_code, + edge.source_node_id, + edge.edge_type_code, + edge.target_node_type_code, + edge.target_node_id, + "forward", + weight, + ), + ) + ) + incident[target].append( + ( + source, + KnowledgeGraphPathHop( + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.source_node_type_code, + edge.source_node_id, + "reverse", + weight, + ), + ) + ) + for hops in incident.values(): + hops.sort( + key=lambda item: ( + -item[1].edge_weight, + item[1].edge_type_code, + item[1].to_node_type_code, + item[1].to_node_id, + item[1].traversal_direction, + ) + ) + + paths: dict[str, tuple[KnowledgeGraphPathHop, ...]] = {start_node: ()} + pending: deque[str] = deque([start_node]) + while pending: + current = pending.popleft() + for neighbor, hop in incident.get(current, []): + if neighbor in paths: + continue + paths[neighbor] = (*paths[current], hop) + pending.append(neighbor) + return paths + + + def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: + ''', + ) + + backend = "backend/app/knowledge_graph.py" + replace_once( + backend, + "from lineageweave.ontology import ontology_annotations\n", + "from lineageweave.ontology import ontology_annotations, relationship_annotations\n", + ) + replace_once( + backend, + ''' random_walk_with_restart, + select_related_nodes, + ) + ''', + ''' random_walk_with_restart, + select_related_nodes, + semantic_paths_from_edges, + ) + ''', + ) + replace_once( + backend, + ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) + related = select_related_nodes(scores, start_node=start) + return await hydrate_related_nodes(conn, related) + ''', + ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) + related = select_related_nodes(scores, start_node=start) + semantic_paths = semantic_paths_from_edges(edges, start) + payload = await hydrate_related_nodes(conn, related) + for item in payload: + related_key = node_key(item["node_type_code"], item["node_id"]) + item["semantic_path"] = [ + { + "from_node_type_code": hop.from_node_type_code, + "to_node_type_code": hop.to_node_type_code, + "edge_type_code": hop.edge_type_code, + "traversal_direction": hop.traversal_direction, + **relationship_annotations( + hop.edge_type_code, + reverse=hop.traversal_direction == "reverse", + ), + } + for hop in semantic_paths.get(related_key, ()) + ] + return payload + ''', + ) + + api = "frontend/src/api.ts" + replace_once( + api, + '''export interface RelatedNode { + node_id: string; + node_type_code: RelatedNodeType | string; + relevance: number; + label?: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + person_side_code?: string; + person_side_label?: string; + ontology_iri?: string; + ontology_label?: string; + } + ''', + '''export interface SemanticPathHop { + from_node_type_code: RelatedNodeType | string; + to_node_type_code: RelatedNodeType | string; + edge_type_code: string; + traversal_direction: "forward" | "reverse"; + relationship_iri?: string; + relationship_label?: string; + } + + export interface RelatedNode { + node_id: string; + node_type_code: RelatedNodeType | string; + relevance: number; + label?: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + person_side_code?: string; + person_side_label?: string; + ontology_iri?: string; + ontology_label?: string; + semantic_path?: SemanticPathHop[]; + } + ''', + ) + + component = '''import type { SemanticPathHop } from "../api"; + import { t } from "../i18n"; + + export function semanticPathText(semanticPath?: SemanticPathHop[]): string { + const labels = (semanticPath ?? []) + .map((hop) => hop.relationship_label ?? hop.edge_type_code) + .filter((label) => label.length > 0); + return labels.length > 0 ? labels.join(" → ") : t("Graph relation"); + } + + export function RelatedSemanticPath({ semanticPath }: { semanticPath?: SemanticPathHop[] }) { + const text = semanticPathText(semanticPath); + return ( + + {text} + + ); + } + ''' + write("frontend/src/components/RelatedSemanticPath.tsx", component) + + app = "frontend/src/App.tsx" + replace_once( + app, + 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', + 'import { PopupCloseButton } from "./components/PopupCloseButton";\nimport { RelatedSemanticPath } from "./components/RelatedSemanticPath";\n', + ) + replace_once( + app, + '{t("Graph relation")}', + '', + ) + + test_ontology = "tests/test_ontology.py" + replace_once( + test_ontology, + "from rdflib.namespace import RDFS, SKOS\n", + "from rdflib.namespace import OWL, RDFS, SKOS\n", + ) + replace_once( + test_ontology, + '''def test_mentions_property_domain_and_range_match_the_schema() -> None: + """`mentions` goes Post -> Person, matching post_person_mention's + actual foreign keys -- not just any two classes.""" + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Post) in graph + assert (LW.mentions, RDFS.range, LW.Person) in graph + ''', + '''def test_mentions_property_domain_and_range_match_canonical_edge_direction() -> None: + """``edge_mention`` is stored Person -> Post; OWL and its inverse agree.""" + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph + assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph + assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph + ''', + ) + + projection_test = "tests/test_person_mention_projection.py" + replace_once( + projection_test, + ''' assert first_post_id in related_ids + assert second_post_id in related_ids + hydrated = await hydrate_related_nodes( + ''', + ''' assert first_post_id in related_ids + assert second_post_id in related_ids + related_by_id = {node["node_id"]: node for node in related} + assert related_by_id[first_post_id]["semantic_path"] == [ + { + "from_node_type_code": NODE_TEAM, + "to_node_type_code": NODE_POST, + "edge_type_code": EDGE_MENTION_TEAM, + "traversal_direction": "forward", + "relationship_iri": "https://contextualwisdomlab.github.io/lineageweave/ontology#mentionsTeam", + "relationship_label": "mentioned in post", + } + ] + hydrated = await hydrate_related_nodes( + ''', + ) + + write( + "docs/adr/0076-ontology-grounded-semantic-relationship-path.md", + '''# ADR 0076: Ontology-grounded semantic relationship paths + + ## Status + + Accepted + + ## Context + + The buyer related-node surface ranked evidence-gated knowledge-graph nodes with RWR, but returned only each node class and a numeric relevance score. The relationship predicates traversed from the selected person, team, organization, or post disappeared, so the UI could only say `Graph relation`. The ontology also declared `edge_mention` as `Post -> Person`, while the canonical relational projection stores `Person -> Post`. + + ## Decision + + - Keep the existing canonical `knowledge_graph_edge` direction. + - Correct the OWL domain/range for `edge_mention` to `Person -> Post` and declare explicit `owl:inverseOf` properties for buyer-readable reverse traversal. + - Compute a deterministic shortest semantic path over the same positive, ABAC/evidence-gated subgraph used by RWR. RWR still determines relevance; the path only explains the stored connectivity and is not a causal claim. + - Return each hop's lookup code, traversal direction, ontology IRI, and ontology label. Unknown ontology terms remain absent rather than receiving guessed labels. + - Render the path on related-post cards so the buyer can act on the relationship instead of seeing generic graph copy. + + ## Consequences + + The API remains additive. Existing consumers may ignore `semantic_path`. The relational database remains the system of record; OWL supplies controlled semantics and inverse labels. A later RDF 1.2 export may annotate individual edge assertions with provenance, but this slice does not claim RDF 1.2 conformance or create unsupported facts. + ''', + ) + write( + "docs/doctoring/SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md", + '''# Semantic relationship path references + + ## Product traceability + + - `knowledge_graph_edge` is interpreted as a typed subject-predicate-object assertion whose canonical direction must match the OWL property's domain and range. + - `owl:inverseOf` provides the relationship used when the buyer traverses a stored assertion in reverse. + - `knowledge_graph_edge_evidence` continues to gate the visible subgraph; the semantic path never bypasses ABAC or invents provenance. + - RDF 1.2 triple terms and annotations are tracked for a future interoperable statement-provenance export; this release keeps PostgreSQL as the authority. + + ## APA 7th references + + World Wide Web Consortium. (2012). *OWL 2 Web Ontology Language: Document overview (Second Edition).* https://www.w3.org/TR/owl2-overview/ + + World Wide Web Consortium. (2013). *PROV-O: The PROV ontology.* https://www.w3.org/TR/prov-o/ + + World Wide Web Consortium. (2026). *RDF 1.2 concepts and abstract data model* (Candidate Recommendation Snapshot, April 7, 2026). https://www.w3.org/TR/rdf12-concepts/ + ''', + ) + write( + "CHANGELOG.d/2.17.0-ontology-semantic-path.md", + '''### Fixed + + - Aligned `edge_mention` OWL domain/range with the canonical Person-to-Post graph direction and added explicit inverse relationship terms. + - Related knowledge-graph nodes now carry and render the deterministic ontology relationship path that explains why each buyer-visible node is connected. + ''', + ) + PY + + - name: Apply ontology and buyer-surface repair + run: python "$RUNNER_TEMP/apply_semantic_patch.py" + + - name: Run focused GREEN verification + run: | + uv run --frozen python -m pytest -q \ + tests/test_semantic_relationship_paths.py \ + tests/test_ontology.py \ + tests/test_knowledge_graph.py \ + tests/test_person_mention_projection.py + pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx + + - name: Run full Python verification + run: uv run --frozen python -m pytest -q + + - name: Run full frontend verification + run: | + pnpm --dir frontend run lint + pnpm --dir frontend run test + pnpm --dir frontend run build + pnpm --dir frontend run build-storybook + + - name: Commit and push without overwriting concurrent fixes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + commit_current_tree() { + rm -f .github/workflows/fix-264-ontology-semantic-path.yml + git add -A + if git diff --cached --quiet; then + return 0 + fi + git commit -m "fix: expose ontology-grounded semantic relationship paths" + } + + commit_current_tree + for attempt in 1 2 3; do + if git push origin "HEAD:${REPAIR_BRANCH}"; then + exit 0 + fi + echo "Concurrent branch update detected; replaying the idempotent repair (attempt ${attempt})." + git fetch origin "${REPAIR_BRANCH}" + git reset --hard "origin/${REPAIR_BRANCH}" + python "$RUNNER_TEMP/apply_semantic_patch.py" + uv run --frozen python -m pytest -q \ + tests/test_semantic_relationship_paths.py \ + tests/test_ontology.py \ + tests/test_knowledge_graph.py \ + tests/test_person_mention_projection.py + pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx + commit_current_tree + done + echo "Could not push after bounded concurrent-update retries." >&2 + exit 1 From 1b73e6621621bb1554bed3c8e01bee5e18b5331f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:06:07 -0700 Subject: [PATCH 12/63] ci: run cutoff repair through canonical tests workflow --- .github/workflows/tests.yml | 277 ++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cad1f17c..29a009fd5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,283 @@ concurrency: cancel-in-progress: true jobs: + repair_analysis_run_cutoff_context: + name: Repair target-specific cutoff context + if: >- + github.event_name == 'pull_request' && + github.head_ref == 'feat/event-lineage-node-keeps-gnb-focus-v2170' + runs-on: ubuntu-latest + permissions: + contents: write + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + steps: + - name: Checkout pull request branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Enable Corepack + run: corepack enable + + - name: Install locked frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: RED - selected DAG target needs its own cutoff clock + shell: bash + run: | + cat > frontend/src/analysisRunNavigation.test.ts <<'EOF' + 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, + }); + }); + }); + EOF + set +e + pnpm --dir frontend exec vitest run src/analysisRunNavigation.test.ts > /tmp/red.log 2>&1 + rc=$? + set -e + cat /tmp/red.log + if [ "$rc" -eq 0 ]; then + echo "RED unexpectedly passed; refusing to patch without reproducing the missing contract" >&2 + exit 1 + fi + grep -Eq 'analysisRunNavigation|Cannot find|Failed to resolve|ENOENT' /tmp/red.log + + - name: GREEN - preserve run context and resolve target-specific clock + shell: bash + run: | + cat > frontend/src/analysisRunNavigation.ts <<'EOF' + /** 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, + }; + } + EOF + python - <<'PY' + from pathlib import Path + + path = Path("frontend/src/App.tsx") + text = path.read_text() + + def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one match, got {count}") + text = text.replace(old, new, 1) + + replace_once( + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' + 'import {\n' + ' analysisRunTargetClock,\n' + ' type AnalysisRunNavigationContext,\n' + '} from "./analysisRunNavigation";\n', + "navigation import", + ) + replace_once( + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n', + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n' + ' analysisRunContext?: AnalysisRunNavigationContext;\n', + "select options", + ) + replace_once( + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' + ' return {\n' + ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' };\n' + '}\n', + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const analysisRunContext: AnalysisRunNavigationContext = {\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' visiblePosts: run.visible_posts ?? [],\n' + ' };\n' + ' return {\n' + ' ...analysisRunTargetClock(analysisRunContext, postId),\n' + ' analysisRunContext,\n' + ' };\n' + '}\n', + "analysis run open options", + ) + replace_once( + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' + ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' + ' useState(null);\n', + "analysis run state", + ) + replace_once( + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' + ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', + "select state", + ) + replace_once( + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedFromReportMember(false);\n', + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedAnalysisRunContext(null);\n' + ' setOpenedFromReportMember(false);\n', + "close state", + ) + replace_once( + ' onClick={() =>\n' + ' onSelectPost(post.post_id, {\n' + ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' + ' knowledgeCutoff: selected.knowledge_cutoff,\n' + ' })\n' + ' }\n', + ' onClick={() =>\n' + ' onSelectPost(\n' + ' post.post_id,\n' + ' analysisRunPostOpenOptions(selected, post.post_id),\n' + ' )\n' + ' }\n', + "visible post open", + ) + replace_once( + ' onSelectPost={(postId) =>\n' + ' selectPost(postId, {\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' })\n' + ' }\n', + ' onSelectPost={(postId) => {\n' + ' const cutoffOptions = openedAnalysisRunContext\n' + ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' + ' : {};\n' + ' selectPost(postId, {\n' + ' ...cutoffOptions,\n' + ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' });\n' + ' }}\n', + "popup navigation", + ) + path.write_text(text) + + test_path = Path("frontend/src/App.test.tsx") + test_text = test_path.read_text() + old_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); +''' + new_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); + + const publicPosts = screen.getAllByLabelText("Open post: Public post"); + await userEvent.click(publicPosts[publicPosts.length - 1]); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); + expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); +''' + count = test_text.count(old_test) + if count != 1: + raise SystemExit(f"cutoff navigation regression: expected exactly one match, got {count}") + test_path.write_text(test_text.replace(old_test, new_test, 1)) + PY + + - name: Verify GREEN and full frontend contract + working-directory: frontend + run: | + pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx + pnpm run lint + pnpm run test + pnpm run build + pnpm run build-storybook + + - name: Restore canonical workflows and publish the fix + shell: bash + run: | + git show "${BASE_SHA}:.github/workflows/tests.yml" > .github/workflows/tests.yml + rm -f .github/workflows/fix-264-cutoff-context.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/tests.yml .github/workflows/fix-264-cutoff-context.yml + git diff --cached --check + git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" + git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 + pytest: name: Full test suite runs-on: ubuntu-latest From 796af572127bd03c8f045dd76b935a804716bd5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:10:56 -0700 Subject: [PATCH 13/63] ci: stage deterministic PR 264 cutoff repair --- .github/scripts/fix_pr264_cutoff.py | 254 ++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 .github/scripts/fix_pr264_cutoff.py diff --git a/.github/scripts/fix_pr264_cutoff.py b/.github/scripts/fix_pr264_cutoff.py new file mode 100644 index 000000000..2efcb61dd --- /dev/null +++ b/.github/scripts/fix_pr264_cutoff.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Apply the test-first PR 264 analysis-run cutoff navigation repair.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FRONTEND = ROOT / "frontend" / "src" +HELPER = FRONTEND / "analysisRunNavigation.ts" +HELPER_TEST = FRONTEND / "analysisRunNavigation.test.ts" +APP = FRONTEND / "App.tsx" +APP_TEST = FRONTEND / "App.test.tsx" + +HELPER_TEST_CONTENT = '''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, + }); + }); +}); +''' + +HELPER_CONTENT = '''/** 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, + }; +} +''' + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + """Replace one exact source anchor or accept an already-applied replacement.""" + if new in text: + return text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one source anchor, found {count}") + return text.replace(old, new, 1) + + +def write_red() -> None: + """Write only the regression so the unpatched source must fail.""" + HELPER_TEST.write_text(HELPER_TEST_CONTENT, encoding="utf-8") + + +def apply_fix() -> None: + """Write the helper and patch the buyer surface plus integration regression.""" + HELPER.write_text(HELPER_CONTENT, encoding="utf-8") + HELPER_TEST.write_text(HELPER_TEST_CONTENT, encoding="utf-8") + + text = APP.read_text(encoding="utf-8") + text = replace_once( + text, + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', + 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' + 'import {\n' + ' analysisRunTargetClock,\n' + ' type AnalysisRunNavigationContext,\n' + '} from "./analysisRunNavigation";\n', + "navigation import", + ) + text = replace_once( + text, + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n', + 'type SelectPostOptions = {\n' + ' liveAfterCutoff?: boolean;\n' + ' knowledgeCutoff?: string;\n' + ' analysisRunContext?: AnalysisRunNavigationContext;\n', + "select options", + ) + text = replace_once( + text, + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' + ' return {\n' + ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' };\n' + '}\n', + 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' + ' const analysisRunContext: AnalysisRunNavigationContext = {\n' + ' knowledgeCutoff: run.knowledge_cutoff,\n' + ' visiblePosts: run.visible_posts ?? [],\n' + ' };\n' + ' return {\n' + ' ...analysisRunTargetClock(analysisRunContext, postId),\n' + ' analysisRunContext,\n' + ' };\n' + '}\n', + "analysis run open options", + ) + text = replace_once( + text, + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', + ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' + ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' + ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' + ' useState(null);\n', + "analysis run state", + ) + text = replace_once( + text, + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', + ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' + ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' + ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', + "select state", + ) + text = replace_once( + text, + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedFromReportMember(false);\n', + ' setOpenedAfterCutoff(false);\n' + ' setOpenedCutoffIso(null);\n' + ' setOpenedAnalysisRunContext(null);\n' + ' setOpenedFromReportMember(false);\n', + "close state", + ) + text = replace_once( + text, + ' onClick={() =>\n' + ' onSelectPost(post.post_id, {\n' + ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' + ' knowledgeCutoff: selected.knowledge_cutoff,\n' + ' })\n' + ' }\n', + ' onClick={() =>\n' + ' onSelectPost(\n' + ' post.post_id,\n' + ' analysisRunPostOpenOptions(selected, post.post_id),\n' + ' )\n' + ' }\n', + "visible post open", + ) + text = replace_once( + text, + ' onSelectPost={(postId) =>\n' + ' selectPost(postId, {\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' })\n' + ' }\n', + ' onSelectPost={(postId) => {\n' + ' const cutoffOptions = openedAnalysisRunContext\n' + ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' + ' : {};\n' + ' selectPost(postId, {\n' + ' ...cutoffOptions,\n' + ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' + ' fromReportMember: openedFromReportMember,\n' + ' fromWeeklyVoc: openedFromWeeklyVoc,\n' + ' fromCalendar: openedFromCalendar,\n' + ' fromCustomerMaster: openedFromCustomerMaster,\n' + ' fromAskAgent: openedFromAskAgent,\n' + ' });\n' + ' }}\n', + "popup navigation", + ) + APP.write_text(text, encoding="utf-8") + + test_text = APP_TEST.read_text(encoding="utf-8") + test_text = replace_once( + test_text, + ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); +''', + ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); + + const publicPosts = screen.getAllByLabelText("Open post: Public post"); + await userEvent.click(publicPosts[publicPosts.length - 1]); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); + expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); +''', + "cutoff navigation regression", + ) + APP_TEST.write_text(test_text, encoding="utf-8") + + +def main() -> None: + """Dispatch the requested test-first stage.""" + parser = argparse.ArgumentParser() + parser.add_argument("stage", choices=("red", "apply")) + args = parser.parse_args() + if args.stage == "red": + write_red() + else: + apply_fix() + + +if __name__ == "__main__": + main() From a409895f1abc46c557a8374535a62f6660a7e040 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:11:26 -0700 Subject: [PATCH 14/63] ci: execute deterministic cutoff repair --- .github/workflows/tests.yml | 338 +++--------------------------------- 1 file changed, 23 insertions(+), 315 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29a009fd5..db02d0d7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,28 +1,22 @@ name: Tests -on: +'on': push: - branches: [main] - pull_request: + branches: + - feat/event-lineage-node-keeps-gnb-focus-v2170 + workflow_dispatch: permissions: - contents: read + contents: write concurrency: - group: tests-${{ github.ref }} - cancel-in-progress: true + group: repair-pr-264-cutoff-context + cancel-in-progress: false jobs: - repair_analysis_run_cutoff_context: + repair: name: Repair target-specific cutoff context - if: >- - github.event_name == 'pull_request' && - github.head_ref == 'feat/event-lineage-node-keeps-gnb-focus-v2170' runs-on: ubuntu-latest - permissions: - contents: write - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} steps: - name: Checkout pull request branch uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 @@ -43,233 +37,27 @@ jobs: working-directory: frontend run: pnpm install --frozen-lockfile - - name: RED - selected DAG target needs its own cutoff clock + - name: Write the RED regression + run: python .github/scripts/fix_pr264_cutoff.py red + + - name: Prove the RED regression fails shell: bash run: | - cat > frontend/src/analysisRunNavigation.test.ts <<'EOF' - 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, - }); - }); - }); - EOF set +e pnpm --dir frontend exec vitest run src/analysisRunNavigation.test.ts > /tmp/red.log 2>&1 - rc=$? + status=$? set -e cat /tmp/red.log - if [ "$rc" -eq 0 ]; then - echo "RED unexpectedly passed; refusing to patch without reproducing the missing contract" >&2 + if [ "$status" -eq 0 ]; then + echo "The regression unexpectedly passed before the production fix." >&2 exit 1 fi grep -Eq 'analysisRunNavigation|Cannot find|Failed to resolve|ENOENT' /tmp/red.log - - name: GREEN - preserve run context and resolve target-specific clock - shell: bash - run: | - cat > frontend/src/analysisRunNavigation.ts <<'EOF' - /** 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, - }; - } - EOF - python - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.tsx") - text = path.read_text() - - def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one match, got {count}") - text = text.replace(old, new, 1) - - replace_once( - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' - 'import {\n' - ' analysisRunTargetClock,\n' - ' type AnalysisRunNavigationContext,\n' - '} from "./analysisRunNavigation";\n', - "navigation import", - ) - replace_once( - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n', - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n' - ' analysisRunContext?: AnalysisRunNavigationContext;\n', - "select options", - ) - replace_once( - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' - ' return {\n' - ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' };\n' - '}\n', - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const analysisRunContext: AnalysisRunNavigationContext = {\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' visiblePosts: run.visible_posts ?? [],\n' - ' };\n' - ' return {\n' - ' ...analysisRunTargetClock(analysisRunContext, postId),\n' - ' analysisRunContext,\n' - ' };\n' - '}\n', - "analysis run open options", - ) - replace_once( - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' - ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' - ' useState(null);\n', - "analysis run state", - ) - replace_once( - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' - ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', - "select state", - ) - replace_once( - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedFromReportMember(false);\n', - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedAnalysisRunContext(null);\n' - ' setOpenedFromReportMember(false);\n', - "close state", - ) - replace_once( - ' onClick={() =>\n' - ' onSelectPost(post.post_id, {\n' - ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' - ' knowledgeCutoff: selected.knowledge_cutoff,\n' - ' })\n' - ' }\n', - ' onClick={() =>\n' - ' onSelectPost(\n' - ' post.post_id,\n' - ' analysisRunPostOpenOptions(selected, post.post_id),\n' - ' )\n' - ' }\n', - "visible post open", - ) - replace_once( - ' onSelectPost={(postId) =>\n' - ' selectPost(postId, {\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' })\n' - ' }\n', - ' onSelectPost={(postId) => {\n' - ' const cutoffOptions = openedAnalysisRunContext\n' - ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' - ' : {};\n' - ' selectPost(postId, {\n' - ' ...cutoffOptions,\n' - ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' });\n' - ' }}\n', - "popup navigation", - ) - path.write_text(text) - - test_path = Path("frontend/src/App.test.tsx") - test_text = test_path.read_text() - old_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - await userEvent.click( - screen.getByRole("button", { - name: "Open live post: Private post", - }), - ); -''' - new_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); - - const publicPosts = screen.getAllByLabelText("Open post: Public post"); - await userEvent.click(publicPosts[publicPosts.length - 1]); - await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); - expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( - "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", - ); - expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); - expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); + - name: Apply the narrow GREEN fix + run: python .github/scripts/fix_pr264_cutoff.py apply - await userEvent.click(screen.getByRole("button", { name: "Close" })); - await userEvent.click( - screen.getByRole("button", { - name: "Open live post: Private post", - }), - ); -''' - count = test_text.count(old_test) - if count != 1: - raise SystemExit(f"cutoff navigation regression: expected exactly one match, got {count}") - test_path.write_text(test_text.replace(old_test, new_test, 1)) - PY - - - name: Verify GREEN and full frontend contract + - name: Verify focused and complete frontend contracts working-directory: frontend run: | pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx @@ -278,96 +66,16 @@ jobs: pnpm run build pnpm run build-storybook - - name: Restore canonical workflows and publish the fix + - name: Restore canonical workflows and publish the verified fix shell: bash run: | - git show "${BASE_SHA}:.github/workflows/tests.yml" > .github/workflows/tests.yml + git show '0aaf992fab65a2e1ad6d41d5de8a7cad1825e04c:.github/workflows/tests.yml' > .github/workflows/tests.yml rm -f .github/workflows/fix-264-cutoff-context.yml + rm -f .github/workflows/fix-264-ontology-semantic-path.yml + rm -f .github/scripts/fix_pr264_cutoff.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/tests.yml .github/workflows/fix-264-cutoff-context.yml + git add frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/tests.yml .github/workflows/fix-264-cutoff-context.yml .github/workflows/fix-264-ontology-semantic-path.yml .github/scripts/fix_pr264_cutoff.py git diff --cached --check git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 - - pytest: - name: Full test suite - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Select pinned Rust toolchain - run: | - rustup toolchain install 1.97.1 --profile minimal - rustup default 1.97.1 - - - name: Install the committed universal lock - run: uv sync --frozen --extra dev --extra backend - - - name: Run full test suite against PostgreSQL - run: uv run --frozen python -m pytest -q - - frontend: - name: Frontend lint, test, build - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - persist-credentials: false - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Enable Corepack - run: corepack enable - - - name: Install dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Lint - working-directory: frontend - run: pnpm run lint - - - name: Test - working-directory: frontend - run: pnpm run test - - - name: Build - working-directory: frontend - run: pnpm run build - - - name: Build Storybook - working-directory: frontend - run: pnpm run build-storybook From 821432a2305c574982988ff6c611af0dd7a6784a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:14:00 +0000 Subject: [PATCH 15/63] fix(ui): preserve analysis-run cutoff across DAG navigation --- .github/workflows/fix-264-cutoff-context.yml | 275 ------------------- frontend/src/App.tsx | 37 ++- frontend/src/analysisRunNavigation.test.ts | 30 ++ frontend/src/analysisRunNavigation.ts | 17 ++ 4 files changed, 74 insertions(+), 285 deletions(-) delete mode 100644 .github/workflows/fix-264-cutoff-context.yml create mode 100644 frontend/src/analysisRunNavigation.test.ts create mode 100644 frontend/src/analysisRunNavigation.ts diff --git a/.github/workflows/fix-264-cutoff-context.yml b/.github/workflows/fix-264-cutoff-context.yml deleted file mode 100644 index 43666db7f..000000000 --- a/.github/workflows/fix-264-cutoff-context.yml +++ /dev/null @@ -1,275 +0,0 @@ -name: Repair PR 264 cutoff context - -on: - push: - branches: - - feat/event-lineage-node-keeps-gnb-focus-v2170 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: repair-pr-264-cutoff-context - cancel-in-progress: false - -jobs: - red-green-fix: - if: github.ref == 'refs/heads/feat/event-lineage-node-keeps-gnb-focus-v2170' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - persist-credentials: true - fetch-depth: 0 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: '24' - - name: Enable Corepack - run: corepack enable - - name: Install frontend dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - name: RED - selected DAG target needs its own cutoff clock - shell: bash - run: | - cat > frontend/src/analysisRunNavigation.test.ts <<'EOF' - 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, - }); - }); - }); - EOF - set +e - (cd frontend && pnpm exec vitest run src/analysisRunNavigation.test.ts) > /tmp/red.log 2>&1 - rc=$? - set -e - cat /tmp/red.log - if [ "$rc" -eq 0 ]; then - echo "RED unexpectedly passed; refusing to patch without reproducing the missing contract" >&2 - exit 1 - fi - grep -Eq 'analysisRunNavigation|Cannot find|Failed to resolve|ENOENT' /tmp/red.log - - name: GREEN - preserve run context and resolve target-specific clock - shell: bash - run: | - cat > frontend/src/analysisRunNavigation.ts <<'EOF' - /** 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, - }; - } - EOF - python - <<'PY' - from pathlib import Path - - path = Path("frontend/src/App.tsx") - text = path.read_text() - - def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one match, got {count}") - text = text.replace(old, new, 1) - - replace_once( - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' - 'import {\n' - ' analysisRunTargetClock,\n' - ' type AnalysisRunNavigationContext,\n' - '} from "./analysisRunNavigation";\n', - "navigation import", - ) - replace_once( - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n', - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n' - ' analysisRunContext?: AnalysisRunNavigationContext;\n', - "select options", - ) - replace_once( - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' - ' return {\n' - ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' };\n' - '}\n', - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const analysisRunContext: AnalysisRunNavigationContext = {\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' visiblePosts: run.visible_posts ?? [],\n' - ' };\n' - ' return {\n' - ' ...analysisRunTargetClock(analysisRunContext, postId),\n' - ' analysisRunContext,\n' - ' };\n' - '}\n', - "analysis run open options", - ) - replace_once( - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' - ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' - ' useState(null);\n', - "analysis run state", - ) - replace_once( - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' - ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', - "select state", - ) - replace_once( - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedFromReportMember(false);\n', - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedAnalysisRunContext(null);\n' - ' setOpenedFromReportMember(false);\n', - "close state", - ) - replace_once( - ' onClick={() =>\n' - ' onSelectPost(post.post_id, {\n' - ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' - ' knowledgeCutoff: selected.knowledge_cutoff,\n' - ' })\n' - ' }\n', - ' onClick={() =>\n' - ' onSelectPost(\n' - ' post.post_id,\n' - ' analysisRunPostOpenOptions(selected, post.post_id),\n' - ' )\n' - ' }\n', - "visible post open", - ) - replace_once( - ' onSelectPost={(postId) =>\n' - ' selectPost(postId, {\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' })\n' - ' }\n', - ' onSelectPost={(postId) => {\n' - ' const cutoffOptions = openedAnalysisRunContext\n' - ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' - ' : {};\n' - ' selectPost(postId, {\n' - ' ...cutoffOptions,\n' - ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' });\n' - ' }}\n', - "popup navigation", - ) - path.write_text(text) - - test_path = Path("frontend/src/App.test.tsx") - test_text = test_path.read_text() - old_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - await userEvent.click( - screen.getByRole("button", { - name: "Open live post: Private post", - }), - ); -''' - new_test = ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); - - const publicPosts = screen.getAllByLabelText("Open post: Public post"); - await userEvent.click(publicPosts[publicPosts.length - 1]); - await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); - expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( - "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", - ); - expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); - expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - await userEvent.click( - screen.getByRole("button", { - name: "Open live post: Private post", - }), - ); -''' - count = test_text.count(old_test) - if count != 1: - raise SystemExit(f"cutoff navigation regression: expected exactly one match, got {count}") - test_path.write_text(test_text.replace(old_test, new_test, 1)) - PY - - name: Verify GREEN and full frontend contract - working-directory: frontend - run: | - pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - name: Commit only the validated repair - shell: bash - run: | - rm .github/workflows/fix-264-cutoff-context.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/fix-264-cutoff-context.yml - git diff --cached --check - git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55fb30f45..bd089c4b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,6 +94,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 { @@ -2396,6 +2400,7 @@ function analysisRunDigestPrefix(digest: string): string { type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; + analysisRunContext?: AnalysisRunNavigationContext; fromReportMember?: boolean; fromWeeklyVoc?: boolean; fromCalendar?: boolean; @@ -2561,10 +2566,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, }; } @@ -2888,10 +2896,10 @@ function AnalysisRunsPanel({ className="keyman-select" aria-label={analysisRunLivePostButtonLabel(post)} onClick={() => - onSelectPost(post.post_id, { - liveAfterCutoff: Boolean(post.live_after_cutoff), - knowledgeCutoff: selected.knowledge_cutoff, - }) + onSelectPost( + post.post_id, + analysisRunPostOpenOptions(selected, post.post_id), + ) } > {post.post_title} @@ -3465,6 +3473,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); @@ -3527,6 +3537,7 @@ function PostList({ setSelectedPostId(postId); setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + setOpenedAnalysisRunContext(options?.analysisRunContext ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); @@ -3554,6 +3565,7 @@ function PostList({ setSelectedPostId(null); setOpenedAfterCutoff(false); setOpenedCutoffIso(null); + setOpenedAnalysisRunContext(null); setOpenedFromReportMember(false); setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); @@ -4002,15 +4014,20 @@ function PostList({ openedFromAskAgent } 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 c616a4a01dbef1058889b5e06c4d532ed631ac60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:14:37 -0700 Subject: [PATCH 16/63] ci: guard PR 264 against the unrelated ontology writer --- .github/scripts/fix_pr264_cutoff.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/scripts/fix_pr264_cutoff.py b/.github/scripts/fix_pr264_cutoff.py index 2efcb61dd..2eabaff3f 100644 --- a/.github/scripts/fix_pr264_cutoff.py +++ b/.github/scripts/fix_pr264_cutoff.py @@ -86,6 +86,14 @@ def apply_fix() -> None: HELPER_TEST.write_text(HELPER_TEST_CONTENT, encoding="utf-8") text = APP.read_text(encoding="utf-8") + text = replace_once( + text, + 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', + 'import {\n' + ' PopupCloseButton,\n' + '} from "./components/PopupCloseButton";\n', + "single-writer import guard", + ) text = replace_once( text, 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', From f74377f8fc61ebdce0281d14a51487bd2b23f7a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:15:33 -0700 Subject: [PATCH 17/63] ci: restore canonical PR 264 verification --- .github/workflows/tests.yml | 123 ++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index db02d0d7e..1cad1f17c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,29 +1,71 @@ name: Tests -'on': +on: push: - branches: - - feat/event-lineage-node-keeps-gnb-focus-v2170 - workflow_dispatch: + branches: [main] + pull_request: permissions: - contents: write + contents: read concurrency: - group: repair-pr-264-cutoff-context - cancel-in-progress: false + group: tests-${{ github.ref }} + cancel-in-progress: true jobs: - repair: - name: Repair target-specific cutoff context + pytest: + name: Full test suite runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres steps: - - name: Checkout pull request branch + - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - fetch-depth: 0 - persist-credentials: true + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Select pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + + - name: Install the committed universal lock + run: uv sync --frozen --extra dev --extra backend + + - name: Run full test suite against PostgreSQL + run: uv run --frozen python -m pytest -q + + frontend: + name: Frontend lint, test, build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false - name: Set up Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 @@ -33,49 +75,22 @@ jobs: - name: Enable Corepack run: corepack enable - - name: Install locked frontend dependencies + - name: Install dependencies working-directory: frontend run: pnpm install --frozen-lockfile - - name: Write the RED regression - run: python .github/scripts/fix_pr264_cutoff.py red + - name: Lint + working-directory: frontend + run: pnpm run lint - - name: Prove the RED regression fails - shell: bash - run: | - set +e - pnpm --dir frontend exec vitest run src/analysisRunNavigation.test.ts > /tmp/red.log 2>&1 - status=$? - set -e - cat /tmp/red.log - if [ "$status" -eq 0 ]; then - echo "The regression unexpectedly passed before the production fix." >&2 - exit 1 - fi - grep -Eq 'analysisRunNavigation|Cannot find|Failed to resolve|ENOENT' /tmp/red.log - - - name: Apply the narrow GREEN fix - run: python .github/scripts/fix_pr264_cutoff.py apply - - - name: Verify focused and complete frontend contracts + - name: Test working-directory: frontend - run: | - pnpm exec vitest run src/analysisRunNavigation.test.ts src/App.test.tsx - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - - name: Restore canonical workflows and publish the verified fix - shell: bash - run: | - git show '0aaf992fab65a2e1ad6d41d5de8a7cad1825e04c:.github/workflows/tests.yml' > .github/workflows/tests.yml - rm -f .github/workflows/fix-264-cutoff-context.yml - rm -f .github/workflows/fix-264-ontology-semantic-path.yml - rm -f .github/scripts/fix_pr264_cutoff.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/analysisRunNavigation.ts frontend/src/analysisRunNavigation.test.ts .github/workflows/tests.yml .github/workflows/fix-264-cutoff-context.yml .github/workflows/fix-264-ontology-semantic-path.yml .github/scripts/fix_pr264_cutoff.py - git diff --cached --check - git commit -m "fix(ui): preserve analysis-run cutoff across DAG navigation" - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 + run: pnpm run test + + - name: Build + working-directory: frontend + run: pnpm run build + + - name: Build Storybook + working-directory: frontend + run: pnpm run build-storybook From 3e37f02a2a552f0f6f65561d6aaaa33d2d990087 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:15:44 -0700 Subject: [PATCH 18/63] ci: add direct ontology semantic-path repair --- .../scripts/apply_ontology_semantic_path.py | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 .github/scripts/apply_ontology_semantic_path.py diff --git a/.github/scripts/apply_ontology_semantic_path.py b/.github/scripts/apply_ontology_semantic_path.py new file mode 100644 index 000000000..c140727d5 --- /dev/null +++ b/.github/scripts/apply_ontology_semantic_path.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""Apply the ontology-aligned buyer semantic-path repair for PR #264.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + """Read one repository text file.""" + return (ROOT / path).read_text(encoding="utf-8") + + +def write(path: str, content: str) -> None: + """Write one repository text file, creating parents when needed.""" + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact source anchor, accepting an already-applied patch.""" + text = read(path) + if new in text: + return + if text.count(old) != 1: + raise SystemExit(f"repair anchor mismatch in {path}: {old[:96]!r}") + write(path, text.replace(old, new, 1)) + + +def apply() -> None: + """Apply the smallest complete KG → ontology → API → buyer UI repair.""" + ttl = "docs/ontology/lineageweave-kg.ttl" + replace_once( + ttl, + ''':mentions a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Person ; + rdfs:label "mentions" ; + rdfs:comment "A post names a person (post_person_mention)." ; + :lookupCode "edge_mention" . +''', + ''':mentions a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "Canonical knowledge_graph_edge direction for post_person_mention: the cataloged person points to the evidence post." ; + owl:inverseOf :postMentionsPerson ; + :lookupCode "edge_mention" . + +:postMentionsPerson a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Person ; + rdfs:label "mentions person" ; + rdfs:comment "Buyer-facing inverse of :mentions: a post names a cataloged person." ; + owl:inverseOf :mentions . +''', + ) + for old, new in ( + ( + ''':affiliatedWith a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :CorporateEntity ; + rdfs:label "affiliated with" ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; + :lookupCode "edge_affiliation" . +''', + ''':affiliatedWith a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :CorporateEntity ; + rdfs:label "affiliated with" ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; + owl:inverseOf :hasAffiliatedPerson ; + :lookupCode "edge_affiliation" . + +:hasAffiliatedPerson a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Person ; + rdfs:label "has affiliated person" ; + owl:inverseOf :affiliatedWith . +''', + ), + ( + ''':mentionsTeam a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; + :lookupCode "edge_mention_team" . +''', + ''':mentionsTeam a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; + owl:inverseOf :postMentionsTeam ; + :lookupCode "edge_mention_team" . + +:postMentionsTeam a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Team ; + rdfs:label "mentions team" ; + owl:inverseOf :mentionsTeam . +''', + ), + ( + ''':teamAffiliatedWith a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :CorporateEntity ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + :lookupCode "edge_team_affiliation" . +''', + ''':teamAffiliatedWith a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :CorporateEntity ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + owl:inverseOf :hasAffiliatedTeam ; + :lookupCode "edge_team_affiliation" . + +:hasAffiliatedTeam a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Team ; + rdfs:label "has affiliated team" ; + owl:inverseOf :teamAffiliatedWith . +''', + ), + ( + ''':mentionsOrganization a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; + :lookupCode "edge_mention_organization" . +''', + ''':mentionsOrganization a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; + owl:inverseOf :postMentionsOrganization ; + :lookupCode "edge_mention_organization" . + +:postMentionsOrganization a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:label "mentions organization" ; + owl:inverseOf :mentionsOrganization . +''', + ), + ): + replace_once(ttl, old, new) + + ontology = "lineageweave/ontology.py" + replace_once( + ontology, + "def all_declared_lookup_codes() -> set[str]:\n", + '''def relationship_annotations(lookup_code: str, *, reverse: bool = False) -> dict[str, str]: + """Return the OWL relationship IRI and buyer-facing label for a traversal.""" + subject = _term_subject(lookup_code) + if subject is None: + return {} + relationship = subject + if reverse and (subject, RDF.type, OWL.SymmetricProperty) not in ONTOLOGY: + relationship = ONTOLOGY.value(subject, OWL.inverseOf) + if relationship is None: + relationship = next(ONTOLOGY.subjects(OWL.inverseOf, subject), None) + if relationship is None: + relationship = subject + fields = {"relationship_iri": str(relationship)} + label = ONTOLOGY.value(relationship, RDFS.label) + if label is not None: + fields["relationship_label"] = str(label) + return fields + + +def all_declared_lookup_codes() -> set[str]: +''', + ) + replace_once( + ontology, + ' "ontology_annotations",\n]', + ' "ontology_annotations",\n "relationship_annotations",\n]', + ) + + graph = "lineageweave/knowledge_graph.py" + replace_once(graph, "from collections import defaultdict\n", "from collections import defaultdict, deque\n") + replace_once(graph, "from typing import Sequence\n", "from typing import Literal, Sequence\n") + replace_once( + graph, + "def node_key(node_type_code: str, node_id: str) -> str:\n", + '''@dataclass(frozen=True) +class KnowledgeGraphPathHop: + """One ontology-bearing traversal step through a stored KG edge.""" + + from_node_type_code: str + from_node_id: str + edge_type_code: str + to_node_type_code: str + to_node_id: str + traversal_direction: Literal["forward", "reverse"] + edge_weight: float = 1.0 + + +def node_key(node_type_code: str, node_id: str) -> str: +''', + ) + replace_once( + graph, + "def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency:\n", + '''def semantic_paths_from_edges( + edges: Sequence[KnowledgeGraphEdgeSpec], + start_node: str, +) -> dict[str, tuple[KnowledgeGraphPathHop, ...]]: + """Return deterministic shortest relationship paths over positive stored edges.""" + incident: dict[str, list[tuple[str, KnowledgeGraphPathHop]]] = defaultdict(list) + for edge in edges: + weight = float(edge.edge_weight) + if not (weight > 0 and weight == weight): + continue + source = node_key(edge.source_node_type_code, edge.source_node_id) + target = node_key(edge.target_node_type_code, edge.target_node_id) + incident[source].append((target, KnowledgeGraphPathHop( + edge.source_node_type_code, edge.source_node_id, edge.edge_type_code, + edge.target_node_type_code, edge.target_node_id, "forward", weight, + ))) + incident[target].append((source, KnowledgeGraphPathHop( + edge.target_node_type_code, edge.target_node_id, edge.edge_type_code, + edge.source_node_type_code, edge.source_node_id, "reverse", weight, + ))) + for hops in incident.values(): + hops.sort(key=lambda item: (-item[1].edge_weight, item[1].edge_type_code, + item[1].to_node_type_code, item[1].to_node_id, + item[1].traversal_direction)) + paths: dict[str, tuple[KnowledgeGraphPathHop, ...]] = {start_node: ()} + pending: deque[str] = deque([start_node]) + while pending: + current = pending.popleft() + for neighbor, hop in incident.get(current, []): + if neighbor in paths: + continue + paths[neighbor] = (*paths[current], hop) + pending.append(neighbor) + return paths + + +def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: +''', + ) + + backend = "backend/app/knowledge_graph.py" + replace_once( + backend, + "from lineageweave.ontology import ontology_annotations\n", + "from lineageweave.ontology import ontology_annotations, relationship_annotations\n", + ) + replace_once( + backend, + " select_related_nodes,\n)", + " select_related_nodes,\n semantic_paths_from_edges,\n)", + ) + replace_once( + backend, + ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) + related = select_related_nodes(scores, start_node=start) + return await hydrate_related_nodes(conn, related) +''', + ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) + related = select_related_nodes(scores, start_node=start) + semantic_paths = semantic_paths_from_edges(edges, start) + payload = await hydrate_related_nodes(conn, related) + for item in payload: + related_key = node_key(item["node_type_code"], item["node_id"]) + item["semantic_path"] = [ + { + "from_node_type_code": hop.from_node_type_code, + "to_node_type_code": hop.to_node_type_code, + "edge_type_code": hop.edge_type_code, + "traversal_direction": hop.traversal_direction, + **relationship_annotations( + hop.edge_type_code, + reverse=hop.traversal_direction == "reverse", + ), + } + for hop in semantic_paths.get(related_key, ()) + ] + return payload +''', + ) + + api = "frontend/src/api.ts" + replace_once( + api, + '''export interface RelatedNode { + node_id: string; + node_type_code: RelatedNodeType | string; + relevance: number; + label?: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + person_side_code?: string; + person_side_label?: string; + ontology_iri?: string; + ontology_label?: string; +} +''', + '''export interface SemanticPathHop { + from_node_type_code: RelatedNodeType | string; + to_node_type_code: RelatedNodeType | string; + edge_type_code: string; + traversal_direction: "forward" | "reverse"; + relationship_iri?: string; + relationship_label?: string; +} + +export interface RelatedNode { + node_id: string; + node_type_code: RelatedNodeType | string; + relevance: number; + label?: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + person_side_code?: string; + person_side_label?: string; + ontology_iri?: string; + ontology_label?: string; + semantic_path?: SemanticPathHop[]; +} +''', + ) + write( + "frontend/src/components/RelatedSemanticPath.tsx", + '''import type { SemanticPathHop } from "../api"; +import { t } from "../i18n"; + +export function semanticPathText(semanticPath?: SemanticPathHop[]): string { + const labels = (semanticPath ?? []) + .map((hop) => hop.relationship_label ?? hop.edge_type_code) + .filter((label) => label.length > 0); + return labels.length > 0 ? labels.join(" → ") : t("Graph relation"); +} + +export function RelatedSemanticPath({ semanticPath }: { semanticPath?: SemanticPathHop[] }) { + const text = semanticPathText(semanticPath); + return {text}; +} +''', + ) + replace_once( + "frontend/src/App.tsx", + 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', + 'import { PopupCloseButton } from "./components/PopupCloseButton";\nimport { RelatedSemanticPath } from "./components/RelatedSemanticPath";\n', + ) + replace_once( + "frontend/src/App.tsx", + '{t("Graph relation")}', + '', + ) + + replace_once( + "tests/test_ontology.py", + "from rdflib.namespace import RDFS, SKOS\n", + "from rdflib.namespace import OWL, RDFS, SKOS\n", + ) + replace_once( + "tests/test_ontology.py", + '''def test_mentions_property_domain_and_range_match_the_schema() -> None: + """`mentions` goes Post -> Person, matching post_person_mention's + actual foreign keys -- not just any two classes.""" + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Post) in graph + assert (LW.mentions, RDFS.range, LW.Person) in graph +''', + '''def test_mentions_property_domain_and_range_match_canonical_edge_direction() -> None: + """``edge_mention`` is stored Person -> Post; OWL and its inverse agree.""" + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph + assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph + assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph +''', + ) + + write( + "tests/test_semantic_relationship_paths.py", + '''"""Regressions for ontology-aligned buyer-visible KG relationship paths.""" + +from rdflib.namespace import OWL, RDFS + +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_CO_MENTION, + EDGE_MENTION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + KnowledgeGraphEdgeSpec, + node_key, + semantic_paths_from_edges, +) +from lineageweave.ontology import LW, load_ontology, relationship_annotations + + +def test_person_mention_ontology_matches_canonical_edge_direction() -> None: + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph + + +def test_relationship_annotations_use_inverse_buyer_label() -> None: + assert relationship_annotations(EDGE_MENTION)["relationship_label"] == "mentioned in post" + assert relationship_annotations(EDGE_MENTION, reverse=True)["relationship_label"] == "mentions person" + assert relationship_annotations(EDGE_CO_MENTION, reverse=True)["relationship_label"] == "co-mentioned with" + assert relationship_annotations("not_a_real_lookup_code") == {} + + +def test_semantic_paths_are_shortest_directional_and_deterministic() -> None: + edges = [ + KnowledgeGraphEdgeSpec(NODE_PERSON, "person-a", NODE_POST, "post-1", EDGE_MENTION), + KnowledgeGraphEdgeSpec(NODE_PERSON, "person-b", NODE_POST, "post-1", EDGE_MENTION), + KnowledgeGraphEdgeSpec(NODE_PERSON, "person-a", NODE_CORPORATE_ENTITY, "corp-1", EDGE_AFFILIATION), + ] + paths = semantic_paths_from_edges(list(reversed(edges)), node_key(NODE_PERSON, "person-a")) + assert [(hop.edge_type_code, hop.traversal_direction) for hop in paths[node_key(NODE_POST, "post-1")]] == [(EDGE_MENTION, "forward")] + assert [(hop.edge_type_code, hop.traversal_direction) for hop in paths[node_key(NODE_PERSON, "person-b")]] == [(EDGE_MENTION, "forward"), (EDGE_MENTION, "reverse")] +''', + ) + write( + "frontend/src/components/RelatedSemanticPath.test.tsx", + '''import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RelatedSemanticPath, semanticPathText } from "./RelatedSemanticPath"; + +describe("RelatedSemanticPath", () => { + it("renders the ontology relationship path instead of generic graph copy", () => { + render(); + expect(screen.getByText("mentioned in post")).toBeInTheDocument(); + expect(semanticPathText(undefined)).toBe("Graph relation"); + }); +}); +''', + ) + write( + "docs/adr/0090-ontology-grounded-semantic-relationship-path.md", + '''# ADR 0090: Ontology-grounded semantic relationship paths + +## Status + +Accepted + +## Context + +The buyer related-node surface ranked evidence-gated knowledge-graph nodes with RWR but discarded relationship predicates. It could only show a node class, relevance score, and generic `Graph relation` copy. The ontology also declared `edge_mention` as `Post -> Person` while the canonical `knowledge_graph_edge` projection stores `Person -> Post`. + +## Decision + +- Keep canonical relational edge direction. +- Align OWL domain/range with storage and declare explicit `owl:inverseOf` terms for reverse buyer traversal. +- Use RWR only for relevance selection; explain each selected node with a deterministic shortest path over the same positive evidence-gated subgraph. +- Return per-hop edge code, traversal direction, ontology IRI, and label. Missing ontology terms stay missing. +- Render the semantic path in the buyer related-node surface. The path explains stored connectivity and is not a causal claim. + +## Consequences + +The API extension is additive. PostgreSQL remains the system of record. Buyer surfaces now preserve relationship semantics instead of collapsing them to a numeric relevance score. +''', + ) + write( + "docs/doctoring/SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md", + '''# Semantic relationship path references + +## Product traceability + +`knowledge_graph_edge` direction, OWL domain/range, inverse traversal labels, and buyer-visible semantic paths must agree. `knowledge_graph_edge_evidence` remains the authorization/evidence gate; semantic paths do not create facts. + +## APA 7th references + +World Wide Web Consortium. (2012). *OWL 2 Web Ontology Language: Document overview (Second Edition).* https://www.w3.org/TR/owl2-overview/ + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology.* https://www.w3.org/TR/prov-o/ +''', + ) + write( + "CHANGELOG.d/2.17.0-ontology-semantic-path.md", + '''### Fixed + +- Aligned `edge_mention` OWL semantics with the canonical Person-to-Post graph direction and added explicit inverse relationship terms. +- Buyer related nodes now carry and render ontology-grounded relationship paths instead of generic graph copy. +''', + ) + + +if __name__ == "__main__": + apply() From f010d9bdd5c62dbaa683cddfd4fe518a0da536ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:16:00 -0700 Subject: [PATCH 19/63] ci: remove completed cutoff repair helper --- .github/scripts/fix_pr264_cutoff.py | 262 ---------------------------- 1 file changed, 262 deletions(-) delete mode 100644 .github/scripts/fix_pr264_cutoff.py diff --git a/.github/scripts/fix_pr264_cutoff.py b/.github/scripts/fix_pr264_cutoff.py deleted file mode 100644 index 2eabaff3f..000000000 --- a/.github/scripts/fix_pr264_cutoff.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the test-first PR 264 analysis-run cutoff navigation repair.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -FRONTEND = ROOT / "frontend" / "src" -HELPER = FRONTEND / "analysisRunNavigation.ts" -HELPER_TEST = FRONTEND / "analysisRunNavigation.test.ts" -APP = FRONTEND / "App.tsx" -APP_TEST = FRONTEND / "App.test.tsx" - -HELPER_TEST_CONTENT = '''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, - }); - }); -}); -''' - -HELPER_CONTENT = '''/** 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, - }; -} -''' - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - """Replace one exact source anchor or accept an already-applied replacement.""" - if new in text: - return text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one source anchor, found {count}") - return text.replace(old, new, 1) - - -def write_red() -> None: - """Write only the regression so the unpatched source must fail.""" - HELPER_TEST.write_text(HELPER_TEST_CONTENT, encoding="utf-8") - - -def apply_fix() -> None: - """Write the helper and patch the buyer surface plus integration regression.""" - HELPER.write_text(HELPER_CONTENT, encoding="utf-8") - HELPER_TEST.write_text(HELPER_TEST_CONTENT, encoding="utf-8") - - text = APP.read_text(encoding="utf-8") - text = replace_once( - text, - 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', - 'import {\n' - ' PopupCloseButton,\n' - '} from "./components/PopupCloseButton";\n', - "single-writer import guard", - ) - text = replace_once( - text, - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n', - 'import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek";\n' - 'import {\n' - ' analysisRunTargetClock,\n' - ' type AnalysisRunNavigationContext,\n' - '} from "./analysisRunNavigation";\n', - "navigation import", - ) - text = replace_once( - text, - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n', - 'type SelectPostOptions = {\n' - ' liveAfterCutoff?: boolean;\n' - ' knowledgeCutoff?: string;\n' - ' analysisRunContext?: AnalysisRunNavigationContext;\n', - "select options", - ) - text = replace_once( - text, - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const post = run.visible_posts?.find((item) => item.post_id === postId);\n' - ' return {\n' - ' liveAfterCutoff: Boolean(post?.live_after_cutoff),\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' };\n' - '}\n', - 'function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPostOptions {\n' - ' const analysisRunContext: AnalysisRunNavigationContext = {\n' - ' knowledgeCutoff: run.knowledge_cutoff,\n' - ' visiblePosts: run.visible_posts ?? [],\n' - ' };\n' - ' return {\n' - ' ...analysisRunTargetClock(analysisRunContext, postId),\n' - ' analysisRunContext,\n' - ' };\n' - '}\n', - "analysis run open options", - ) - text = replace_once( - text, - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n', - ' const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false);\n' - ' const [openedCutoffIso, setOpenedCutoffIso] = useState(null);\n' - ' const [openedAnalysisRunContext, setOpenedAnalysisRunContext] =\n' - ' useState(null);\n', - "analysis run state", - ) - text = replace_once( - text, - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n', - ' setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff));\n' - ' setOpenedCutoffIso(options?.knowledgeCutoff ?? null);\n' - ' setOpenedAnalysisRunContext(options?.analysisRunContext ?? null);\n', - "select state", - ) - text = replace_once( - text, - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedFromReportMember(false);\n', - ' setOpenedAfterCutoff(false);\n' - ' setOpenedCutoffIso(null);\n' - ' setOpenedAnalysisRunContext(null);\n' - ' setOpenedFromReportMember(false);\n', - "close state", - ) - text = replace_once( - text, - ' onClick={() =>\n' - ' onSelectPost(post.post_id, {\n' - ' liveAfterCutoff: Boolean(post.live_after_cutoff),\n' - ' knowledgeCutoff: selected.knowledge_cutoff,\n' - ' })\n' - ' }\n', - ' onClick={() =>\n' - ' onSelectPost(\n' - ' post.post_id,\n' - ' analysisRunPostOpenOptions(selected, post.post_id),\n' - ' )\n' - ' }\n', - "visible post open", - ) - text = replace_once( - text, - ' onSelectPost={(postId) =>\n' - ' selectPost(postId, {\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' })\n' - ' }\n', - ' onSelectPost={(postId) => {\n' - ' const cutoffOptions = openedAnalysisRunContext\n' - ' ? analysisRunTargetClock(openedAnalysisRunContext, postId)\n' - ' : {};\n' - ' selectPost(postId, {\n' - ' ...cutoffOptions,\n' - ' analysisRunContext: openedAnalysisRunContext ?? undefined,\n' - ' fromReportMember: openedFromReportMember,\n' - ' fromWeeklyVoc: openedFromWeeklyVoc,\n' - ' fromCalendar: openedFromCalendar,\n' - ' fromCustomerMaster: openedFromCustomerMaster,\n' - ' fromAskAgent: openedFromAskAgent,\n' - ' });\n' - ' }}\n', - "popup navigation", - ) - APP.write_text(text, encoding="utf-8") - - test_text = APP_TEST.read_text(encoding="utf-8") - test_text = replace_once( - test_text, - ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - await userEvent.click( - screen.getByRole("button", { - name: "Open live post: Private post", - }), - ); -''', - ''' expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); - - const publicPosts = screen.getAllByLabelText("Open post: Public post"); - await userEvent.click(publicPosts[publicPosts.length - 1]); - await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); - expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( - "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", - ); - expect(screen.getByRole("heading", { name: "Body this run knew" })).toBeInTheDocument(); - expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Close" })); - await userEvent.click( - screen.getByRole("button", { - name: "Open live post: Private post", - }), - ); -''', - "cutoff navigation regression", - ) - APP_TEST.write_text(test_text, encoding="utf-8") - - -def main() -> None: - """Dispatch the requested test-first stage.""" - parser = argparse.ArgumentParser() - parser.add_argument("stage", choices=("red", "apply")) - args = parser.parse_args() - if args.stage == "red": - write_red() - else: - apply_fix() - - -if __name__ == "__main__": - main() From 6cf7f3b628516e4b223332200e087d28ccef780c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:16:13 -0700 Subject: [PATCH 20/63] ci: replace stale semantic repair workflow --- .../fix-264-ontology-semantic-path.yml | 765 ------------------ 1 file changed, 765 deletions(-) delete mode 100644 .github/workflows/fix-264-ontology-semantic-path.yml diff --git a/.github/workflows/fix-264-ontology-semantic-path.yml b/.github/workflows/fix-264-ontology-semantic-path.yml deleted file mode 100644 index 14fb0025f..000000000 --- a/.github/workflows/fix-264-ontology-semantic-path.yml +++ /dev/null @@ -1,765 +0,0 @@ -name: Repair PR 264 ontology semantic path - -on: - pull_request: - types: [synchronize] - -permissions: - contents: write - -jobs: - red-green-fix: - if: github.event.pull_request.number == 264 && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 - steps: - - name: Checkout PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - persist-credentials: true - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install locked dependencies - run: | - corepack enable - uv sync --frozen --extra dev --extra backend - pnpm --dir frontend install --frozen-lockfile - - - name: Write RED regressions - run: | - cat > tests/test_semantic_relationship_paths.py <<'PY' - """Regressions for ontology-aligned, buyer-visible KG relationship paths.""" - - from rdflib.namespace import OWL, RDFS - - from lineageweave.knowledge_graph import ( - EDGE_AFFILIATION, - EDGE_CO_MENTION, - EDGE_MENTION, - NODE_CORPORATE_ENTITY, - NODE_PERSON, - NODE_POST, - KnowledgeGraphEdgeSpec, - node_key, - semantic_paths_from_edges, - ) - from lineageweave.ontology import LW, load_ontology, relationship_annotations - - - def test_person_mention_ontology_matches_canonical_edge_direction() -> None: - """The relational edge is Person -> Post, so OWL must say the same.""" - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph - assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph - assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph - - - def test_relationship_annotations_select_the_inverse_buyer_label() -> None: - assert relationship_annotations(EDGE_MENTION) == { - "relationship_iri": str(LW.mentions), - "relationship_label": "mentioned in post", - } - assert relationship_annotations(EDGE_MENTION, reverse=True) == { - "relationship_iri": str(LW.postMentionsPerson), - "relationship_label": "mentions person", - } - assert relationship_annotations(EDGE_CO_MENTION, reverse=True) == { - "relationship_iri": str(LW.coMentionedWith), - "relationship_label": "co-mentioned with", - } - assert relationship_annotations("not_a_real_lookup_code") == {} - - - def test_semantic_paths_are_shortest_directional_and_deterministic() -> None: - edges = [ - KnowledgeGraphEdgeSpec( - NODE_PERSON, - "person-a", - NODE_POST, - "post-1", - EDGE_MENTION, - ), - KnowledgeGraphEdgeSpec( - NODE_PERSON, - "person-b", - NODE_POST, - "post-1", - EDGE_MENTION, - ), - KnowledgeGraphEdgeSpec( - NODE_PERSON, - "person-a", - NODE_CORPORATE_ENTITY, - "corp-1", - EDGE_AFFILIATION, - ), - ] - start = node_key(NODE_PERSON, "person-a") - paths = semantic_paths_from_edges(list(reversed(edges)), start) - - post_path = paths[node_key(NODE_POST, "post-1")] - assert [(hop.edge_type_code, hop.traversal_direction) for hop in post_path] == [ - (EDGE_MENTION, "forward") - ] - - other_person_path = paths[node_key(NODE_PERSON, "person-b")] - assert [(hop.edge_type_code, hop.traversal_direction) for hop in other_person_path] == [ - (EDGE_MENTION, "forward"), - (EDGE_MENTION, "reverse"), - ] - assert paths[node_key(NODE_CORPORATE_ENTITY, "corp-1")][0].edge_type_code == EDGE_AFFILIATION - PY - - cat > frontend/src/components/RelatedSemanticPath.test.tsx <<'TS' - import { render, screen } from "@testing-library/react"; - import { describe, expect, it } from "vitest"; - import { RelatedSemanticPath, semanticPathText } from "./RelatedSemanticPath"; - - describe("RelatedSemanticPath", () => { - it("renders the ontology relationship path instead of a generic graph label", () => { - const semanticPath = [ - { - from_node_type_code: "node_person", - to_node_type_code: "node_post", - edge_type_code: "edge_mention", - traversal_direction: "forward" as const, - relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", - relationship_label: "mentioned in post", - }, - { - from_node_type_code: "node_post", - to_node_type_code: "node_person", - edge_type_code: "edge_mention", - traversal_direction: "reverse" as const, - relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#postMentionsPerson", - relationship_label: "mentions person", - }, - ]; - render(); - expect(screen.getByText("mentioned in post → mentions person")).toBeInTheDocument(); - expect(semanticPathText(undefined)).toBe("Graph relation"); - }); - }); - TS - - - name: Prove RED failures - run: | - set +e - uv run --frozen python -m pytest -q tests/test_semantic_relationship_paths.py - py_status=$? - pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx - ui_status=$? - set -e - if [ "$py_status" -eq 0 ] || [ "$ui_status" -eq 0 ]; then - echo "A RED regression unexpectedly passed before implementation." >&2 - exit 1 - fi - - - name: Write idempotent repair program - run: | - cat > "$RUNNER_TEMP/apply_semantic_patch.py" <<'PY' - from pathlib import Path - - - def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - - def write(path: str, content: str) -> None: - target = Path(path) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - - def replace_once(path: str, old: str, new: str) -> None: - text = read(path) - if new in text: - return - if old not in text: - raise SystemExit(f"repair anchor missing in {path}: {old[:80]!r}") - write(path, text.replace(old, new, 1)) - - - ttl = "docs/ontology/lineageweave-kg.ttl" - replace_once( - ttl, - ''':mentions a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Person ; - rdfs:label "mentions" ; - rdfs:comment "A post names a person (post_person_mention)." ; - :lookupCode "edge_mention" . - ''', - ''':mentions a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "Canonical knowledge_graph_edge direction for post_person_mention: the cataloged person points to the evidence post." ; - owl:inverseOf :postMentionsPerson ; - :lookupCode "edge_mention" . - - :postMentionsPerson a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Person ; - rdfs:label "mentions person" ; - rdfs:comment "Buyer-facing inverse of :mentions: a post names a cataloged person." ; - owl:inverseOf :mentions . - ''', - ) - replace_once( - ttl, - ''':affiliatedWith a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :CorporateEntity ; - rdfs:label "affiliated with" ; - rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; - :lookupCode "edge_affiliation" . - ''', - ''':affiliatedWith a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :CorporateEntity ; - rdfs:label "affiliated with" ; - rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; - owl:inverseOf :hasAffiliatedPerson ; - :lookupCode "edge_affiliation" . - - :hasAffiliatedPerson a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Person ; - rdfs:label "has affiliated person" ; - owl:inverseOf :affiliatedWith . - ''', - ) - replace_once( - ttl, - ''':mentionsTeam a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; - :lookupCode "edge_mention_team" . - ''', - ''':mentionsTeam a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; - owl:inverseOf :postMentionsTeam ; - :lookupCode "edge_mention_team" . - - :postMentionsTeam a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Team ; - rdfs:label "mentions team" ; - owl:inverseOf :mentionsTeam . - ''', - ) - replace_once( - ttl, - ''':teamAffiliatedWith a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :CorporateEntity ; - rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; - :lookupCode "edge_team_affiliation" . - ''', - ''':teamAffiliatedWith a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :CorporateEntity ; - rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; - owl:inverseOf :hasAffiliatedTeam ; - :lookupCode "edge_team_affiliation" . - - :hasAffiliatedTeam a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Team ; - rdfs:label "has affiliated team" ; - owl:inverseOf :teamAffiliatedWith . - ''', - ) - replace_once( - ttl, - ''':mentionsOrganization a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; - :lookupCode "edge_mention_organization" . - ''', - ''':mentionsOrganization a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; - owl:inverseOf :postMentionsOrganization ; - :lookupCode "edge_mention_organization" . - - :postMentionsOrganization a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; - rdfs:label "mentions organization" ; - owl:inverseOf :mentionsOrganization . - ''', - ) - - ontology = "lineageweave/ontology.py" - replace_once( - ontology, - '''def all_declared_lookup_codes() -> set[str]: - ''', - '''def relationship_annotations(lookup_code: str, *, reverse: bool = False) -> dict[str, str]: - """Return the OWL relationship IRI and buyer-facing label. - - ``knowledge_graph_edge`` stores one canonical direction. When a - graph walk traverses that edge in reverse, the declared - ``owl:inverseOf`` property supplies the correct semantic label. - Symmetric properties retain their own IRI and label. Missing - ontology terms stay missing rather than receiving guessed copy. - """ - subject = _term_subject(lookup_code) - if subject is None: - return {} - relationship = subject - if reverse and (subject, RDF.type, OWL.SymmetricProperty) not in ONTOLOGY: - relationship = ONTOLOGY.value(subject, OWL.inverseOf) - if relationship is None: - relationship = next(ONTOLOGY.subjects(OWL.inverseOf, subject), None) - if relationship is None: - relationship = subject - fields = {"relationship_iri": str(relationship)} - label = ONTOLOGY.value(relationship, RDFS.label) - if label is not None: - fields["relationship_label"] = str(label) - return fields - - - def all_declared_lookup_codes() -> set[str]: - ''', - ) - replace_once( - ontology, - ''' "ontology_annotations", - ] - ''', - ''' "ontology_annotations", - "relationship_annotations", - ] - ''', - ) - - graph = "lineageweave/knowledge_graph.py" - replace_once(graph, "from collections import defaultdict\n", "from collections import defaultdict, deque\n") - replace_once(graph, "from typing import Sequence\n", "from typing import Literal, Sequence\n") - replace_once( - graph, - '''def node_key(node_type_code: str, node_id: str) -> str: - ''', - '''@dataclass(frozen=True) - class KnowledgeGraphPathHop: - """One ontology-bearing traversal step through a stored KG edge.""" - - from_node_type_code: str - from_node_id: str - edge_type_code: str - to_node_type_code: str - to_node_id: str - traversal_direction: Literal["forward", "reverse"] - edge_weight: float = 1.0 - - - def node_key(node_type_code: str, node_id: str) -> str: - ''', - ) - replace_once( - graph, - '''def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: - ''', - '''def semantic_paths_from_edges( - edges: Sequence[KnowledgeGraphEdgeSpec], - start_node: str, - ) -> dict[str, tuple[KnowledgeGraphPathHop, ...]]: - """Deterministic shortest semantic path from ``start_node``. - - RWR determines *which* nodes are relevant. This breadth-first - projection explains *why* each reachable node is connected, - using the same positive evidence-gated edges. Ties prefer higher - weight, then stable edge/type/id ordering; the path is an - explanation of stored relationships, not a causal claim. - """ - incident: dict[str, list[tuple[str, KnowledgeGraphPathHop]]] = defaultdict(list) - for edge in edges: - weight = float(edge.edge_weight) - if not (weight > 0 and weight == weight): - continue - source = node_key(edge.source_node_type_code, edge.source_node_id) - target = node_key(edge.target_node_type_code, edge.target_node_id) - incident[source].append( - ( - target, - KnowledgeGraphPathHop( - edge.source_node_type_code, - edge.source_node_id, - edge.edge_type_code, - edge.target_node_type_code, - edge.target_node_id, - "forward", - weight, - ), - ) - ) - incident[target].append( - ( - source, - KnowledgeGraphPathHop( - edge.target_node_type_code, - edge.target_node_id, - edge.edge_type_code, - edge.source_node_type_code, - edge.source_node_id, - "reverse", - weight, - ), - ) - ) - for hops in incident.values(): - hops.sort( - key=lambda item: ( - -item[1].edge_weight, - item[1].edge_type_code, - item[1].to_node_type_code, - item[1].to_node_id, - item[1].traversal_direction, - ) - ) - - paths: dict[str, tuple[KnowledgeGraphPathHop, ...]] = {start_node: ()} - pending: deque[str] = deque([start_node]) - while pending: - current = pending.popleft() - for neighbor, hop in incident.get(current, []): - if neighbor in paths: - continue - paths[neighbor] = (*paths[current], hop) - pending.append(neighbor) - return paths - - - def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: - ''', - ) - - backend = "backend/app/knowledge_graph.py" - replace_once( - backend, - "from lineageweave.ontology import ontology_annotations\n", - "from lineageweave.ontology import ontology_annotations, relationship_annotations\n", - ) - replace_once( - backend, - ''' random_walk_with_restart, - select_related_nodes, - ) - ''', - ''' random_walk_with_restart, - select_related_nodes, - semantic_paths_from_edges, - ) - ''', - ) - replace_once( - backend, - ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) - related = select_related_nodes(scores, start_node=start) - return await hydrate_related_nodes(conn, related) - ''', - ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) - related = select_related_nodes(scores, start_node=start) - semantic_paths = semantic_paths_from_edges(edges, start) - payload = await hydrate_related_nodes(conn, related) - for item in payload: - related_key = node_key(item["node_type_code"], item["node_id"]) - item["semantic_path"] = [ - { - "from_node_type_code": hop.from_node_type_code, - "to_node_type_code": hop.to_node_type_code, - "edge_type_code": hop.edge_type_code, - "traversal_direction": hop.traversal_direction, - **relationship_annotations( - hop.edge_type_code, - reverse=hop.traversal_direction == "reverse", - ), - } - for hop in semantic_paths.get(related_key, ()) - ] - return payload - ''', - ) - - api = "frontend/src/api.ts" - replace_once( - api, - '''export interface RelatedNode { - node_id: string; - node_type_code: RelatedNodeType | string; - relevance: number; - label?: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - person_side_code?: string; - person_side_label?: string; - ontology_iri?: string; - ontology_label?: string; - } - ''', - '''export interface SemanticPathHop { - from_node_type_code: RelatedNodeType | string; - to_node_type_code: RelatedNodeType | string; - edge_type_code: string; - traversal_direction: "forward" | "reverse"; - relationship_iri?: string; - relationship_label?: string; - } - - export interface RelatedNode { - node_id: string; - node_type_code: RelatedNodeType | string; - relevance: number; - label?: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - person_side_code?: string; - person_side_label?: string; - ontology_iri?: string; - ontology_label?: string; - semantic_path?: SemanticPathHop[]; - } - ''', - ) - - component = '''import type { SemanticPathHop } from "../api"; - import { t } from "../i18n"; - - export function semanticPathText(semanticPath?: SemanticPathHop[]): string { - const labels = (semanticPath ?? []) - .map((hop) => hop.relationship_label ?? hop.edge_type_code) - .filter((label) => label.length > 0); - return labels.length > 0 ? labels.join(" → ") : t("Graph relation"); - } - - export function RelatedSemanticPath({ semanticPath }: { semanticPath?: SemanticPathHop[] }) { - const text = semanticPathText(semanticPath); - return ( - - {text} - - ); - } - ''' - write("frontend/src/components/RelatedSemanticPath.tsx", component) - - app = "frontend/src/App.tsx" - replace_once( - app, - 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', - 'import { PopupCloseButton } from "./components/PopupCloseButton";\nimport { RelatedSemanticPath } from "./components/RelatedSemanticPath";\n', - ) - replace_once( - app, - '{t("Graph relation")}', - '', - ) - - test_ontology = "tests/test_ontology.py" - replace_once( - test_ontology, - "from rdflib.namespace import RDFS, SKOS\n", - "from rdflib.namespace import OWL, RDFS, SKOS\n", - ) - replace_once( - test_ontology, - '''def test_mentions_property_domain_and_range_match_the_schema() -> None: - """`mentions` goes Post -> Person, matching post_person_mention's - actual foreign keys -- not just any two classes.""" - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Post) in graph - assert (LW.mentions, RDFS.range, LW.Person) in graph - ''', - '''def test_mentions_property_domain_and_range_match_canonical_edge_direction() -> None: - """``edge_mention`` is stored Person -> Post; OWL and its inverse agree.""" - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph - assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph - assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph - ''', - ) - - projection_test = "tests/test_person_mention_projection.py" - replace_once( - projection_test, - ''' assert first_post_id in related_ids - assert second_post_id in related_ids - hydrated = await hydrate_related_nodes( - ''', - ''' assert first_post_id in related_ids - assert second_post_id in related_ids - related_by_id = {node["node_id"]: node for node in related} - assert related_by_id[first_post_id]["semantic_path"] == [ - { - "from_node_type_code": NODE_TEAM, - "to_node_type_code": NODE_POST, - "edge_type_code": EDGE_MENTION_TEAM, - "traversal_direction": "forward", - "relationship_iri": "https://contextualwisdomlab.github.io/lineageweave/ontology#mentionsTeam", - "relationship_label": "mentioned in post", - } - ] - hydrated = await hydrate_related_nodes( - ''', - ) - - write( - "docs/adr/0076-ontology-grounded-semantic-relationship-path.md", - '''# ADR 0076: Ontology-grounded semantic relationship paths - - ## Status - - Accepted - - ## Context - - The buyer related-node surface ranked evidence-gated knowledge-graph nodes with RWR, but returned only each node class and a numeric relevance score. The relationship predicates traversed from the selected person, team, organization, or post disappeared, so the UI could only say `Graph relation`. The ontology also declared `edge_mention` as `Post -> Person`, while the canonical relational projection stores `Person -> Post`. - - ## Decision - - - Keep the existing canonical `knowledge_graph_edge` direction. - - Correct the OWL domain/range for `edge_mention` to `Person -> Post` and declare explicit `owl:inverseOf` properties for buyer-readable reverse traversal. - - Compute a deterministic shortest semantic path over the same positive, ABAC/evidence-gated subgraph used by RWR. RWR still determines relevance; the path only explains the stored connectivity and is not a causal claim. - - Return each hop's lookup code, traversal direction, ontology IRI, and ontology label. Unknown ontology terms remain absent rather than receiving guessed labels. - - Render the path on related-post cards so the buyer can act on the relationship instead of seeing generic graph copy. - - ## Consequences - - The API remains additive. Existing consumers may ignore `semantic_path`. The relational database remains the system of record; OWL supplies controlled semantics and inverse labels. A later RDF 1.2 export may annotate individual edge assertions with provenance, but this slice does not claim RDF 1.2 conformance or create unsupported facts. - ''', - ) - write( - "docs/doctoring/SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md", - '''# Semantic relationship path references - - ## Product traceability - - - `knowledge_graph_edge` is interpreted as a typed subject-predicate-object assertion whose canonical direction must match the OWL property's domain and range. - - `owl:inverseOf` provides the relationship used when the buyer traverses a stored assertion in reverse. - - `knowledge_graph_edge_evidence` continues to gate the visible subgraph; the semantic path never bypasses ABAC or invents provenance. - - RDF 1.2 triple terms and annotations are tracked for a future interoperable statement-provenance export; this release keeps PostgreSQL as the authority. - - ## APA 7th references - - World Wide Web Consortium. (2012). *OWL 2 Web Ontology Language: Document overview (Second Edition).* https://www.w3.org/TR/owl2-overview/ - - World Wide Web Consortium. (2013). *PROV-O: The PROV ontology.* https://www.w3.org/TR/prov-o/ - - World Wide Web Consortium. (2026). *RDF 1.2 concepts and abstract data model* (Candidate Recommendation Snapshot, April 7, 2026). https://www.w3.org/TR/rdf12-concepts/ - ''', - ) - write( - "CHANGELOG.d/2.17.0-ontology-semantic-path.md", - '''### Fixed - - - Aligned `edge_mention` OWL domain/range with the canonical Person-to-Post graph direction and added explicit inverse relationship terms. - - Related knowledge-graph nodes now carry and render the deterministic ontology relationship path that explains why each buyer-visible node is connected. - ''', - ) - PY - - - name: Apply ontology and buyer-surface repair - run: python "$RUNNER_TEMP/apply_semantic_patch.py" - - - name: Run focused GREEN verification - run: | - uv run --frozen python -m pytest -q \ - tests/test_semantic_relationship_paths.py \ - tests/test_ontology.py \ - tests/test_knowledge_graph.py \ - tests/test_person_mention_projection.py - pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx - - - name: Run full Python verification - run: uv run --frozen python -m pytest -q - - - name: Run full frontend verification - run: | - pnpm --dir frontend run lint - pnpm --dir frontend run test - pnpm --dir frontend run build - pnpm --dir frontend run build-storybook - - - name: Commit and push without overwriting concurrent fixes - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - commit_current_tree() { - rm -f .github/workflows/fix-264-ontology-semantic-path.yml - git add -A - if git diff --cached --quiet; then - return 0 - fi - git commit -m "fix: expose ontology-grounded semantic relationship paths" - } - - commit_current_tree - for attempt in 1 2 3; do - if git push origin "HEAD:${REPAIR_BRANCH}"; then - exit 0 - fi - echo "Concurrent branch update detected; replaying the idempotent repair (attempt ${attempt})." - git fetch origin "${REPAIR_BRANCH}" - git reset --hard "origin/${REPAIR_BRANCH}" - python "$RUNNER_TEMP/apply_semantic_patch.py" - uv run --frozen python -m pytest -q \ - tests/test_semantic_relationship_paths.py \ - tests/test_ontology.py \ - tests/test_knowledge_graph.py \ - tests/test_person_mention_projection.py - pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx - commit_current_tree - done - echo "Could not push after bounded concurrent-update retries." >&2 - exit 1 From f68b8d73620bcd789078c1ea393c9d4e405c9138 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:16:41 -0700 Subject: [PATCH 21/63] ci: execute ontology semantic-path repair --- .../apply-ontology-semantic-path.yml | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/apply-ontology-semantic-path.yml diff --git a/.github/workflows/apply-ontology-semantic-path.yml b/.github/workflows/apply-ontology-semantic-path.yml new file mode 100644 index 000000000..9551a77f6 --- /dev/null +++ b/.github/workflows/apply-ontology-semantic-path.yml @@ -0,0 +1,129 @@ +name: Apply ontology semantic-path repair + +on: + push: + branches: + - feat/event-lineage-node-keeps-gnb-focus-v2170 + +permissions: + contents: write + +jobs: + repair: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 + steps: + - name: Checkout repair branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + persist-credentials: true + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install locked dependencies + run: | + corepack enable + uv sync --frozen --extra dev --extra backend + pnpm --dir frontend install --frozen-lockfile + + - name: Prove RED root cause + shell: bash + run: | + set +e + uv run --frozen python - <<'PY' + from rdflib.namespace import RDFS + from lineageweave.ontology import LW, load_ontology + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + PY + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "Expected pre-repair ontology mismatch was not reproduced." >&2 + exit 1 + fi + + - name: Apply minimal semantic repair + run: python .github/scripts/apply_ontology_semantic_path.py + + - name: Verify focused GREEN contracts + run: | + uv run --frozen python -m pytest -q \ + tests/test_semantic_relationship_paths.py \ + tests/test_ontology.py \ + tests/test_knowledge_graph.py \ + tests/test_person_mention_projection.py + pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx + + - name: Verify full product suites + run: | + uv run --frozen python -m pytest -q + pnpm --dir frontend run lint + pnpm --dir frontend run test + pnpm --dir frontend run build + pnpm --dir frontend run build-storybook + + - name: Commit verified source repair + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git rm -- .github/scripts/apply_ontology_semantic_path.py \ + .github/workflows/apply-ontology-semantic-path.yml \ + .github/workflows/fix-264-ontology-semantic-path.yml + + git add -- \ + docs/ontology/lineageweave-kg.ttl \ + lineageweave/ontology.py \ + lineageweave/knowledge_graph.py \ + backend/app/knowledge_graph.py \ + frontend/src/api.ts \ + frontend/src/App.tsx \ + frontend/src/components/RelatedSemanticPath.tsx \ + frontend/src/components/RelatedSemanticPath.test.tsx \ + tests/test_ontology.py \ + tests/test_semantic_relationship_paths.py \ + docs/adr/0090-ontology-grounded-semantic-relationship-path.md \ + docs/doctoring/SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md \ + CHANGELOG.d/2.17.0-ontology-semantic-path.md + + if git diff --cached --quiet; then + echo "No verified repair changes to commit." >&2 + exit 1 + fi + git commit -m "fix: expose ontology-grounded semantic relationship paths" + git push origin "HEAD:${REPAIR_BRANCH}" From af9d25a7ff3e85b8b04ceefec0d67e7c384fb6d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:19:42 -0700 Subject: [PATCH 22/63] test: stage ontology semantic-path repair --- .github/scripts/fix_pr264_semantic_path.py | 817 +++++++++++++++++++++ 1 file changed, 817 insertions(+) create mode 100644 .github/scripts/fix_pr264_semantic_path.py diff --git a/.github/scripts/fix_pr264_semantic_path.py b/.github/scripts/fix_pr264_semantic_path.py new file mode 100644 index 000000000..3c8b4be63 --- /dev/null +++ b/.github/scripts/fix_pr264_semantic_path.py @@ -0,0 +1,817 @@ +#!/usr/bin/env python3 +"""Apply the test-first PR 264 ontology relationship-path repair.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +ONTOLOGY = ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" +ONTOLOGY_MODULE = ROOT / "lineageweave" / "ontology.py" +GRAPH_MODULE = ROOT / "lineageweave" / "knowledge_graph.py" +BACKEND_GRAPH = ROOT / "backend" / "app" / "knowledge_graph.py" +API_TYPES = ROOT / "frontend" / "src" / "api.ts" +APP = ROOT / "frontend" / "src" / "App.tsx" +ONTOLOGY_TEST = ROOT / "tests" / "test_ontology.py" +PROJECTION_TEST = ROOT / "tests" / "test_person_mention_projection.py" +PYTHON_RED = ROOT / "tests" / "test_semantic_relationship_paths.py" +FRONTEND_COMPONENT = ROOT / "frontend" / "src" / "components" / "RelatedSemanticPath.tsx" +FRONTEND_RED = ROOT / "frontend" / "src" / "components" / "RelatedSemanticPath.test.tsx" +DOCTORING = ROOT / "docs" / "doctoring" / "SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md" +CHANGELOG = ROOT / "CHANGELOG.d" / "2.17.0-ontology-semantic-path.md" + + +def read(path: Path) -> str: + """Read one UTF-8 repository file.""" + + return path.read_text(encoding="utf-8") + + +def write(path: Path, content: str) -> None: + """Write one UTF-8 repository file, creating its parent directory.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def replace_once(path: Path, old: str, new: str, label: str) -> None: + """Replace exactly one anchor, while remaining idempotent after success.""" + + text = read(path) + if new in text: + return + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor in {path}, got {count}") + write(path, text.replace(old, new, 1)) + + +def write_red() -> None: + """Write regressions that fail before the production semantic contract exists.""" + + write( + PYTHON_RED, + '''"""Regressions for ontology-aligned, buyer-visible KG relationship paths.""" + +from rdflib.namespace import OWL, RDFS + +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_CO_MENTION, + EDGE_MENTION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + KnowledgeGraphEdgeSpec, + node_key, + semantic_paths_from_edges, +) +from lineageweave.ontology import LW, load_ontology, relationship_annotations + + +def test_person_mention_ontology_matches_canonical_edge_direction() -> None: + """The relational edge is Person -> Post, so OWL must say the same.""" + + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph + assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph + assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph + + +def test_relationship_annotations_select_the_inverse_buyer_label() -> None: + """Reverse traversal must expose the inverse property, not a false direction.""" + + assert relationship_annotations(EDGE_MENTION) == { + "relationship_iri": str(LW.mentions), + "relationship_label": "mentioned in post", + } + assert relationship_annotations(EDGE_MENTION, reverse=True) == { + "relationship_iri": str(LW.postMentionsPerson), + "relationship_label": "mentions person", + } + assert relationship_annotations(EDGE_CO_MENTION, reverse=True) == { + "relationship_iri": str(LW.coMentionedWith), + "relationship_label": "co-mentioned with", + } + assert relationship_annotations("not_a_real_lookup_code") == {} + + +def test_semantic_paths_are_shortest_directional_and_deterministic() -> None: + """A path explains the stored graph without depending on edge input order.""" + + edges = [ + KnowledgeGraphEdgeSpec( + NODE_PERSON, + "person-a", + NODE_POST, + "post-1", + EDGE_MENTION, + ), + KnowledgeGraphEdgeSpec( + NODE_PERSON, + "person-b", + NODE_POST, + "post-1", + EDGE_MENTION, + ), + KnowledgeGraphEdgeSpec( + NODE_PERSON, + "person-a", + NODE_CORPORATE_ENTITY, + "corp-1", + EDGE_AFFILIATION, + ), + ] + start = node_key(NODE_PERSON, "person-a") + paths = semantic_paths_from_edges(list(reversed(edges)), start) + + post_path = paths[node_key(NODE_POST, "post-1")] + assert [ + ( + hop.from_node_id, + hop.edge_type_code, + hop.to_node_id, + hop.traversal_direction, + ) + for hop in post_path + ] == [("person-a", EDGE_MENTION, "post-1", "forward")] + + other_person_path = paths[node_key(NODE_PERSON, "person-b")] + assert [ + ( + hop.from_node_id, + hop.edge_type_code, + hop.to_node_id, + hop.traversal_direction, + ) + for hop in other_person_path + ] == [ + ("person-a", EDGE_MENTION, "post-1", "forward"), + ("post-1", EDGE_MENTION, "person-b", "reverse"), + ] + assert paths[node_key(NODE_CORPORATE_ENTITY, "corp-1")][0].edge_type_code == EDGE_AFFILIATION +''', + ) + write( + FRONTEND_RED, + '''import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setLocale } from "../i18n"; +import { RelatedSemanticPath, semanticPathText } from "./RelatedSemanticPath"; + +const semanticPath = [ + { + from_node_type_code: "node_person", + from_node_id: "person-a", + to_node_type_code: "node_post", + to_node_id: "post-1", + edge_type_code: "edge_mention", + traversal_direction: "forward" as const, + relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", + relationship_label: "mentioned in post", + }, + { + from_node_type_code: "node_post", + from_node_id: "post-1", + to_node_type_code: "node_person", + to_node_id: "person-b", + edge_type_code: "edge_mention", + traversal_direction: "reverse" as const, + relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#postMentionsPerson", + relationship_label: "mentions person", + }, +]; + +describe("RelatedSemanticPath", () => { + beforeEach(() => setLocale("en")); + + it("renders the ontology relationship path instead of generic graph copy", () => { + render(); + expect(screen.getByText("mentioned in post → mentions person")).toBeInTheDocument(); + expect(semanticPathText(undefined)).toBe("Graph relation"); + }); +}); +''', + ) + + +def next_adr_path() -> Path: + """Choose the next ADR number across this branch and the live stack parent.""" + + names = [path.name for path in (ROOT / "docs" / "adr").glob("[0-9][0-9][0-9][0-9]-*.md")] + try: + result = subprocess.run( + [ + "git", + "ls-tree", + "-r", + "--name-only", + "origin/feat/analysis-run-name-evidence-lineage", + "docs/adr", + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + names.extend(Path(line).name for line in result.stdout.splitlines()) + except subprocess.CalledProcessError: + pass + numbers = [ + int(match.group(1)) + for name in names + if (match := re.match(r"^(\d{4})-", name)) is not None + ] + number = max(numbers, default=0) + 1 + return ROOT / "docs" / "adr" / f"{number:04d}-ontology-grounded-semantic-relationship-path.md" + + +def apply() -> None: + """Apply the narrow ontology, API, and buyer-surface implementation.""" + + replace_once( + ONTOLOGY, + ''':mentions a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Person ; + rdfs:label "mentions" ; + rdfs:comment "A post names a person (post_person_mention)." ; + :lookupCode "edge_mention" . +''', + ''':mentions a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "Canonical knowledge_graph_edge direction for post_person_mention: the cataloged person points to the evidence post." ; + owl:inverseOf :postMentionsPerson ; + :lookupCode "edge_mention" . + +:postMentionsPerson a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Person ; + rdfs:label "mentions person" ; + rdfs:comment "Buyer-facing inverse of :mentions: a post names a cataloged person." ; + owl:inverseOf :mentions . +''', + "person mention ontology direction", + ) + replace_once( + ONTOLOGY, + ''':affiliatedWith a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :CorporateEntity ; + rdfs:label "affiliated with" ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; + :lookupCode "edge_affiliation" . +''', + ''':affiliatedWith a owl:ObjectProperty ; + rdfs:domain :Person ; + rdfs:range :CorporateEntity ; + rdfs:label "affiliated with" ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; + owl:inverseOf :hasAffiliatedPerson ; + :lookupCode "edge_affiliation" . + +:hasAffiliatedPerson a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Person ; + rdfs:label "has affiliated person" ; + owl:inverseOf :affiliatedWith . +''', + "person affiliation inverse", + ) + replace_once( + ONTOLOGY, + ''':mentionsTeam a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; + :lookupCode "edge_mention_team" . +''', + ''':mentionsTeam a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; + owl:inverseOf :postMentionsTeam ; + :lookupCode "edge_mention_team" . + +:postMentionsTeam a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :Team ; + rdfs:label "mentions team" ; + owl:inverseOf :mentionsTeam . +''', + "team mention inverse", + ) + replace_once( + ONTOLOGY, + ''':teamAffiliatedWith a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :CorporateEntity ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + :lookupCode "edge_team_affiliation" . +''', + ''':teamAffiliatedWith a owl:ObjectProperty ; + rdfs:domain :Team ; + rdfs:range :CorporateEntity ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + owl:inverseOf :hasAffiliatedTeam ; + :lookupCode "edge_team_affiliation" . + +:hasAffiliatedTeam a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Team ; + rdfs:label "has affiliated team" ; + owl:inverseOf :teamAffiliatedWith . +''', + "team affiliation inverse", + ) + replace_once( + ONTOLOGY, + ''':mentionsOrganization a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; + :lookupCode "edge_mention_organization" . +''', + ''':mentionsOrganization a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Post ; + rdfs:label "mentioned in post" ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; + owl:inverseOf :postMentionsOrganization ; + :lookupCode "edge_mention_organization" . + +:postMentionsOrganization a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :CorporateEntity ; + rdfs:label "mentions organization" ; + owl:inverseOf :mentionsOrganization . +''', + "organization mention inverse", + ) + + replace_once( + ONTOLOGY_MODULE, + "def all_declared_lookup_codes() -> set[str]:\n", + '''def relationship_annotations(lookup_code: str, *, reverse: bool = False) -> dict[str, str]: + """Return the ontology predicate IRI and direction-correct label. + + ``knowledge_graph_edge`` stores one canonical direction. Reverse graph + traversal selects the declared ``owl:inverseOf`` property. Symmetric + properties retain their own term. Missing terms stay missing rather than + receiving guessed semantics. + """ + + subject = _term_subject(lookup_code) + if subject is None: + return {} + relationship = subject + if reverse and (subject, RDF.type, OWL.SymmetricProperty) not in ONTOLOGY: + relationship = ONTOLOGY.value(subject, OWL.inverseOf) + if relationship is None: + relationship = next(ONTOLOGY.subjects(OWL.inverseOf, subject), None) + if relationship is None: + relationship = subject + fields = {"relationship_iri": str(relationship)} + label = ONTOLOGY.value(relationship, RDFS.label) + if label is not None: + fields["relationship_label"] = str(label) + return fields + + +def all_declared_lookup_codes() -> set[str]: +''', + "relationship ontology annotations", + ) + replace_once( + ONTOLOGY_MODULE, + ' "ontology_annotations",\n]\n', + ' "ontology_annotations",\n "relationship_annotations",\n]\n', + "ontology module export", + ) + + replace_once( + GRAPH_MODULE, + "from collections import defaultdict\n", + "from collections import defaultdict, deque\n", + "semantic path deque import", + ) + replace_once( + GRAPH_MODULE, + "from typing import Sequence\n", + "from typing import Literal, Sequence\n", + "semantic path type import", + ) + replace_once( + GRAPH_MODULE, + "def node_key(node_type_code: str, node_id: str) -> str:\n", + '''@dataclass(frozen=True) +class KnowledgeGraphPathHop: + """One direction-aware traversal step through a stored KG assertion.""" + + from_node_type_code: str + from_node_id: str + edge_type_code: str + to_node_type_code: str + to_node_id: str + traversal_direction: Literal["forward", "reverse"] + edge_weight: float = 1.0 + + +def node_key(node_type_code: str, node_id: str) -> str: +''', + "semantic path hop contract", + ) + replace_once( + GRAPH_MODULE, + "def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency:\n", + '''def semantic_paths_from_edges( + edges: Sequence[KnowledgeGraphEdgeSpec], + start_node: str, +) -> dict[str, tuple[KnowledgeGraphPathHop, ...]]: + """Return deterministic shortest relationship paths from ``start_node``. + + RWR determines which nodes are relevant. This breadth-first projection + explains why each reachable node is connected using the same positive, + evidence-gated edges. Ties prefer higher edge weight, then stable typed + identifiers. The path describes stored connectivity and is not causal. + """ + + incident: dict[str, list[tuple[str, KnowledgeGraphPathHop]]] = defaultdict(list) + for edge in edges: + weight = float(edge.edge_weight) + if not (weight > 0 and weight == weight): + continue + source = node_key(edge.source_node_type_code, edge.source_node_id) + target = node_key(edge.target_node_type_code, edge.target_node_id) + incident[source].append( + ( + target, + KnowledgeGraphPathHop( + from_node_type_code=edge.source_node_type_code, + from_node_id=edge.source_node_id, + edge_type_code=edge.edge_type_code, + to_node_type_code=edge.target_node_type_code, + to_node_id=edge.target_node_id, + traversal_direction="forward", + edge_weight=weight, + ), + ) + ) + incident[target].append( + ( + source, + KnowledgeGraphPathHop( + from_node_type_code=edge.target_node_type_code, + from_node_id=edge.target_node_id, + edge_type_code=edge.edge_type_code, + to_node_type_code=edge.source_node_type_code, + to_node_id=edge.source_node_id, + traversal_direction="reverse", + edge_weight=weight, + ), + ) + ) + for hops in incident.values(): + hops.sort( + key=lambda item: ( + -item[1].edge_weight, + item[1].edge_type_code, + item[1].to_node_type_code, + item[1].to_node_id, + item[1].traversal_direction, + ) + ) + + paths: dict[str, tuple[KnowledgeGraphPathHop, ...]] = {start_node: ()} + pending: deque[str] = deque([start_node]) + while pending: + current = pending.popleft() + for neighbor, hop in incident.get(current, []): + if neighbor in paths: + continue + paths[neighbor] = (*paths[current], hop) + pending.append(neighbor) + return paths + + +def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: +''', + "semantic shortest paths", + ) + + replace_once( + BACKEND_GRAPH, + "from lineageweave.ontology import ontology_annotations\n", + "from lineageweave.ontology import ontology_annotations, relationship_annotations\n", + "backend relationship annotation import", + ) + replace_once( + BACKEND_GRAPH, + " select_related_nodes,\n)\n", + " select_related_nodes,\n semantic_paths_from_edges,\n)\n", + "backend semantic path import", + ) + replace_once( + BACKEND_GRAPH, + ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) + related = select_related_nodes(scores, start_node=start) + return await hydrate_related_nodes(conn, related) +''', + ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) + related = select_related_nodes(scores, start_node=start) + semantic_paths = semantic_paths_from_edges(edges, start) + payload = await hydrate_related_nodes(conn, related) + for item in payload: + related_key = node_key(item["node_type_code"], item["node_id"]) + item["semantic_path"] = [ + { + "from_node_type_code": hop.from_node_type_code, + "from_node_id": hop.from_node_id, + "to_node_type_code": hop.to_node_type_code, + "to_node_id": hop.to_node_id, + "edge_type_code": hop.edge_type_code, + "traversal_direction": hop.traversal_direction, + **relationship_annotations( + hop.edge_type_code, + reverse=hop.traversal_direction == "reverse", + ), + } + for hop in semantic_paths.get(related_key, ()) + ] + return payload +''', + "buyer semantic path payload", + ) + + replace_once( + API_TYPES, + "export interface RelatedNode {\n", + '''export interface SemanticPathHop { + from_node_type_code: RelatedNodeType | string; + from_node_id: string; + to_node_type_code: RelatedNodeType | string; + to_node_id: string; + edge_type_code: string; + traversal_direction: "forward" | "reverse"; + relationship_iri?: string; + relationship_label?: string; +} + +export interface RelatedNode { +''', + "frontend semantic path type", + ) + replace_once( + API_TYPES, + " ontology_label?: string;\n}\n\nexport interface PostRoleResponsibility", + " ontology_label?: string;\n semantic_path?: SemanticPathHop[];\n}\n\nexport interface PostRoleResponsibility", + "related node semantic path", + ) + + write( + FRONTEND_COMPONENT, + '''import type { SemanticPathHop } from "../api"; +import { t } from "../i18n"; + +export function semanticPathText(semanticPath?: SemanticPathHop[]): string { + const labels = (semanticPath ?? []) + .map((hop) => t(hop.relationship_label ?? hop.edge_type_code)) + .filter((label) => label.length > 0); + return labels.length > 0 ? labels.join(" → ") : t("Graph relation"); +} + +export function RelatedSemanticPath({ + semanticPath, + className = "related-post-kind", +}: { + semanticPath?: SemanticPathHop[]; + className?: string; +}) { + const text = semanticPathText(semanticPath); + return ( + + {text} + + ); +} +''', + ) + + replace_once( + APP, + 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', + 'import { PopupCloseButton } from "./components/PopupCloseButton";\nimport { RelatedSemanticPath } from "./components/RelatedSemanticPath";\n', + "semantic path component import", + ) + replace_once( + APP, + '{t("Graph relation")}', + '', + "related post semantic path", + ) + replace_once( + APP, + ''' > + {caption} + + + ); + case NODE_CORPORATE_ENTITY: +''', + ''' > + {caption} + + + + ); + case NODE_CORPORATE_ENTITY: +''', + "related person semantic path", + ) + replace_once( + APP, + ''' > + {caption} + + + ); + case NODE_TEAM: +''', + ''' > + {caption} + + + + ); + case NODE_TEAM: +''', + "related entity semantic path", + ) + replace_once( + APP, + ''' > + {caption} + + + ); + default: { +''', + ''' > + {caption} + + + + ); + default: { +''', + "related team semantic path", + ) + replace_once( + APP, + '''
  • + {relatedNodeCaption(node)} +
  • +''', + '''
  • + {relatedNodeCaption(node)} + +
  • +''', + "landed related semantic path", + ) + + replace_once( + ONTOLOGY_TEST, + "from rdflib.namespace import RDFS, SKOS\n", + "from rdflib.namespace import OWL, RDFS, SKOS\n", + "ontology inverse import", + ) + replace_once( + ONTOLOGY_TEST, + '''def test_mentions_property_domain_and_range_match_the_schema() -> None: + """`mentions` goes Post -> Person, matching post_person_mention's + actual foreign keys -- not just any two classes.""" + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Post) in graph + assert (LW.mentions, RDFS.range, LW.Person) in graph +''', + '''def test_mentions_property_domain_and_range_match_canonical_edge_direction() -> None: + """``edge_mention`` is stored Person -> Post; OWL and its inverse agree.""" + + graph = load_ontology() + assert (LW.mentions, RDFS.domain, LW.Person) in graph + assert (LW.mentions, RDFS.range, LW.Post) in graph + assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph + assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph + assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph +''', + "ontology direction regression", + ) + replace_once( + PROJECTION_TEST, + ''' assert first_post_id in related_ids + assert second_post_id in related_ids + hydrated = await hydrate_related_nodes( +''', + ''' assert first_post_id in related_ids + assert second_post_id in related_ids + related_by_id = {node["node_id"]: node for node in related} + assert related_by_id[first_post_id]["semantic_path"] == [ + { + "from_node_type_code": NODE_TEAM, + "from_node_id": team_id, + "to_node_type_code": NODE_POST, + "to_node_id": first_post_id, + "edge_type_code": EDGE_MENTION_TEAM, + "traversal_direction": "forward", + "relationship_iri": "https://contextualwisdomlab.github.io/lineageweave/ontology#mentionsTeam", + "relationship_label": "mentioned in post", + } + ] + hydrated = await hydrate_related_nodes( +''', + "real PostgreSQL semantic path regression", + ) + + adr_path = next_adr_path() + write( + adr_path, + f'''# ADR {adr_path.name[:4]}: Ontology-grounded semantic relationship paths + +## Status + +Accepted + +## Context + +The Buyer related-node surface ranked evidence-gated knowledge-graph nodes with random walk with restart, but returned only each node class and a numeric relevance score. The relationship predicates traversed from the selected person, team, organization, or post disappeared, so the UI could only say `Graph relation`. The ontology also declared `edge_mention` as `Post -> Person`, while the canonical relational projection stores `Person -> Post`. + +## Decision + +- Keep the existing canonical `knowledge_graph_edge` direction. +- Correct the OWL domain/range for `edge_mention` to `Person -> Post` and declare explicit `owl:inverseOf` properties for direction-correct buyer traversal. +- Compute a deterministic shortest semantic path over the same positive, ABAC- and evidence-gated subgraph used by RWR. RWR still determines relevance; the path explains stored connectivity and is not a causal claim. +- Return every hop's endpoint identifiers, lookup code, traversal direction, ontology IRI, and ontology label. Unknown ontology terms remain absent rather than receiving guessed labels. +- Render the relationship path for post, person, team, and organization results. + +## Consequences + +The API change is additive. Existing consumers may ignore `semantic_path`. PostgreSQL remains the system of record; OWL supplies controlled terms and inverse semantics. A future RDF 1.2 export may annotate individual assertions with provenance, but this slice does not claim RDF 1.2 conformance or create unsupported facts. +''', + ) + write( + DOCTORING, + '''# Semantic relationship path references + +## Product traceability + +- `knowledge_graph_edge` is interpreted as a typed subject-predicate-object assertion whose canonical direction must match the OWL property's domain and range. +- `owl:inverseOf` supplies the predicate used when the buyer traverses a stored assertion in reverse. +- `knowledge_graph_edge_evidence` continues to gate the visible subgraph; semantic paths never bypass ABAC or invent provenance. +- RDF 1.2 triple terms and annotations are tracked for a future interoperable statement-provenance export; PostgreSQL remains authoritative in this release. + +## APA 7th references + +World Wide Web Consortium. (2012). *OWL 2 Web Ontology Language: Document overview (Second Edition).* https://www.w3.org/TR/owl2-overview/ + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology.* https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2026). *RDF 1.2 concepts and abstract data model* (Candidate Recommendation Snapshot, April 7, 2026). https://www.w3.org/TR/rdf12-concepts/ +''', + ) + write( + CHANGELOG, + '''### Fixed + +- Aligned `edge_mention` OWL domain/range with the canonical Person-to-Post graph direction and added explicit inverse relationship terms. +- Related knowledge-graph nodes now carry and render a deterministic ontology relationship path explaining why each buyer-visible result is connected. +''', + ) + + +def main() -> None: + """Dispatch the RED or GREEN phase.""" + + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("red", "apply")) + args = parser.parse_args() + if args.mode == "red": + write_red() + else: + apply() + + +if __name__ == "__main__": + main() From 06a5b37a54d1ddb4c28b042fcd0aec233ee9bee6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:20:17 -0700 Subject: [PATCH 23/63] ci: run test-first ontology semantic-path repair --- .../fix-264-ontology-semantic-path.yml | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/fix-264-ontology-semantic-path.yml diff --git a/.github/workflows/fix-264-ontology-semantic-path.yml b/.github/workflows/fix-264-ontology-semantic-path.yml new file mode 100644 index 000000000..4c4bf6f8d --- /dev/null +++ b/.github/workflows/fix-264-ontology-semantic-path.yml @@ -0,0 +1,124 @@ +name: Repair PR 264 ontology semantic path + +on: + push: + branches: + - feat/event-lineage-node-keeps-gnb-focus-v2170 + +permissions: + contents: write + +concurrency: + group: repair-pr-264-ontology-semantic-path + cancel-in-progress: false + +jobs: + red-green-fix: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 + steps: + - name: Checkout PR branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + persist-credentials: true + fetch-depth: 0 + + - name: Fetch live stack parent for collision-free ADR numbering + run: git fetch origin feat/analysis-run-name-evidence-lineage:refs/remotes/origin/feat/analysis-run-name-evidence-lineage + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Select pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install locked dependencies + run: | + corepack enable + uv sync --frozen --extra dev --extra backend + pnpm --dir frontend install --frozen-lockfile + python -m compileall -q .github/scripts/fix_pr264_semantic_path.py + + - name: Write RED regressions + run: python .github/scripts/fix_pr264_semantic_path.py red + + - name: Prove RED failures + shell: bash + run: | + set +e + uv run --frozen python -m pytest -q tests/test_semantic_relationship_paths.py + python_status=$? + pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx + frontend_status=$? + set -e + if [ "$python_status" -eq 0 ] || [ "$frontend_status" -eq 0 ]; then + echo "A semantic-path regression unexpectedly passed before implementation." >&2 + exit 1 + fi + + - name: Apply the narrow GREEN repair + run: python .github/scripts/fix_pr264_semantic_path.py apply + + - name: Verify focused Python contracts + run: | + uv run --frozen python -m pytest -q \ + tests/test_semantic_relationship_paths.py \ + tests/test_ontology.py \ + tests/test_knowledge_graph.py \ + tests/test_person_mention_projection.py \ + tests/test_documentation_hygiene.py + + - name: Verify focused frontend contract + run: pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx src/App.test.tsx + + - name: Verify complete Python contract + run: uv run --frozen python -m pytest -q + + - name: Verify complete frontend contract + run: | + pnpm --dir frontend run lint + pnpm --dir frontend run test + pnpm --dir frontend run build + pnpm --dir frontend run build-storybook + + - name: Publish the verified repair without overwriting concurrent work + shell: bash + run: | + rm -f .github/workflows/fix-264-ontology-semantic-path.yml + rm -f .github/scripts/fix_pr264_semantic_path.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix: expose ontology-grounded semantic relationship paths" + git push origin "HEAD:${REPAIR_BRANCH}" From 84319989716da98597ca52f3639cce49bea151fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:30:03 -0700 Subject: [PATCH 24/63] ci: move semantic-path repair out of PR 264 --- .../fix-264-ontology-semantic-path.yml | 124 ------------------ 1 file changed, 124 deletions(-) delete mode 100644 .github/workflows/fix-264-ontology-semantic-path.yml diff --git a/.github/workflows/fix-264-ontology-semantic-path.yml b/.github/workflows/fix-264-ontology-semantic-path.yml deleted file mode 100644 index 4c4bf6f8d..000000000 --- a/.github/workflows/fix-264-ontology-semantic-path.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: Repair PR 264 ontology semantic path - -on: - push: - branches: - - feat/event-lineage-node-keeps-gnb-focus-v2170 - -permissions: - contents: write - -concurrency: - group: repair-pr-264-ontology-semantic-path - cancel-in-progress: false - -jobs: - red-green-fix: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 - steps: - - name: Checkout PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - persist-credentials: true - fetch-depth: 0 - - - name: Fetch live stack parent for collision-free ADR numbering - run: git fetch origin feat/analysis-run-name-evidence-lineage:refs/remotes/origin/feat/analysis-run-name-evidence-lineage - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Select pinned Rust toolchain - run: | - rustup toolchain install 1.97.1 --profile minimal - rustup default 1.97.1 - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install locked dependencies - run: | - corepack enable - uv sync --frozen --extra dev --extra backend - pnpm --dir frontend install --frozen-lockfile - python -m compileall -q .github/scripts/fix_pr264_semantic_path.py - - - name: Write RED regressions - run: python .github/scripts/fix_pr264_semantic_path.py red - - - name: Prove RED failures - shell: bash - run: | - set +e - uv run --frozen python -m pytest -q tests/test_semantic_relationship_paths.py - python_status=$? - pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx - frontend_status=$? - set -e - if [ "$python_status" -eq 0 ] || [ "$frontend_status" -eq 0 ]; then - echo "A semantic-path regression unexpectedly passed before implementation." >&2 - exit 1 - fi - - - name: Apply the narrow GREEN repair - run: python .github/scripts/fix_pr264_semantic_path.py apply - - - name: Verify focused Python contracts - run: | - uv run --frozen python -m pytest -q \ - tests/test_semantic_relationship_paths.py \ - tests/test_ontology.py \ - tests/test_knowledge_graph.py \ - tests/test_person_mention_projection.py \ - tests/test_documentation_hygiene.py - - - name: Verify focused frontend contract - run: pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx src/App.test.tsx - - - name: Verify complete Python contract - run: uv run --frozen python -m pytest -q - - - name: Verify complete frontend contract - run: | - pnpm --dir frontend run lint - pnpm --dir frontend run test - pnpm --dir frontend run build - pnpm --dir frontend run build-storybook - - - name: Publish the verified repair without overwriting concurrent work - shell: bash - run: | - rm -f .github/workflows/fix-264-ontology-semantic-path.yml - rm -f .github/scripts/fix_pr264_semantic_path.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix: expose ontology-grounded semantic relationship paths" - git push origin "HEAD:${REPAIR_BRANCH}" From 4e601f6634ff97fe51924f3db0064ff0aea43124 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:30:14 -0700 Subject: [PATCH 25/63] ci: remove relocated semantic-path repair helper --- .github/scripts/fix_pr264_semantic_path.py | 817 --------------------- 1 file changed, 817 deletions(-) delete mode 100644 .github/scripts/fix_pr264_semantic_path.py diff --git a/.github/scripts/fix_pr264_semantic_path.py b/.github/scripts/fix_pr264_semantic_path.py deleted file mode 100644 index 3c8b4be63..000000000 --- a/.github/scripts/fix_pr264_semantic_path.py +++ /dev/null @@ -1,817 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the test-first PR 264 ontology relationship-path repair.""" - -from __future__ import annotations - -import argparse -import re -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -ONTOLOGY = ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" -ONTOLOGY_MODULE = ROOT / "lineageweave" / "ontology.py" -GRAPH_MODULE = ROOT / "lineageweave" / "knowledge_graph.py" -BACKEND_GRAPH = ROOT / "backend" / "app" / "knowledge_graph.py" -API_TYPES = ROOT / "frontend" / "src" / "api.ts" -APP = ROOT / "frontend" / "src" / "App.tsx" -ONTOLOGY_TEST = ROOT / "tests" / "test_ontology.py" -PROJECTION_TEST = ROOT / "tests" / "test_person_mention_projection.py" -PYTHON_RED = ROOT / "tests" / "test_semantic_relationship_paths.py" -FRONTEND_COMPONENT = ROOT / "frontend" / "src" / "components" / "RelatedSemanticPath.tsx" -FRONTEND_RED = ROOT / "frontend" / "src" / "components" / "RelatedSemanticPath.test.tsx" -DOCTORING = ROOT / "docs" / "doctoring" / "SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md" -CHANGELOG = ROOT / "CHANGELOG.d" / "2.17.0-ontology-semantic-path.md" - - -def read(path: Path) -> str: - """Read one UTF-8 repository file.""" - - return path.read_text(encoding="utf-8") - - -def write(path: Path, content: str) -> None: - """Write one UTF-8 repository file, creating its parent directory.""" - - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -def replace_once(path: Path, old: str, new: str, label: str) -> None: - """Replace exactly one anchor, while remaining idempotent after success.""" - - text = read(path) - if new in text: - return - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one anchor in {path}, got {count}") - write(path, text.replace(old, new, 1)) - - -def write_red() -> None: - """Write regressions that fail before the production semantic contract exists.""" - - write( - PYTHON_RED, - '''"""Regressions for ontology-aligned, buyer-visible KG relationship paths.""" - -from rdflib.namespace import OWL, RDFS - -from lineageweave.knowledge_graph import ( - EDGE_AFFILIATION, - EDGE_CO_MENTION, - EDGE_MENTION, - NODE_CORPORATE_ENTITY, - NODE_PERSON, - NODE_POST, - KnowledgeGraphEdgeSpec, - node_key, - semantic_paths_from_edges, -) -from lineageweave.ontology import LW, load_ontology, relationship_annotations - - -def test_person_mention_ontology_matches_canonical_edge_direction() -> None: - """The relational edge is Person -> Post, so OWL must say the same.""" - - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph - assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph - assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph - - -def test_relationship_annotations_select_the_inverse_buyer_label() -> None: - """Reverse traversal must expose the inverse property, not a false direction.""" - - assert relationship_annotations(EDGE_MENTION) == { - "relationship_iri": str(LW.mentions), - "relationship_label": "mentioned in post", - } - assert relationship_annotations(EDGE_MENTION, reverse=True) == { - "relationship_iri": str(LW.postMentionsPerson), - "relationship_label": "mentions person", - } - assert relationship_annotations(EDGE_CO_MENTION, reverse=True) == { - "relationship_iri": str(LW.coMentionedWith), - "relationship_label": "co-mentioned with", - } - assert relationship_annotations("not_a_real_lookup_code") == {} - - -def test_semantic_paths_are_shortest_directional_and_deterministic() -> None: - """A path explains the stored graph without depending on edge input order.""" - - edges = [ - KnowledgeGraphEdgeSpec( - NODE_PERSON, - "person-a", - NODE_POST, - "post-1", - EDGE_MENTION, - ), - KnowledgeGraphEdgeSpec( - NODE_PERSON, - "person-b", - NODE_POST, - "post-1", - EDGE_MENTION, - ), - KnowledgeGraphEdgeSpec( - NODE_PERSON, - "person-a", - NODE_CORPORATE_ENTITY, - "corp-1", - EDGE_AFFILIATION, - ), - ] - start = node_key(NODE_PERSON, "person-a") - paths = semantic_paths_from_edges(list(reversed(edges)), start) - - post_path = paths[node_key(NODE_POST, "post-1")] - assert [ - ( - hop.from_node_id, - hop.edge_type_code, - hop.to_node_id, - hop.traversal_direction, - ) - for hop in post_path - ] == [("person-a", EDGE_MENTION, "post-1", "forward")] - - other_person_path = paths[node_key(NODE_PERSON, "person-b")] - assert [ - ( - hop.from_node_id, - hop.edge_type_code, - hop.to_node_id, - hop.traversal_direction, - ) - for hop in other_person_path - ] == [ - ("person-a", EDGE_MENTION, "post-1", "forward"), - ("post-1", EDGE_MENTION, "person-b", "reverse"), - ] - assert paths[node_key(NODE_CORPORATE_ENTITY, "corp-1")][0].edge_type_code == EDGE_AFFILIATION -''', - ) - write( - FRONTEND_RED, - '''import { render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; -import { setLocale } from "../i18n"; -import { RelatedSemanticPath, semanticPathText } from "./RelatedSemanticPath"; - -const semanticPath = [ - { - from_node_type_code: "node_person", - from_node_id: "person-a", - to_node_type_code: "node_post", - to_node_id: "post-1", - edge_type_code: "edge_mention", - traversal_direction: "forward" as const, - relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", - relationship_label: "mentioned in post", - }, - { - from_node_type_code: "node_post", - from_node_id: "post-1", - to_node_type_code: "node_person", - to_node_id: "person-b", - edge_type_code: "edge_mention", - traversal_direction: "reverse" as const, - relationship_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#postMentionsPerson", - relationship_label: "mentions person", - }, -]; - -describe("RelatedSemanticPath", () => { - beforeEach(() => setLocale("en")); - - it("renders the ontology relationship path instead of generic graph copy", () => { - render(); - expect(screen.getByText("mentioned in post → mentions person")).toBeInTheDocument(); - expect(semanticPathText(undefined)).toBe("Graph relation"); - }); -}); -''', - ) - - -def next_adr_path() -> Path: - """Choose the next ADR number across this branch and the live stack parent.""" - - names = [path.name for path in (ROOT / "docs" / "adr").glob("[0-9][0-9][0-9][0-9]-*.md")] - try: - result = subprocess.run( - [ - "git", - "ls-tree", - "-r", - "--name-only", - "origin/feat/analysis-run-name-evidence-lineage", - "docs/adr", - ], - cwd=ROOT, - check=True, - capture_output=True, - text=True, - ) - names.extend(Path(line).name for line in result.stdout.splitlines()) - except subprocess.CalledProcessError: - pass - numbers = [ - int(match.group(1)) - for name in names - if (match := re.match(r"^(\d{4})-", name)) is not None - ] - number = max(numbers, default=0) + 1 - return ROOT / "docs" / "adr" / f"{number:04d}-ontology-grounded-semantic-relationship-path.md" - - -def apply() -> None: - """Apply the narrow ontology, API, and buyer-surface implementation.""" - - replace_once( - ONTOLOGY, - ''':mentions a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Person ; - rdfs:label "mentions" ; - rdfs:comment "A post names a person (post_person_mention)." ; - :lookupCode "edge_mention" . -''', - ''':mentions a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "Canonical knowledge_graph_edge direction for post_person_mention: the cataloged person points to the evidence post." ; - owl:inverseOf :postMentionsPerson ; - :lookupCode "edge_mention" . - -:postMentionsPerson a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Person ; - rdfs:label "mentions person" ; - rdfs:comment "Buyer-facing inverse of :mentions: a post names a cataloged person." ; - owl:inverseOf :mentions . -''', - "person mention ontology direction", - ) - replace_once( - ONTOLOGY, - ''':affiliatedWith a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :CorporateEntity ; - rdfs:label "affiliated with" ; - rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; - :lookupCode "edge_affiliation" . -''', - ''':affiliatedWith a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :CorporateEntity ; - rdfs:label "affiliated with" ; - rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; - owl:inverseOf :hasAffiliatedPerson ; - :lookupCode "edge_affiliation" . - -:hasAffiliatedPerson a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Person ; - rdfs:label "has affiliated person" ; - owl:inverseOf :affiliatedWith . -''', - "person affiliation inverse", - ) - replace_once( - ONTOLOGY, - ''':mentionsTeam a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; - :lookupCode "edge_mention_team" . -''', - ''':mentionsTeam a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; - owl:inverseOf :postMentionsTeam ; - :lookupCode "edge_mention_team" . - -:postMentionsTeam a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Team ; - rdfs:label "mentions team" ; - owl:inverseOf :mentionsTeam . -''', - "team mention inverse", - ) - replace_once( - ONTOLOGY, - ''':teamAffiliatedWith a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :CorporateEntity ; - rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; - :lookupCode "edge_team_affiliation" . -''', - ''':teamAffiliatedWith a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :CorporateEntity ; - rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; - owl:inverseOf :hasAffiliatedTeam ; - :lookupCode "edge_team_affiliation" . - -:hasAffiliatedTeam a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Team ; - rdfs:label "has affiliated team" ; - owl:inverseOf :teamAffiliatedWith . -''', - "team affiliation inverse", - ) - replace_once( - ONTOLOGY, - ''':mentionsOrganization a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; - :lookupCode "edge_mention_organization" . -''', - ''':mentionsOrganization a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; - owl:inverseOf :postMentionsOrganization ; - :lookupCode "edge_mention_organization" . - -:postMentionsOrganization a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; - rdfs:label "mentions organization" ; - owl:inverseOf :mentionsOrganization . -''', - "organization mention inverse", - ) - - replace_once( - ONTOLOGY_MODULE, - "def all_declared_lookup_codes() -> set[str]:\n", - '''def relationship_annotations(lookup_code: str, *, reverse: bool = False) -> dict[str, str]: - """Return the ontology predicate IRI and direction-correct label. - - ``knowledge_graph_edge`` stores one canonical direction. Reverse graph - traversal selects the declared ``owl:inverseOf`` property. Symmetric - properties retain their own term. Missing terms stay missing rather than - receiving guessed semantics. - """ - - subject = _term_subject(lookup_code) - if subject is None: - return {} - relationship = subject - if reverse and (subject, RDF.type, OWL.SymmetricProperty) not in ONTOLOGY: - relationship = ONTOLOGY.value(subject, OWL.inverseOf) - if relationship is None: - relationship = next(ONTOLOGY.subjects(OWL.inverseOf, subject), None) - if relationship is None: - relationship = subject - fields = {"relationship_iri": str(relationship)} - label = ONTOLOGY.value(relationship, RDFS.label) - if label is not None: - fields["relationship_label"] = str(label) - return fields - - -def all_declared_lookup_codes() -> set[str]: -''', - "relationship ontology annotations", - ) - replace_once( - ONTOLOGY_MODULE, - ' "ontology_annotations",\n]\n', - ' "ontology_annotations",\n "relationship_annotations",\n]\n', - "ontology module export", - ) - - replace_once( - GRAPH_MODULE, - "from collections import defaultdict\n", - "from collections import defaultdict, deque\n", - "semantic path deque import", - ) - replace_once( - GRAPH_MODULE, - "from typing import Sequence\n", - "from typing import Literal, Sequence\n", - "semantic path type import", - ) - replace_once( - GRAPH_MODULE, - "def node_key(node_type_code: str, node_id: str) -> str:\n", - '''@dataclass(frozen=True) -class KnowledgeGraphPathHop: - """One direction-aware traversal step through a stored KG assertion.""" - - from_node_type_code: str - from_node_id: str - edge_type_code: str - to_node_type_code: str - to_node_id: str - traversal_direction: Literal["forward", "reverse"] - edge_weight: float = 1.0 - - -def node_key(node_type_code: str, node_id: str) -> str: -''', - "semantic path hop contract", - ) - replace_once( - GRAPH_MODULE, - "def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency:\n", - '''def semantic_paths_from_edges( - edges: Sequence[KnowledgeGraphEdgeSpec], - start_node: str, -) -> dict[str, tuple[KnowledgeGraphPathHop, ...]]: - """Return deterministic shortest relationship paths from ``start_node``. - - RWR determines which nodes are relevant. This breadth-first projection - explains why each reachable node is connected using the same positive, - evidence-gated edges. Ties prefer higher edge weight, then stable typed - identifiers. The path describes stored connectivity and is not causal. - """ - - incident: dict[str, list[tuple[str, KnowledgeGraphPathHop]]] = defaultdict(list) - for edge in edges: - weight = float(edge.edge_weight) - if not (weight > 0 and weight == weight): - continue - source = node_key(edge.source_node_type_code, edge.source_node_id) - target = node_key(edge.target_node_type_code, edge.target_node_id) - incident[source].append( - ( - target, - KnowledgeGraphPathHop( - from_node_type_code=edge.source_node_type_code, - from_node_id=edge.source_node_id, - edge_type_code=edge.edge_type_code, - to_node_type_code=edge.target_node_type_code, - to_node_id=edge.target_node_id, - traversal_direction="forward", - edge_weight=weight, - ), - ) - ) - incident[target].append( - ( - source, - KnowledgeGraphPathHop( - from_node_type_code=edge.target_node_type_code, - from_node_id=edge.target_node_id, - edge_type_code=edge.edge_type_code, - to_node_type_code=edge.source_node_type_code, - to_node_id=edge.source_node_id, - traversal_direction="reverse", - edge_weight=weight, - ), - ) - ) - for hops in incident.values(): - hops.sort( - key=lambda item: ( - -item[1].edge_weight, - item[1].edge_type_code, - item[1].to_node_type_code, - item[1].to_node_id, - item[1].traversal_direction, - ) - ) - - paths: dict[str, tuple[KnowledgeGraphPathHop, ...]] = {start_node: ()} - pending: deque[str] = deque([start_node]) - while pending: - current = pending.popleft() - for neighbor, hop in incident.get(current, []): - if neighbor in paths: - continue - paths[neighbor] = (*paths[current], hop) - pending.append(neighbor) - return paths - - -def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: -''', - "semantic shortest paths", - ) - - replace_once( - BACKEND_GRAPH, - "from lineageweave.ontology import ontology_annotations\n", - "from lineageweave.ontology import ontology_annotations, relationship_annotations\n", - "backend relationship annotation import", - ) - replace_once( - BACKEND_GRAPH, - " select_related_nodes,\n)\n", - " select_related_nodes,\n semantic_paths_from_edges,\n)\n", - "backend semantic path import", - ) - replace_once( - BACKEND_GRAPH, - ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) - related = select_related_nodes(scores, start_node=start) - return await hydrate_related_nodes(conn, related) -''', - ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) - related = select_related_nodes(scores, start_node=start) - semantic_paths = semantic_paths_from_edges(edges, start) - payload = await hydrate_related_nodes(conn, related) - for item in payload: - related_key = node_key(item["node_type_code"], item["node_id"]) - item["semantic_path"] = [ - { - "from_node_type_code": hop.from_node_type_code, - "from_node_id": hop.from_node_id, - "to_node_type_code": hop.to_node_type_code, - "to_node_id": hop.to_node_id, - "edge_type_code": hop.edge_type_code, - "traversal_direction": hop.traversal_direction, - **relationship_annotations( - hop.edge_type_code, - reverse=hop.traversal_direction == "reverse", - ), - } - for hop in semantic_paths.get(related_key, ()) - ] - return payload -''', - "buyer semantic path payload", - ) - - replace_once( - API_TYPES, - "export interface RelatedNode {\n", - '''export interface SemanticPathHop { - from_node_type_code: RelatedNodeType | string; - from_node_id: string; - to_node_type_code: RelatedNodeType | string; - to_node_id: string; - edge_type_code: string; - traversal_direction: "forward" | "reverse"; - relationship_iri?: string; - relationship_label?: string; -} - -export interface RelatedNode { -''', - "frontend semantic path type", - ) - replace_once( - API_TYPES, - " ontology_label?: string;\n}\n\nexport interface PostRoleResponsibility", - " ontology_label?: string;\n semantic_path?: SemanticPathHop[];\n}\n\nexport interface PostRoleResponsibility", - "related node semantic path", - ) - - write( - FRONTEND_COMPONENT, - '''import type { SemanticPathHop } from "../api"; -import { t } from "../i18n"; - -export function semanticPathText(semanticPath?: SemanticPathHop[]): string { - const labels = (semanticPath ?? []) - .map((hop) => t(hop.relationship_label ?? hop.edge_type_code)) - .filter((label) => label.length > 0); - return labels.length > 0 ? labels.join(" → ") : t("Graph relation"); -} - -export function RelatedSemanticPath({ - semanticPath, - className = "related-post-kind", -}: { - semanticPath?: SemanticPathHop[]; - className?: string; -}) { - const text = semanticPathText(semanticPath); - return ( - - {text} - - ); -} -''', - ) - - replace_once( - APP, - 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', - 'import { PopupCloseButton } from "./components/PopupCloseButton";\nimport { RelatedSemanticPath } from "./components/RelatedSemanticPath";\n', - "semantic path component import", - ) - replace_once( - APP, - '{t("Graph relation")}', - '', - "related post semantic path", - ) - replace_once( - APP, - ''' > - {caption} - - - ); - case NODE_CORPORATE_ENTITY: -''', - ''' > - {caption} - - - - ); - case NODE_CORPORATE_ENTITY: -''', - "related person semantic path", - ) - replace_once( - APP, - ''' > - {caption} - - - ); - case NODE_TEAM: -''', - ''' > - {caption} - - - - ); - case NODE_TEAM: -''', - "related entity semantic path", - ) - replace_once( - APP, - ''' > - {caption} - - - ); - default: { -''', - ''' > - {caption} - - - - ); - default: { -''', - "related team semantic path", - ) - replace_once( - APP, - '''
  • - {relatedNodeCaption(node)} -
  • -''', - '''
  • - {relatedNodeCaption(node)} - -
  • -''', - "landed related semantic path", - ) - - replace_once( - ONTOLOGY_TEST, - "from rdflib.namespace import RDFS, SKOS\n", - "from rdflib.namespace import OWL, RDFS, SKOS\n", - "ontology inverse import", - ) - replace_once( - ONTOLOGY_TEST, - '''def test_mentions_property_domain_and_range_match_the_schema() -> None: - """`mentions` goes Post -> Person, matching post_person_mention's - actual foreign keys -- not just any two classes.""" - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Post) in graph - assert (LW.mentions, RDFS.range, LW.Person) in graph -''', - '''def test_mentions_property_domain_and_range_match_canonical_edge_direction() -> None: - """``edge_mention`` is stored Person -> Post; OWL and its inverse agree.""" - - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph - assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph - assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph -''', - "ontology direction regression", - ) - replace_once( - PROJECTION_TEST, - ''' assert first_post_id in related_ids - assert second_post_id in related_ids - hydrated = await hydrate_related_nodes( -''', - ''' assert first_post_id in related_ids - assert second_post_id in related_ids - related_by_id = {node["node_id"]: node for node in related} - assert related_by_id[first_post_id]["semantic_path"] == [ - { - "from_node_type_code": NODE_TEAM, - "from_node_id": team_id, - "to_node_type_code": NODE_POST, - "to_node_id": first_post_id, - "edge_type_code": EDGE_MENTION_TEAM, - "traversal_direction": "forward", - "relationship_iri": "https://contextualwisdomlab.github.io/lineageweave/ontology#mentionsTeam", - "relationship_label": "mentioned in post", - } - ] - hydrated = await hydrate_related_nodes( -''', - "real PostgreSQL semantic path regression", - ) - - adr_path = next_adr_path() - write( - adr_path, - f'''# ADR {adr_path.name[:4]}: Ontology-grounded semantic relationship paths - -## Status - -Accepted - -## Context - -The Buyer related-node surface ranked evidence-gated knowledge-graph nodes with random walk with restart, but returned only each node class and a numeric relevance score. The relationship predicates traversed from the selected person, team, organization, or post disappeared, so the UI could only say `Graph relation`. The ontology also declared `edge_mention` as `Post -> Person`, while the canonical relational projection stores `Person -> Post`. - -## Decision - -- Keep the existing canonical `knowledge_graph_edge` direction. -- Correct the OWL domain/range for `edge_mention` to `Person -> Post` and declare explicit `owl:inverseOf` properties for direction-correct buyer traversal. -- Compute a deterministic shortest semantic path over the same positive, ABAC- and evidence-gated subgraph used by RWR. RWR still determines relevance; the path explains stored connectivity and is not a causal claim. -- Return every hop's endpoint identifiers, lookup code, traversal direction, ontology IRI, and ontology label. Unknown ontology terms remain absent rather than receiving guessed labels. -- Render the relationship path for post, person, team, and organization results. - -## Consequences - -The API change is additive. Existing consumers may ignore `semantic_path`. PostgreSQL remains the system of record; OWL supplies controlled terms and inverse semantics. A future RDF 1.2 export may annotate individual assertions with provenance, but this slice does not claim RDF 1.2 conformance or create unsupported facts. -''', - ) - write( - DOCTORING, - '''# Semantic relationship path references - -## Product traceability - -- `knowledge_graph_edge` is interpreted as a typed subject-predicate-object assertion whose canonical direction must match the OWL property's domain and range. -- `owl:inverseOf` supplies the predicate used when the buyer traverses a stored assertion in reverse. -- `knowledge_graph_edge_evidence` continues to gate the visible subgraph; semantic paths never bypass ABAC or invent provenance. -- RDF 1.2 triple terms and annotations are tracked for a future interoperable statement-provenance export; PostgreSQL remains authoritative in this release. - -## APA 7th references - -World Wide Web Consortium. (2012). *OWL 2 Web Ontology Language: Document overview (Second Edition).* https://www.w3.org/TR/owl2-overview/ - -World Wide Web Consortium. (2013). *PROV-O: The PROV ontology.* https://www.w3.org/TR/prov-o/ - -World Wide Web Consortium. (2026). *RDF 1.2 concepts and abstract data model* (Candidate Recommendation Snapshot, April 7, 2026). https://www.w3.org/TR/rdf12-concepts/ -''', - ) - write( - CHANGELOG, - '''### Fixed - -- Aligned `edge_mention` OWL domain/range with the canonical Person-to-Post graph direction and added explicit inverse relationship terms. -- Related knowledge-graph nodes now carry and render a deterministic ontology relationship path explaining why each buyer-visible result is connected. -''', - ) - - -def main() -> None: - """Dispatch the RED or GREEN phase.""" - - parser = argparse.ArgumentParser() - parser.add_argument("mode", choices=("red", "apply")) - args = parser.parse_args() - if args.mode == "red": - write_red() - else: - apply() - - -if __name__ == "__main__": - main() From 757757be46f5159595b0c724a4545da4b3a00c56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:32:27 -0700 Subject: [PATCH 26/63] ci: consolidate PR 264 Knowledge Graph semantic repair --- .../scripts/apply_ontology_semantic_path.py | 506 ------------------ .../fix_264_knowledge_semantics.py.gz.b64.00 | 1 + .../fix_264_knowledge_semantics.py.gz.b64.01 | 1 + .../fix_264_knowledge_semantics.py.gz.b64.02 | 1 + .../apply-ontology-semantic-path.yml | 129 ----- .../repair-264-knowledge-semantics.yml | 164 ++++++ 6 files changed, 167 insertions(+), 635 deletions(-) delete mode 100644 .github/scripts/apply_ontology_semantic_path.py create mode 100644 .github/scripts/fix_264_knowledge_semantics.py.gz.b64.00 create mode 100644 .github/scripts/fix_264_knowledge_semantics.py.gz.b64.01 create mode 100644 .github/scripts/fix_264_knowledge_semantics.py.gz.b64.02 delete mode 100644 .github/workflows/apply-ontology-semantic-path.yml create mode 100644 .github/workflows/repair-264-knowledge-semantics.yml diff --git a/.github/scripts/apply_ontology_semantic_path.py b/.github/scripts/apply_ontology_semantic_path.py deleted file mode 100644 index c140727d5..000000000 --- a/.github/scripts/apply_ontology_semantic_path.py +++ /dev/null @@ -1,506 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the ontology-aligned buyer semantic-path repair for PR #264.""" - -from __future__ import annotations - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - - -def read(path: str) -> str: - """Read one repository text file.""" - return (ROOT / path).read_text(encoding="utf-8") - - -def write(path: str, content: str) -> None: - """Write one repository text file, creating parents when needed.""" - target = ROOT / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact source anchor, accepting an already-applied patch.""" - text = read(path) - if new in text: - return - if text.count(old) != 1: - raise SystemExit(f"repair anchor mismatch in {path}: {old[:96]!r}") - write(path, text.replace(old, new, 1)) - - -def apply() -> None: - """Apply the smallest complete KG → ontology → API → buyer UI repair.""" - ttl = "docs/ontology/lineageweave-kg.ttl" - replace_once( - ttl, - ''':mentions a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Person ; - rdfs:label "mentions" ; - rdfs:comment "A post names a person (post_person_mention)." ; - :lookupCode "edge_mention" . -''', - ''':mentions a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "Canonical knowledge_graph_edge direction for post_person_mention: the cataloged person points to the evidence post." ; - owl:inverseOf :postMentionsPerson ; - :lookupCode "edge_mention" . - -:postMentionsPerson a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Person ; - rdfs:label "mentions person" ; - rdfs:comment "Buyer-facing inverse of :mentions: a post names a cataloged person." ; - owl:inverseOf :mentions . -''', - ) - for old, new in ( - ( - ''':affiliatedWith a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :CorporateEntity ; - rdfs:label "affiliated with" ; - rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; - :lookupCode "edge_affiliation" . -''', - ''':affiliatedWith a owl:ObjectProperty ; - rdfs:domain :Person ; - rdfs:range :CorporateEntity ; - rdfs:label "affiliated with" ; - rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; - owl:inverseOf :hasAffiliatedPerson ; - :lookupCode "edge_affiliation" . - -:hasAffiliatedPerson a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Person ; - rdfs:label "has affiliated person" ; - owl:inverseOf :affiliatedWith . -''', - ), - ( - ''':mentionsTeam a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; - :lookupCode "edge_mention_team" . -''', - ''':mentionsTeam a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; - owl:inverseOf :postMentionsTeam ; - :lookupCode "edge_mention_team" . - -:postMentionsTeam a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :Team ; - rdfs:label "mentions team" ; - owl:inverseOf :mentionsTeam . -''', - ), - ( - ''':teamAffiliatedWith a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :CorporateEntity ; - rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; - :lookupCode "edge_team_affiliation" . -''', - ''':teamAffiliatedWith a owl:ObjectProperty ; - rdfs:domain :Team ; - rdfs:range :CorporateEntity ; - rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; - owl:inverseOf :hasAffiliatedTeam ; - :lookupCode "edge_team_affiliation" . - -:hasAffiliatedTeam a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Team ; - rdfs:label "has affiliated team" ; - owl:inverseOf :teamAffiliatedWith . -''', - ), - ( - ''':mentionsOrganization a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; - :lookupCode "edge_mention_organization" . -''', - ''':mentionsOrganization a owl:ObjectProperty ; - rdfs:domain :CorporateEntity ; - rdfs:range :Post ; - rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; - owl:inverseOf :postMentionsOrganization ; - :lookupCode "edge_mention_organization" . - -:postMentionsOrganization a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; - rdfs:label "mentions organization" ; - owl:inverseOf :mentionsOrganization . -''', - ), - ): - replace_once(ttl, old, new) - - ontology = "lineageweave/ontology.py" - replace_once( - ontology, - "def all_declared_lookup_codes() -> set[str]:\n", - '''def relationship_annotations(lookup_code: str, *, reverse: bool = False) -> dict[str, str]: - """Return the OWL relationship IRI and buyer-facing label for a traversal.""" - subject = _term_subject(lookup_code) - if subject is None: - return {} - relationship = subject - if reverse and (subject, RDF.type, OWL.SymmetricProperty) not in ONTOLOGY: - relationship = ONTOLOGY.value(subject, OWL.inverseOf) - if relationship is None: - relationship = next(ONTOLOGY.subjects(OWL.inverseOf, subject), None) - if relationship is None: - relationship = subject - fields = {"relationship_iri": str(relationship)} - label = ONTOLOGY.value(relationship, RDFS.label) - if label is not None: - fields["relationship_label"] = str(label) - return fields - - -def all_declared_lookup_codes() -> set[str]: -''', - ) - replace_once( - ontology, - ' "ontology_annotations",\n]', - ' "ontology_annotations",\n "relationship_annotations",\n]', - ) - - graph = "lineageweave/knowledge_graph.py" - replace_once(graph, "from collections import defaultdict\n", "from collections import defaultdict, deque\n") - replace_once(graph, "from typing import Sequence\n", "from typing import Literal, Sequence\n") - replace_once( - graph, - "def node_key(node_type_code: str, node_id: str) -> str:\n", - '''@dataclass(frozen=True) -class KnowledgeGraphPathHop: - """One ontology-bearing traversal step through a stored KG edge.""" - - from_node_type_code: str - from_node_id: str - edge_type_code: str - to_node_type_code: str - to_node_id: str - traversal_direction: Literal["forward", "reverse"] - edge_weight: float = 1.0 - - -def node_key(node_type_code: str, node_id: str) -> str: -''', - ) - replace_once( - graph, - "def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency:\n", - '''def semantic_paths_from_edges( - edges: Sequence[KnowledgeGraphEdgeSpec], - start_node: str, -) -> dict[str, tuple[KnowledgeGraphPathHop, ...]]: - """Return deterministic shortest relationship paths over positive stored edges.""" - incident: dict[str, list[tuple[str, KnowledgeGraphPathHop]]] = defaultdict(list) - for edge in edges: - weight = float(edge.edge_weight) - if not (weight > 0 and weight == weight): - continue - source = node_key(edge.source_node_type_code, edge.source_node_id) - target = node_key(edge.target_node_type_code, edge.target_node_id) - incident[source].append((target, KnowledgeGraphPathHop( - edge.source_node_type_code, edge.source_node_id, edge.edge_type_code, - edge.target_node_type_code, edge.target_node_id, "forward", weight, - ))) - incident[target].append((source, KnowledgeGraphPathHop( - edge.target_node_type_code, edge.target_node_id, edge.edge_type_code, - edge.source_node_type_code, edge.source_node_id, "reverse", weight, - ))) - for hops in incident.values(): - hops.sort(key=lambda item: (-item[1].edge_weight, item[1].edge_type_code, - item[1].to_node_type_code, item[1].to_node_id, - item[1].traversal_direction)) - paths: dict[str, tuple[KnowledgeGraphPathHop, ...]] = {start_node: ()} - pending: deque[str] = deque([start_node]) - while pending: - current = pending.popleft() - for neighbor, hop in incident.get(current, []): - if neighbor in paths: - continue - paths[neighbor] = (*paths[current], hop) - pending.append(neighbor) - return paths - - -def adjacency_from_edges(edges: Sequence[KnowledgeGraphEdgeSpec]) -> Adjacency: -''', - ) - - backend = "backend/app/knowledge_graph.py" - replace_once( - backend, - "from lineageweave.ontology import ontology_annotations\n", - "from lineageweave.ontology import ontology_annotations, relationship_annotations\n", - ) - replace_once( - backend, - " select_related_nodes,\n)", - " select_related_nodes,\n semantic_paths_from_edges,\n)", - ) - replace_once( - backend, - ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) - related = select_related_nodes(scores, start_node=start) - return await hydrate_related_nodes(conn, related) -''', - ''' scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) - related = select_related_nodes(scores, start_node=start) - semantic_paths = semantic_paths_from_edges(edges, start) - payload = await hydrate_related_nodes(conn, related) - for item in payload: - related_key = node_key(item["node_type_code"], item["node_id"]) - item["semantic_path"] = [ - { - "from_node_type_code": hop.from_node_type_code, - "to_node_type_code": hop.to_node_type_code, - "edge_type_code": hop.edge_type_code, - "traversal_direction": hop.traversal_direction, - **relationship_annotations( - hop.edge_type_code, - reverse=hop.traversal_direction == "reverse", - ), - } - for hop in semantic_paths.get(related_key, ()) - ] - return payload -''', - ) - - api = "frontend/src/api.ts" - replace_once( - api, - '''export interface RelatedNode { - node_id: string; - node_type_code: RelatedNodeType | string; - relevance: number; - label?: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - person_side_code?: string; - person_side_label?: string; - ontology_iri?: string; - ontology_label?: string; -} -''', - '''export interface SemanticPathHop { - from_node_type_code: RelatedNodeType | string; - to_node_type_code: RelatedNodeType | string; - edge_type_code: string; - traversal_direction: "forward" | "reverse"; - relationship_iri?: string; - relationship_label?: string; -} - -export interface RelatedNode { - node_id: string; - node_type_code: RelatedNodeType | string; - relevance: number; - label?: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - person_side_code?: string; - person_side_label?: string; - ontology_iri?: string; - ontology_label?: string; - semantic_path?: SemanticPathHop[]; -} -''', - ) - write( - "frontend/src/components/RelatedSemanticPath.tsx", - '''import type { SemanticPathHop } from "../api"; -import { t } from "../i18n"; - -export function semanticPathText(semanticPath?: SemanticPathHop[]): string { - const labels = (semanticPath ?? []) - .map((hop) => hop.relationship_label ?? hop.edge_type_code) - .filter((label) => label.length > 0); - return labels.length > 0 ? labels.join(" → ") : t("Graph relation"); -} - -export function RelatedSemanticPath({ semanticPath }: { semanticPath?: SemanticPathHop[] }) { - const text = semanticPathText(semanticPath); - return {text}; -} -''', - ) - replace_once( - "frontend/src/App.tsx", - 'import { PopupCloseButton } from "./components/PopupCloseButton";\n', - 'import { PopupCloseButton } from "./components/PopupCloseButton";\nimport { RelatedSemanticPath } from "./components/RelatedSemanticPath";\n', - ) - replace_once( - "frontend/src/App.tsx", - '{t("Graph relation")}', - '', - ) - - replace_once( - "tests/test_ontology.py", - "from rdflib.namespace import RDFS, SKOS\n", - "from rdflib.namespace import OWL, RDFS, SKOS\n", - ) - replace_once( - "tests/test_ontology.py", - '''def test_mentions_property_domain_and_range_match_the_schema() -> None: - """`mentions` goes Post -> Person, matching post_person_mention's - actual foreign keys -- not just any two classes.""" - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Post) in graph - assert (LW.mentions, RDFS.range, LW.Person) in graph -''', - '''def test_mentions_property_domain_and_range_match_canonical_edge_direction() -> None: - """``edge_mention`` is stored Person -> Post; OWL and its inverse agree.""" - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph - assert (LW.postMentionsPerson, RDFS.domain, LW.Post) in graph - assert (LW.postMentionsPerson, RDFS.range, LW.Person) in graph -''', - ) - - write( - "tests/test_semantic_relationship_paths.py", - '''"""Regressions for ontology-aligned buyer-visible KG relationship paths.""" - -from rdflib.namespace import OWL, RDFS - -from lineageweave.knowledge_graph import ( - EDGE_AFFILIATION, - EDGE_CO_MENTION, - EDGE_MENTION, - NODE_CORPORATE_ENTITY, - NODE_PERSON, - NODE_POST, - KnowledgeGraphEdgeSpec, - node_key, - semantic_paths_from_edges, -) -from lineageweave.ontology import LW, load_ontology, relationship_annotations - - -def test_person_mention_ontology_matches_canonical_edge_direction() -> None: - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - assert (LW.mentions, OWL.inverseOf, LW.postMentionsPerson) in graph - - -def test_relationship_annotations_use_inverse_buyer_label() -> None: - assert relationship_annotations(EDGE_MENTION)["relationship_label"] == "mentioned in post" - assert relationship_annotations(EDGE_MENTION, reverse=True)["relationship_label"] == "mentions person" - assert relationship_annotations(EDGE_CO_MENTION, reverse=True)["relationship_label"] == "co-mentioned with" - assert relationship_annotations("not_a_real_lookup_code") == {} - - -def test_semantic_paths_are_shortest_directional_and_deterministic() -> None: - edges = [ - KnowledgeGraphEdgeSpec(NODE_PERSON, "person-a", NODE_POST, "post-1", EDGE_MENTION), - KnowledgeGraphEdgeSpec(NODE_PERSON, "person-b", NODE_POST, "post-1", EDGE_MENTION), - KnowledgeGraphEdgeSpec(NODE_PERSON, "person-a", NODE_CORPORATE_ENTITY, "corp-1", EDGE_AFFILIATION), - ] - paths = semantic_paths_from_edges(list(reversed(edges)), node_key(NODE_PERSON, "person-a")) - assert [(hop.edge_type_code, hop.traversal_direction) for hop in paths[node_key(NODE_POST, "post-1")]] == [(EDGE_MENTION, "forward")] - assert [(hop.edge_type_code, hop.traversal_direction) for hop in paths[node_key(NODE_PERSON, "person-b")]] == [(EDGE_MENTION, "forward"), (EDGE_MENTION, "reverse")] -''', - ) - write( - "frontend/src/components/RelatedSemanticPath.test.tsx", - '''import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { RelatedSemanticPath, semanticPathText } from "./RelatedSemanticPath"; - -describe("RelatedSemanticPath", () => { - it("renders the ontology relationship path instead of generic graph copy", () => { - render(); - expect(screen.getByText("mentioned in post")).toBeInTheDocument(); - expect(semanticPathText(undefined)).toBe("Graph relation"); - }); -}); -''', - ) - write( - "docs/adr/0090-ontology-grounded-semantic-relationship-path.md", - '''# ADR 0090: Ontology-grounded semantic relationship paths - -## Status - -Accepted - -## Context - -The buyer related-node surface ranked evidence-gated knowledge-graph nodes with RWR but discarded relationship predicates. It could only show a node class, relevance score, and generic `Graph relation` copy. The ontology also declared `edge_mention` as `Post -> Person` while the canonical `knowledge_graph_edge` projection stores `Person -> Post`. - -## Decision - -- Keep canonical relational edge direction. -- Align OWL domain/range with storage and declare explicit `owl:inverseOf` terms for reverse buyer traversal. -- Use RWR only for relevance selection; explain each selected node with a deterministic shortest path over the same positive evidence-gated subgraph. -- Return per-hop edge code, traversal direction, ontology IRI, and label. Missing ontology terms stay missing. -- Render the semantic path in the buyer related-node surface. The path explains stored connectivity and is not a causal claim. - -## Consequences - -The API extension is additive. PostgreSQL remains the system of record. Buyer surfaces now preserve relationship semantics instead of collapsing them to a numeric relevance score. -''', - ) - write( - "docs/doctoring/SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md", - '''# Semantic relationship path references - -## Product traceability - -`knowledge_graph_edge` direction, OWL domain/range, inverse traversal labels, and buyer-visible semantic paths must agree. `knowledge_graph_edge_evidence` remains the authorization/evidence gate; semantic paths do not create facts. - -## APA 7th references - -World Wide Web Consortium. (2012). *OWL 2 Web Ontology Language: Document overview (Second Edition).* https://www.w3.org/TR/owl2-overview/ - -World Wide Web Consortium. (2013). *PROV-O: The PROV ontology.* https://www.w3.org/TR/prov-o/ -''', - ) - write( - "CHANGELOG.d/2.17.0-ontology-semantic-path.md", - '''### Fixed - -- Aligned `edge_mention` OWL semantics with the canonical Person-to-Post graph direction and added explicit inverse relationship terms. -- Buyer related nodes now carry and render ontology-grounded relationship paths instead of generic graph copy. -''', - ) - - -if __name__ == "__main__": - apply() diff --git a/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.00 b/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.00 new file mode 100644 index 000000000..aae635173 --- /dev/null +++ b/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.00 @@ -0,0 +1 @@ +H4sICPU3hmoCA2ZpeF8yNjRfa25vd2xlZGdlX3NlbWFudGljcy5weQDtff1zJEeV4O/1VyRlx6nbbpWkgTWmx7JPaDS2zjPSnCQz4ZO1PaXuaqk83VVNVfVohFCEjTkIdmExx8Kye7DL3t7GQnCc4Tb2wJ6924iFuP/DDLYBm//h3nv5XZXVH/OB2QgmQK6uynyZ+fLly5cv38cjH1ka59nSYZwsRcktNjotjtPko57v+2uj0eCUFccRO0zHSS/qsWs77JELT3yM3UzSk0HUO4oWj7JwdMzyaBgmRdzNWRaNwjgLoLbn9bN0yDqd/rgYZ1Gnw+LhKM0KFiZJWoRFnCa558l32dEozPJI/s4iXnsUFseD+FBWvQY/PW9ne3uPrdKPBoCPBwC8GWRRng5uRY1mAJCipMj3LxywuM/84CgujseHPouTch0oWeQsGuQRfQm6J71G0/O8XtSnphtZNICe3oraLC+yJlt8msq1PQb/sgjGlTDqzRKTJUXtkywuIrt6i3XTpICeaWBbaRJxYAVgICpgVFazTeObGFYwvNmLs4YY4+peNo5aLLod50UnvUk/rUrUj04R3S4aonUonXTTXpwcrfrjor/4pC9HDHM3CLtRJ026la6ng554SqIT8TQID6PB3IOBrsAX0b0sCnu8d9VOYekuEB4VhyIB/WhAT/g3mFv++SOrbIU3TNMSxjCdu6d5EQ03bsdFo++fyS6ct9kZ9RoeotujqFsAUUe3w24BhA7dZ8Ow6B632FFasDMCfu7XoZO6JFCGnSLMtNhKczqC435nBOQKk/F7gmZAJbSHCwQLGbgkEv9QZgKAP8TZuPbi3nPbW52djUudvY1d5CYLCwvAtHaiI5iYHHgTDDQH3tBPM1acjqIeLjKAHifh4QAY4vg0yhb7YRfgMs4Diyy8FWV5OJif+eWnSTdO1W9gwdA2h5D1+gb/exZbarErMOwsHLTYCzubO1HfLBkk4TDKR4AFWWf7+pUW27l0mf7sio4dht2bUdILwtEoUMy8wwci6jUI09GtuAcIjDqjFDhM3Ms7gJHOrTiPAQ0drJW3qKDcAjpIjPDndJCGvZbX5O0N4iQKj6KTCHA0ucGNS89udK5ubO1tbm+1Km9gttauGq/xZ2ft8uXNK5trusbW9qWNzvr2zrXtnbW9jQ5W3XvR+HRtY2fXKntte3fP+KkbKXWVD5hQgPjgZZK0F3VuRqc4WDEUa7jAdtNBenQK88zkswMrqpiFjm3xdh0+Z7A6NrIszXi7sgJ1qtMVBfi3W+Eg7oVFVP7mmg5r5uzWd8Un3PR2i2jEgRNMJIsiLk7F7B9DJSDajgUM2+OcT7/vx7dxRTSa1iY6Gc8NxVJ8/Lm44rfUm31/BKsuTfAlk88X/ANdoojCIVLu6r6Pj1Cw/LGbZjBoQheNiZduyOIAN82O4KEpKspR0YgjWPWnHWwY9uCoxzF+M056HWJfMIy4yDswvE4vHQL/6ITwKQuTI8CBzc9p0MCKHiYyYFRmEXtcHEQVSRNQwavwdwo7+DfM8wjoiPpOL5CT4i/cZeit3igkfcLY3YSrxyzxFNB35MxQqBe1qgXydJwB36LVOakc30tqyzXVkxiR7JO7Aba6OqH5WljOTihYzq8mDfIp7QyRfOG/EmxnnAPhgADfyYs0A9oU5YqUs3MQJGHjhRplSjQmxM1lGiZTtiZcVY1z4IwFAfXmQp/Bor25cKVYubNanOD2jNKtGBCJH1jPF3jLGcePb6LWzdmA30ejnJrv4co8CbMeX9gRbwVYwSgvo5Vq0hdArBuwpnQHy9RUKfechrmfMb20a4tWdkW1oB2Ldx/7WlpptI7xPa5jPaADROS+arS6i0/Yyafs5gfVLilZS5PwbP3yxVwZPNMXU2a+sktZHfj9nDb8Nwxvd47TUb56QUwlDp2OsyY5W1RM5/qcCHcYg9ibHHX46oLtJowHne4gzaNe3T7lGCoXBgAGso6po50Br1zC5CDFf+S45q1sd4fID5Y+H7bfnB2qZ24fU8CLbRNOIWp2lkuTowUJU6zqhN1uNIJ5yOFsxGdIE7VrQtzzsb98IKYEFsaHxXNIulbSimQ0smuGoKBGTzPXlPuffo3DsHCmmLna8uAMCGPMFT0L2uayGPLnAT99lXEoulGz3fmwlXV6UXcQwjbqN3FzUxvbSQxiMz+zBXTkzRtuqZ2fZ1cBlmpGbU9+U4tDNTKQ3YeWdZTRe1/z3vqkOiQayGfokMXkjYOU2TPBEU6Hw6jI4m69QOGLn1KIKXEHBcAQK+Aw3dNfAqsMKsLE6awjdv4JbQNVdG6lXbtNo2a5Uf2pKlbEWazIw6DUYTiAXWUIJKhKwiF+aDPaYZrAqhnhlLTl9F2ld9fwXYlk0yw+ipNwYAws2N7a276y/eyLXE2SnepJ5GftVa5DaGixdpSlyBq4KqHhHxfFKG8vLUW3w+FoALIn9GEJywBKTv2mDS4Ie71GA7+2dBeubG8//8I12LIubShVhZjewxAWT1MDMcYLx9AiLIqsIeEAx5CDAWKn5nTFe1hzqHtBZQhLD18GkYHpMbWtQ4Eax0RCpZF49djYuXQ5QJmphU0G29TkNdFi8z7GYajIHkjHuRQ+bf55KWP2lepxYjVeahLRoEoq4OfiluhLc1pxOju3RBfuB5eDNL05Hq3DXvWgaYCPZNqaMNmke9R8jLPBQd5vQHEc4WYZTc0BtXKac52wSicrxQRl+d54NIi7sI9MoxtV0E066vO9MZ256WQIZ2dWHAP7wJuR+6EV0oMgyx4YjHl2JigZviUCCVWrOJGOE9QeJXqTkXJQnIDwBMXSPm1dsMfiy6IsBglBsaz40yIi6g9LZ+/VilLVLhj3Vg2lk+YhqRuO0sWahQhGWd1ln0xXOaYFBsyGqqfFVcdBkGqfRPHRcbG6EizrD0J54lRYuMfuqFGDBFHSqdNw4cNRvoKaplA2EF2g1O9SzTdImG7ByeTs3BJ7OBZNccanpU/Haqpaktz1232/cl3g8wP4gUmx5u1BR9WIc7yWhYWLw0s7CFJqU4X8H0dznny4LmsA/WSXw5sRrO6ET7+heoS1iGfZZc9ga6dJl2Fv+xGsyUYeDfot1vn0OMpgPcrel7gAFgo4sMdX2Yr1Tei4962X+O+s8oa0D05C89szqzWdgOKeC0Lcq6nqJEgJYYrKtBaQ7oP9tqaqvbxl1UmKX121QodtVI7zVXJQrXTeut/JcfKBybPhp8VxlPlzzoCDKUxGeJVvTsayW3f3INErV6Zaj7AA7QUqzidwDh0PUIQRV6RBNk4M/cv0u8mGbqNl9pDt4+eDZlXnKZtcZWcOlYQ5zPOSmlm0I7jAquQCooA5grk7rjtMSpGz8wcG2USJE7p7YAY3p6twJYVEtwFglCs9i95I1N5LKhnVJdHh30NVeVmFZ9CK7DX07cyWH2xVlUkuWjByKqrp8/lMO7eu1FI9adbuyNZuTpI535MFBpjQt0Q96g901q8D5ZCjBCgpTM0rFtjIKVVcmdh7nJYZ+74yse9S/e8tLCx43uUdkLc3ti6VTEPErfgZiAV5N4sPyeYKrVZaLC7YOQm7zL8VI5X6Fz1VPDfl6PFwGGanqnSwZH6FWh6stbwgmmD80oITl0PkBq5OL8SFVcsrS9xtVpY2K+K2goF4s8vw+ua2Ye8SbbFtSF2dqF3FcVvTBpo/0AANgFzabrMV450peUJ15wFRzftmck333kEs7Qlk3nIbuJR3Mtq7pk+EhlieBhONtZOA5OyaBHnvPn0SLBDOmZCU/hBmIt/TjbunoWa9Tp8EeXNwQU3HwUXcfvg6bPiOJQZ1YD9ZfZqmLUbNORpFcTsupm4AWkwgRKOrRdpdxd27QLd43gZ+boFkYvE3HG0Td4bCsI1QefxvMyjST0Zyv/Kv1VAk+9efsBWpkaMXv/jCf2F7TsSxRqXzTax/wayfywPhRUQc/BXo4AeSHBWi0XBUnKJtIRqynRxHCbf35eZh7DhEtY/iYtwELgn5mp8VIWg63I8TNKFzYcX3Vffw/8SF17bWrry4u7nb2Xlh6wFwYejy4DSP851xskfS8fog7d40GLFRYCu8FR/xEVpk5oZhYYGzbzKzJePLM9uEbH1cpP0+LIULyxeeWFxeWVz5o72VC+3lZfjffxIzJcQiZGq4BgTFnDGxLqDyOOkeowIUb4AG8a2oE/bhzNzpCuD9EI2Zlbxt1switJcsosRds8jGqiJfaYpe0IyE6AJOudxS89Las0IBu5Cz9CSpAsRLo3HkphI3LhsCcy2zq0QlG58eh4OGlLSwqTVsad3ouRxwBdsCaFD6wMufE+nN3i+N/Fn6RXNxPx0zlixp92gOQMxnAsXIrPD6CNg53R+RaBlK5Xx4iBbGnMaluC24wj1Nir4q/x0NXbEDqQLtXN2+9MKVDWWtewWlZESCvKBkV7hh43U0bATK3L5+ZQnvDJZ2n9/eZbfSbng4HgBPCjwPl9hRFu3+xyuwDeD9g6Bwzj1TNNbuphlHqTBThdUdwraQB4ztQVFtsJl7yElvRzAtZB2s+KVUArcZWQkyZSXIFVe4ITHhtwDN47Vw5DmuqIAxw/Qyfk9CA6Y7EK6f5t0mtaDsPZbgROChJEHtYK8/aVotq33D5OrowDHGTQVhwqaplfvezFbNVAZmIyTNGwxMFFKvJrh21Ns8b0m75nmsnVsMJ96qgZeusvAmHdb6cZR53pXrQFaqEX0xIWgVqP0kzmEGQJ4RDiVBnC6ZhrRLkiAe8ZuecR8BcK9cD/Rlk9dR9Hxtbe+5WTxZVg7YEvN7aTf38UE2RD/MLizePAqKYoAGbFzt6bzYaHwKWTM9Ci0mWr7jTUiPiwChQaic9olce3EewpKBGaUrFKQRJFZFIkQh3r9X89wAvH8mSoRTit2hDVz1cnXIPuxyKtY2XUjIx2NYTIaHES5IvIc3OsZxy1Ao5p1wCcsg49hm0yTVVt5qbwv2WX3d5dT66dpOXZ3+7LIRqDThtk+s9kRaObTZYZoOhCaGdC+yJtel0LpRqL0WZmJZd9PhEPfWHtsbZ8XA5GPwAHPfB/o7xuUj7twURl0WBLwI+W41bLpu4UwBh1r1C2rHb5oG31TP06wdwJbGIC3HccF28jFxxAafaY1eGqlexgJRmqh5YzjscRJ/emwMVkBEGT87RX5444YB/MYNPWxhxs1MO0JR29bpCIjAouWoAvEub1j3k9hHfR8Z93EkDVWHBKeGqNliRk2unzO6aZgnApRBlDREZ5vs6aonjpsZ9H1j+bAzA/pHsnO8xJUmR6x8D2rOp2h3f5l87iTKyLPOsISR89gxWpEjbVfnkWZX0395WpELiMoLuTrXwYZmDghZVToumLr0xGp6chH5ct5yoxVjaGrp0cQAGcwyUyZucHapaBOb41BMi6IKlmjl3zteYIJw8+OOY5yjYzPQJO70cTdGAxI5qw8HFWSoQe3fFyaAT5IC2yQXJwuoR0ZhSmq0+W/ubNJ6LS14vF6HdwjDXP0SJav1nKgyRFGmWUJp7TAVyzfkKPc4e3G32CevQPhzUB6rnF4aIu6dnAIMqOKE4NwwZx7pDIQi7hCAZgc95JxnvnWh3LbQxMsKc/wS9asWlU2JxKJuk7ey71f1xLyWOT28bBnvtsWGQ3Ig5LtklyrVkexGC64sm6CEHxc5w1PUgMtOXKRf4uK8lrUJ2lV+3iKizWX/FYEykBqzIkZTEs2hcXKHh/HROB3n4oiBlflcwEFRnhMPo24oxPxT2AGRPkDc70cAS3g5Iih1qtLSIMp2gRzrZLKxsdicm8VAWYuf1JvTSfMEyYxm3ffO7C6K7W6SpSAB5nOGVD0ApOh9OxVbvc0EeWlhfkTzPGtNKtxsmru7aLpJjrY4RfiSA206nW/dQ7cuY514yKJPj0EKzy2XXDhB5W3zEIq/qXW/5C4lxGXumOPe9cVQQGAwXXonVuEDVTWQmIx2BEEhVkxYVTq7L8TYC1YccAW2mD7jmTy3jBxtjly3eyKNi1LbfTVYwxjZHpEGmKArdFX4tOC15BJsmoKoWH0uFteos7eqs8QwWf2qyegdRYhBr5Z4fqtknlq2izKmvVUySS0XNUihVUZYp9JR8aFZQnd1454AyxqRBGiMSJ7fVuu4264soRmcwdxsD9RJ7pKObaw15TDbmnKabXm1G6HaAS8b+8w4GURw6A+lfoq2JHk4wK1Qux3IM7+WR6Y7IdZsMabR6uxrv+9rnZxj4YurEIfjBr9y7qKKCLracBtouZHaNBSwEV74Nya7R7ameEEqFIjuwJYggT8oBkgzmbMz3gK8abFDOF5ZYrb0IQE8itahXJkNCo4jByQIGuQZ5eNiMn9h+ZdHJP5WJV9Sr9YcAZV4dHhq9VOTmpRajfNJnwtA48g6z8udmniAdeA7hwF0Otj9jtIU+EYBccHjX7kun7Rhr/h9/Yp8BIZgPO7KZ1RmqtKu+St/NBeo/FaLYVnAceiSnywVjXzpOr1UvlkLV351cy/4esD1/rsbV9e29jbXSZ9UVv5filDcjBOQpOKuuNs1NNmkX+bnnXK0IiCT6BZq8oP5dNrddDDgTErrtKN+OB4UeCxrwY9Pj6NZ1d/QYRTuxUcV0WMXYUDXvNnjZjwvX5NeDqd8Fxaddkj0vD2p6r+kNKurssl9bQmtLQQOPASzKWyZMOrLeDSI1MGz9Eca91bt5do1nYNTk182MfdpgZvtuo7yOb9hka2wca7XtbH8hbaPbmSB3yWFpeaIc3mXQ71sVczdm5VjiSMCwSSz8NZMxcuWr2a5GWxs64o7wdYJaxYztkW9abbGE62JZzEUnmgKXNvr5tRLhvJ0KkLaTiJLFBF3YNysIrSj+pR5iaYgpxmQ1PXb1j/qgiCtvztIq8Xr7i6cdj3V1V016+kD5y68ac4MuiEnsVY+T7n2cJIm/8wZxiS7Th4iRPHDfTcjEbbG5IJNLbjFWOPtYy3PdJZv460HMLgnhGjL \ No newline at end of file diff --git a/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.01 b/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.01 new file mode 100644 index 000000000..76bf92932 --- /dev/null +++ b/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.01 @@ -0,0 +1 @@ +GV0l7g0LgqAidITA8c09SA5HbEZkx5fC1KA6YZzwGYP3t+LoRKh48MIrpJs1yZfxtIFMGC/b0PwIBLeqmZKpymVyusPuMZmVittpGBwP28dGWD8pUAxXO59F3Xw/ghEB2YsTKl4EYadl4D++4ZyEg5sl7Q9dXkjk4+2EgXU8iks8s6cs5YRgN0J27r0cdmGST9tcv2k4u2SaH6DKZN/Yh+q2vepyOBBUggpBY8NuIMTmtNg0ykFSGQdPYI0uluhwmrRBuZmkizka7nkSZfu8sQOM5BUlhssg39jU5t501eVldV3ptijqSmmgKZwcYSWOYSmRmGPOxKQ1c8CRDjUa+w1NKWiB0hR6HLRN4aefM12A64RPjmPY7Hm72mVnnGUUx1A8dITBLBULRil0pl80rPs1VJOZpZvs6VXNAqzNEqXQOBlHhocQLF2UUskSHVd4zxYGFD4DQGZD9Q4GaG/DMOGrg3B42Avh6BsN2yWZgroK7/eXHQ4U9GHlYKrzC5W7MAHANOcfWW6SPNF0RUfCJZTgbnOInpOcggy2lhh4tBFOsQd5PSwmqKFd6VllYtRuy1Un9BgjGYkpCHLgkEXDb/sYB9CqBnsurwQPVEX2YEKdqZ6RkzwkdT8nFI97q2IQ1VJVZ0k5htqiAI6GVy3h0ONNpSyXE6V6qmlCOFOS8NHQrfDXzWqliY6W83i/uf0vZ/J+m+iQOY/3m9tTc6r3m010qNCV/K3xmMnCWowHQ6lbSvZWXF1OYgtWDVgFxCIkd2sJ0W6KM1u5ceiFr+A17YNF06UeMQ97SsdRPhaK99VtXX6xjp3ypVu01KoF5edh6RgsM2OuCWQ7eKqMeluAxXLfTAviUaw9OMiD7sraJzeudJ7feHG3DTDQlvApbmbdEubWTwsTYdOIv11vHU4jo7Jhvx8P4tAuL1+iDRdaZ6XZUZjEnwkVpkrxVagm7pXAWBdVY7qk6VUAZffqzfc558A4inbHqMps3ZKNmV8Bwrbx09k4migvPfYYu5alvTEaPqJ10wgjXTKMn5ST0asl66Z9ECpOuShMvSJBNQuTm0Du7LElDx3YYPL744TvXC6Ldo/7p7Un0cb+Afss06bvHnHQJMfybbKDbwsyIEtcQRleU77lrgt91vgItvRMABLMEcotYj35PhrJih9q+QbDcMT9ug3zXmmXLu/XVTcadvA1Ta+usG8H7JlnPHszDByOHnWlKnxSGF7Lzskjzqpn1XXFeVu1nLZ0Y8+wG6zx6Jkenl85N/nN8+YNo0pb4FH3Q7p+rItox3wEZe8UOR0wWrbsBgB1LVie2c1//Ql79Mz6fM7Mnpd6gR6XUM83PDt87Lvl6dE81yMzxyXtBh4V4ZYfPRNoOdddOL9xUZheczJ6OY2Thk/eJ+icce5wyjCVsbgCN4dDYfws7cgXyVCdHC2kSwTa2sWw5sJuluY592NJlL+FufyI9665vDLWLfeKimk5Xz04HNuVYi3LwtOnDHcIUbDquPAMN6yM4Px7/vRFxWKkYYflBmG5QFA06kU+4v4gPKLVn/EbBx64g8zPtP2+k9/UmOFz9xKKzj0RL7i+cIybaojEVM6qJvpikBfrcAibm3ZqUUdXabpvYjcAFocheuCZ+A4+BALRRL28Q02DZXHWVOnTJ3mfxCEWlll5cpotb16PgnNNwJd2OME+wvBxefkTy222R7oaZbHPbw1S4QaF+kCtaOCGr563yHaLsBgjVVGQvqgHry4RY+euPU8uXlj2vEceYWJSPG9Pqqej3iLtOGvXNsWuU9IOjbL0ZcHpaMPcgWMTiBjXw8FNeuEBIfKAgwUa9nF8oulPMsBtjYDnXbTU5IHKekiSqBTd3NmULgDo/jce4NfCy6MIj9noR2C7x2FPcFPtcd069JWMJzNROhwXIGLFn1FeBx7xIZaPRyM6JEM1dFpQ8KDxNcAzzFQPtTBoWDMMobM3TAngBhebPSHUIAdCEhOKALwxRrsc5RehbvauSW85XhFjSVlXfZ4asLRzUsZNqPoRNk9Ygxs00+xdiroxhoL3vJWAdGr8RmyRvON6uhdkTGiPIs7ZjXbJS/UGnz8gXymy5aZLBw0BXpFpiVDiAcsYZ+FgEcSXo3FI+ini3QhEwc9vKA+W8FYYD4gLF8dZOj46ZjeAdtvK9uNG4F0I2IbLFwV63D2OaGDhEQIrlIF/Kk1sheGa7rQnbZrYYdRHmovp0l04byFqJX0G3kcDtnN9x3K2kUQVDo6AlIrjIb9Llpe5grItSY1vIWTRhgYZUxWwLa0oFaqwparXJ0LD/hAi1C02LJgl2jO5V6hB8HLfXKQdLFbmuXngfYxTCuccL8Aaj3ATwOECXniAdR6ICSVRorcoiTBm4S9e+faz8naSFswvXvmOJ80iA20NyBkUxyITLlkXCRK01ksjbq3SDw8zHmpLOU4E3h8F5Gli+/vA8gGkjhBfISyO+DYjdS2OGC0Eia0g9HCASSROsUsSEwRhUQbtQbMiY/EA68uF7j5HlrmtjAOyuF+gBWKKPrkh2SXiwLTVhkZQDqseJKBhVIR44cPrBgBNsjHgnT2ce1gecX4sFE7U+eF4UMSLqAVXvB0GkEsbWH0HDNDg+HArSkgvLslTKr7F1SL5Qq19cm1dD1dJenlEXcJp5yyzYM8/qwDl0SjE0PJCy86vMJMeEGK+iIeVAVAwu7az/anFbRQNingY4T4p3kilF3mHSWdUbEb0t0WzLRak1u6LhcVnYle2B7wNR4VafvIyu6BsnAQrizGcrYzGp93MsLm4x0Uf8ukYKCL1lD0KY7vPra3bznToQjccRoTIGHbCjHtPSt5Lewm5G/XHg4GHHic0y+J2m+44aCu5yJdPDutYVY7y8p2vsXOGhYd7BUlkS0i3fD7R80+h1uyoRqjlJEhSw87G5Y2dja31jV0pPJQEgpaibY60XeWqtAPbXCZWwPU0gw3oOuLxenRIywPGFo+HAWtcWF650ARpkE/KCXxWTEhy/jZMYwakhduBhzGBgeF0Q+UkJcVH2Cvy4hRdEE+TIrwNoNGfvRc0g8c86dF2cnISnHw0gLP20t7OEgzlwiIvvTS1lx/FXnL8tYng8Vnb09S1gehdTKeD/ziC3z0ORzC56+rqnl2Ru1+DSKx+LPlx2B1AM3spalmeC1rscjhIx0WeAl2vw89/BwwQSPs/BIsvUovLT0CLl0M8gHIx60SKWeiWSWGscZdDWgIWORAYB1FmMwE8ZGk3inA55ZKZ78a3oeomknoiyRIGIqgAr+ouIYFfjfGq7THWGI0C9sTKR3/xytefuIAUsLmxsREwObheGtPIVpaDlZXlTyxtrl+6GmCng48vc9pcf25t69mNK9vPdi7vrD2LMZskjV4IVp4MlmFX+XO2YeStKZGu3h2QRa+nGbFPue/bsdW1vCACixQp3/+EGCLCgpOkxgX9kIaJQikXY8rs5hTZpmkuKLiBeUNf8jm0bQYNW1wEJa5lew7DoJJ8Tz7DsdAOWZv55iXaH2AAljGAKYPkSvQW5zsUczlcIUO+sMl3lSiPslvlEzF33ZZHYjFkLi/008EgPcFNEXbEm8LNHpsUrEhnFuuQahkzFJUDJ/HEYz59WyINaDmdiNKIKt49wvAWpZRITRMa7F2YO6y3lGddK2oNBQEJyKW8EjlHp5ZKeVCbEhxn0AUFsBlQUrO8YdjiuHozGUqLVUJJGPnduscd7ptYRiImn6KFpGRsmBOUpG3PA8bVJqZdfJvEeOM930LbQuQ3PnB9mco94Zvf+LZZMH9NKkm4rCRi2dBRu5T0oxlICG3DDN0O08M4HfGLhRN7hPKUMmmg1lmCady40FAZr0RECT8CDbVRepxoqTgKc7cmYM/A748UiyK8kQwXmmFRZsOUN9Pcu1GikPkACUQmJ3EiZKvmoCg3plrmTfdRJmE4ctSR47nybl9yOZxT5jqRc600l3qWlMdopQWdeWOGplRhc33yy4YZ1ygVptgS9wasNOmIyKtm1RmbtGIiyRmzYrg+SFRNJHgePIqoHp/W1K3Nddx1a1BRxt+sLVTwNT/LqUNzzeIqF5dMxxBfcnbfKHDOp/Yee5gT62AY03jomsEuqeNoeqb5JQ8yRRsN3e1Z24xr5s0u1AT7erj9sNHPfUAfFs4VIVpXlLPyn3WZb26DGzFPYEWzwp/AkiwQ8/fGTx23sL8TbnVPRC2Cp/Ssm+Z6mjJLzUfj6eTL6d9p99xz9HCXwGRub93zM1JE3ec/7xG2bfnjxiASLy4ykS9J3TMcxyO6bp5ze6p0uH4Nzb9dTVuCNdtWdWVO3sF+35A+kXVUN0bzXGYkyXEccifSsXH0VZqp0amV3m1SBCkdN+qlZOZKMuyUsybqUuhagheefUVOHcmCiHTripNhH2gocgy6t125Hkh6ab6ULDwgWPLAY4OUXJvHoyDw2m6raZ+0daIk0buOpI9K3lSeWJXnteweR8OwTB6+79/Ql2NHeClCKwzTt4v7QQJB9ynVo/RCbrp04r1WfJSg+S6RPmozXh7jQTk5ZcVJKr3AyT/EDJNUjmdkRCY2Z6GUBAe+ECJxI+FBkibXE9lwsBoNwqhYOevX5QtVmJYZa3Pul0Aol86cMuOVA9kT70a5Skscb3EGcCbysuPDPWJP0p0DiWVszATAwOYsk6Brl1zszVU260w+RApwHerrmIt5kq8c4Yl8/PlYV5XYxDVNiZeV0gr14wwrCzMSfr2FxzE0wHopKRXmdgVTSx+f9rKQ+xqEJ2FcyBcdWZr0uw2bL/4O+tXnRghkAWIWbliNtUrgmm0NQYavl0Eg0I6fwNKDbpfcH9HG35cJIigKernxpg1ZZcTrRfu+bVF7oKPM6yQSBLOS7XVOmNPDzc/byZoo9OXBH9wfwfBzqHXjQLefsCSt7c8Ud0TA5bnkHFFnKRyNlko6fpe8YzscryWnDvGmUqbFrgJ4eGcXFuXEt7nlmjl7bjlB62iFvJMu73PHyCbCgPnTlqS+FZ/EgNqqL2X5r1fLuf3c7XJN61e1zxZB13fcNKmvdqTio1UpYRnTVz+7zeqrIzHQrxYC73NeFv84UrLUkFHoDV5yd8gfBr414P9tnulkdBRwc3paK24vQCWZAIWSndCNG+VbMHy8cYNhuziNpCUH0T7DDA3YtBJIhMWiu6HGlEg1AH6/JmXQgTvKTdyjSDSVisipm9Oi3VAtd+KgA3dds7lS5iCzuZLHEBUv83x3JjXu/KMr8NcKeNOUUh86KeC9r1y0i9oCcAqBKFIQ2cb+QAoPiRQm5BqeHhZgrqgFE3y4LL8lLDxRip62lVkcrmUROXnYhip4+YwXZDPvnULjNcG1mUs0mOApoEx3uLicWrMJMKZN19Qpm3XaZo0p0ZxjeD4ZFJgTYJJAmHSP0ZFOKX9FGj83S+IRg5B3obANR94Dz9OZBN1io6KssNeLRViWe27MaG2WLGAqA5vmpTr9mpWMwkjKQj74GPGlZcZooLe18Rl03FJLNtGwDqpxfdGUvWpuyz1daOi0W1OGSEGYKkOkFegFdTXlUaBVMr4vEbIduNQIyeVKA1ddEbwaToU6KmjKM5avrwUl7kXC5gwnUHE0nTOGgLv+FO/fEL1nOuHRUYNb23a1o1PFO6rdJpcHVvcPtusow4uOKRCaaAxfoWTPdpxmrl2bc21dEN2ZnAVV9jnVTimzr+6jszq5jhP+aj5rcCfHURbVDxmJJjltPLrSbo/HcW//wPC2RavdESHsPinlARCLm15M8tZVygvP3l4xy2J7Ml9gKhdhidsZoS9lqChWClhl70CzyV4i5MhUgUtBnCpeWRAnyFQKYr0gZcWzUOGv4sQR4E8jeF8WPJAxZ3krVUWIJfXwyjJIjzMHYSlJY3tiEJDaJF9CgVBLAWL3oBc69PVacmpsGrtRFsMG/pnIDLvDM0fK2O/SGwJ1FcJlwrAixZDGatMQ42s722SUybg+h6NBlxTgw65uJYtUp/xqglfyOXV8atVVpiyvpVrltetXAkjIOpUPNRV1K/pNqWgllewEB2AJ3aHka9c6/7raE6cIszH+qlzYkbO28o5CuDgSeVJ2VA3w3DOjkkwOUurAgbWWzWil1djmkpiC8ahn+WxPSVNcTeLd1rFDzfetWepzSwIXBO4gNQFGXb7kKYFOJ+UqrkmMPDk0qiMpcU3cDcECZHALxL7FHcV3bw75/p5Ojvz80VKHA7SsRN4m08MeRwO8WvG1Jk3q+OkUoTsnO4WHAHJqmEX4r40ObMZxm+GcMJ2B74wT8gwkeQ51MTx22C3hOx12u+gGv5Ari33laGbqZbTcTTeHsk+yLGU9blW62tTh48y4XGXRyIq/Rd612Bz3Gemgz0gHt5qO8BlpqIhQ/NDGD1z0t9kywqWt0qOkLTl1/FhQoiTeZn1lHo9uwg0FH754Zyvd/m1TDblmFgVGvwuV1141Giwd2IRfKQo3f6AdUVknm56DfEo5sHnNWXOPVzHJzx65jsUnLy2lNDYhhJ5xmWnKzeVLzqYlmlXuB2tzbduivDteJXVe4FcG+i4J9xI3NXFX5ZZy35pGMUMtc2GTnpFTiO0G5dt3kNKtphOO4lqHGBGugrw5+2gAZYTAIVnEXOgUiaNihTIJRCXCEoKsi7cqAn1UIq6K9+6Yq6WPdhVH3FVZfnJu68/qkDQKjoy9moyHh1GmXpvC1zOVxm3BSn2HBpLxYKCKOYWmKjSnJGQXqx7K+Mf9A4yb4d37fLto2PbcGsXcRcswOLFvzbHLs+jEnWBtJXYt1msQLoKG3DcUZvOVZ6bEjKo061vRDyz0OA0JjEU8Ki9ieMVWS9haG8FBK7/N90IRWIeYGhTGTJQhuhnehjNRAqQDI1r1x0V/8UlfOfctuJPZL8ikPBSyRg1nGGY3I9Kpq+TMcZ5ej6KblwHMOrQHY10DLoqDzotN/s2IsyZK+xdfShbMU5QA7GxUJz3YPYXT2HDjNoZ4FCNvWxG+5MW6AKdS3uoNiKOI \ No newline at end of file diff --git a/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.02 b/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.02 new file mode 100644 index 000000000..5d64d34e1 --- /dev/null +++ b/.github/scripts/fix_264_knowledge_semantics.py.gz.b64.02 @@ -0,0 +1 @@ +gs0Immzwwi1Z6XFjcI7oYcZgbJShEYkMOom8vBuO9J2Aig+UafJZ5wUaPMKxQVd2FDEewQdNVsW+GchQXfyXWMEXRcAx/U5xDAris7V9aaNzbWNnd3urKU6dHHIe9xRkYeuFrzp2K+YXhHlRJ3mCV011jtXxqrDH5xjSCwucN2WMKo//31GO2qnEI2OOARG0c0sK/hCwPYgwJJZs9j5G8qFOnHsExpydK6yMjFWw6oysRy3atiWsaJZi3YnCngin9uiZ6MI5ubs/egaLe9dilM3zNrw2qopYaW3ZeZMYKIimpgcnR4n7nAPQybRhLNZyFi4n6+lr3iO5u2yNZ3QmsOysponzKezIKNsyh2Ixl5txQhLdU/koTLgJMyY/XpX7DXmHLmIp/2lEqB2bBjD61BLWfFovIAlxYSaYtfdDZ1USkOH26u+UOBnUzPvsdHZ+Y0ITbeZCQ00FiR2bpghFMxIUlr1XaiKvEGpsEj1RC7MQExZsqQEoMtJiAg9JQHICzxVflRZMGSVeeTIpyybqYOcIFIDlKSSAKaTwCjNJKCWqaC9U8c+5i8yxQGEYyaPGzPlbUt6XgDL/g+986/03fsp+87/+z/t/+22/pH2ujeoKFX/z01c++IvX2Qff/Olvfvql91//Nvvgc//j/b/+cg2IiRFVsRt/+6MP/uFVAviFL3/wha9MAliN/woAfvuN7/3mzrcq3Xn/6/+9DKAmKKxrSL/98ivO2vcyGgesKZFinUgmyGVA1Zih2Im/+OH7P/r+b7/59+y3r73+wWtfKlcyA3NiUz/559/8+EcMWpxQMK8ted6ameR+9cYPf/nml371kzfe+b+fn4fi3v3mF9/7wXd/+dMfvvvV19/56p+891ef/+Vbb73ztW/dI8nxyr/+q//9zo//+pdv/dl7dz7/3p0vzkht7/znH7/z+pen9KOe0Kojeee//rdff+tv7onYeNUp45hOatU+uSE5ae2dr37lnde/9utXv/LOn31hCp29+52/f/et13lr0yitpuwctPbu57/6ztf+8e5rX7v7uTfnorU/+cZ73/uXu6/+w6++9wpg4+6r37j7uS/fffVvYKbf+9L375Hi3vunz7/35p/fffUH737pFZisu6/+5d3P/WkdxDq6g+oz9WkC9dWM7e5rr9597Z/vvvbdeyJD5+BqQc5AjzW95O3MRJW/fuUL737zTSDMu6/+z1/904/e/Zs702jzu2+++6ff5S1PpU132Tlo8+ev/78fvX3nO3AE67391g9AaD96+84b8Pf4598P56HVrSOCE7Offw0f/q7LkuO333qjC8hIAfjhz74bs1vx22/9S3GPdKsaKLK37/wY4B6P377zl/Dft+98g3WP377z/e6M9LsFhd+APiV1Xe7+7E38fByOZ6fnreOf/eNw/sHPRNUc9uzjnk7Xe7ry/H12kvml8enbd75IEdBSgBy/fefVscDw33WnULzRFlSB4X09mUb4k6sI+j+QwjYGlcOboyjAuJHxIGpkC41nhs0/bryUP14+mbTZ/h+/lBw83nopaS7IvMO96DYAWOaWLpRts9dr0BEBzw7BVXyiCziea7PItICcpMkg7VIMN4Bi2SIR2KdXKcmOKTw3p2nghFwPB6s0hZNKcsrsQViiuHFOwRYpOjv0mXrfyHxEgfDqDchgrrHSbIqnZV1VGGj6Po9xbueSWTjjkM/9s5vR6TnO0BmlAD33W6a6Ud5SQaGWzhFqdnafkHIQ4F1TbqQissdwmz0Ox7uyBao9BvY49dkr6WeRFjC/cwNmsEXvjTTRCBkOjh8rp3y1Do0K+crU9WOMZhgHIzy18xY7gtMqoeW2PCmKg9fMZ74oyTGvJo971xFx72qi1s0S2a0S1I2Hom/OC2dShDitLXccR6XK3DqOQum5tOXOTv2u1eZyniv2krw965Wh1S6vhcfRR9Ydt77lLDs1ur+rmpFrxY07s07zAd0NyKaMNAX3dUWgUK60KKLEkDL0meoGe0YWCGe7dL+P3uHbI6mgOENv1VIkfZ3D4CJ+LUXF19dTlt/qw2zGog8xyc9MzmRQ7ZwvLObTkZljuJSRrIQ2V14FPrAoEYNrZOPE6kqrlEGh2XZgpLIKFpSWG7VvqxjMOTANHfJnRJ4EMijDPAlk1+fKk+AALd0THJ8m5FFAgM4sCk4wlawKOARtasDruqqeV3t8XiWtD2EqqkTXnppV5MGippyHpEIVeM+yfzAjVqfQQRAENelDqoiQmK2hBUeFe514XzFRjH87z+KV07ifAonABqdJHEMhEOFYb9GMZ5xHmJ0javTDQR4515IFlNeEHdMAqd6ZAJ+yLDGebuDfpoOD/tvo9OQ+O6hFd73yERp00pDqxMQlNwWfNvXkCG9G2mFOdDckbxSEyNmjUaDpxA9zIFqDKLEHujutJY/fk35N6lZlijXIKmOYMFq5Yz+AaatfGU4UzDJMlJ13IhTmrkZoomW00frweuVA/gMdjE/B1uecEocbX7I+iLs3V88aKMs4++aupvfvhpkoqlWzp7n/TZR4ZhZ43P+q+bBEuq8ZN3zXv/PmjEXP3cT3cPA/B1KsqZqj3gRZT6K1ZQGfY6ruHae+dCUgWR03vTlXgolGmAIhSk2biNwm/c0ZiL5fWtZtljqXe2sGOKgBGJx+Ku2aQNTLWSCsh4MI06iYAOS7meqP8yIdRtnVEM7aNhTryyyw1vKba0dwajahyHeT609cjBMXYO20T5lHLl1xpqGP1HXC1pTBP1OXI7BeeBMdnQK4zc7OLz5oAoYziTXuaVPrOrLVDQwlD51i9Q8r6UNYSZPo5dzBeEfpaDwytGlVvqsdbLVdOLdIpCh2WltmWU+hd4FTuVdJG18xtZrXykokUjU05+i0Rmp7oS8vwZ/BxkqMc0Wpnue1qcLcX2EedTCI81xR5EanIiBjAFRjhXjGazGuHPYvBCsfD5Z9Oy6i/f3J8nf/2mlxDJ9Fsdmjwyll+yjs3gyPouDl3KKTBdy/BdC26lurHLSxVObJShnVkLOLPFXZID1SlwAq2VIw7NnKf1V26hVALpMYU5amR9g+79kBWyylDn2ErfV6Uc+jtEZG3lDuvYaewidm2srYyGjHr19DVx4kwx2BySTylElF3YLqUJ8U01TfxZWyIWKMFo9nTaxkAhVxWopjTCjI04GyCB30KDhHQ+ZebfIA1exyfJsP1BFG1s4koqLK4uhlZFkrvYknk2tighOKNKtDl4aUeJQHs6VaMi0VlqPyvDURxjUAWDs8IR+/VuJpo3CGc0paKLNWYVZxV0oqna3wipHNSd8jjER+qLySIthUeSzk+kCChtT8IM97QqHk8wkpifEgtEgHoUVRkY59gHjDMtU36NCfcv8ky37cptmVT5gRAO/pysVcXW3Gm7i3Oxa5xh5n8s2KrqiX6gwMloe+6R5DQcy8FylWsLaz/tzm3sb63gs7G5obmEU78l7QeDeVO1gALFZBiRxnTORWyTUs06yqvIoichLFWaJLtVKEJZnrU56SRHjEbjgYRFnAyO8WbRDGuCg03yGX7bpsxi0PU7siGEp9TNnhcuGSi+NYoGyeaRKjkUElPLQgeNR9Q/c86edbSv9qOY9xIxS5MLG+GmWm8jIG3g1nZNDAHdfsBs+Ry7MQW2sduniYFscejygmGYVMO4lxqRi7VEnT6cwRyvMrekZOShU6PQ/KC3cmmlCrukKhRjA4k1CNtVGpEmSoU0bnssfdBPs48zECaM16Ejf0ern3ljjvWVRsbpEIdVF2f5GcVXGZtVg14aEFlVJYhL1sCfeXmQDCZlSFAH+AAKHrS89vwUxTIONnd9auPdfZ3bi6trW3ub7b0WlBOSD9WwpmmDHytIPJC5MacweT9JbsINzbW3vbMMwXXdYNVjXLiYHXlZ3sXFvbe26qeYSdzs/K5GcBMiCY2QeldZKRS894VU7jYHxSIY+Nd7YLsvvDyPrAfQm4Q7rb0MQobAvJYppwCZbnB5ZjTvsdbKb0HKxlR2MUQ67RFwUVfwRhD/olvjf8ITqgt4BRpDEwmNWGD8iC3z4Rgt+UnP4op92EANB/EITEECxu/BUMuQ8XIxilPIiVSYgGuRE4xqI+GCqA7HTQPavTIYidDiVV6AiwHAve/weVyVzfDeAAAA== \ No newline at end of file diff --git a/.github/workflows/apply-ontology-semantic-path.yml b/.github/workflows/apply-ontology-semantic-path.yml deleted file mode 100644 index 9551a77f6..000000000 --- a/.github/workflows/apply-ontology-semantic-path.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: Apply ontology semantic-path repair - -on: - push: - branches: - - feat/event-lineage-node-keeps-gnb-focus-v2170 - -permissions: - contents: write - -jobs: - repair: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 - steps: - - name: Checkout repair branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 - persist-credentials: true - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install locked dependencies - run: | - corepack enable - uv sync --frozen --extra dev --extra backend - pnpm --dir frontend install --frozen-lockfile - - - name: Prove RED root cause - shell: bash - run: | - set +e - uv run --frozen python - <<'PY' - from rdflib.namespace import RDFS - from lineageweave.ontology import LW, load_ontology - graph = load_ontology() - assert (LW.mentions, RDFS.domain, LW.Person) in graph - assert (LW.mentions, RDFS.range, LW.Post) in graph - PY - status=$? - set -e - if [ "$status" -eq 0 ]; then - echo "Expected pre-repair ontology mismatch was not reproduced." >&2 - exit 1 - fi - - - name: Apply minimal semantic repair - run: python .github/scripts/apply_ontology_semantic_path.py - - - name: Verify focused GREEN contracts - run: | - uv run --frozen python -m pytest -q \ - tests/test_semantic_relationship_paths.py \ - tests/test_ontology.py \ - tests/test_knowledge_graph.py \ - tests/test_person_mention_projection.py - pnpm --dir frontend exec vitest run src/components/RelatedSemanticPath.test.tsx - - - name: Verify full product suites - run: | - uv run --frozen python -m pytest -q - pnpm --dir frontend run lint - pnpm --dir frontend run test - pnpm --dir frontend run build - pnpm --dir frontend run build-storybook - - - name: Commit verified source repair - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git rm -- .github/scripts/apply_ontology_semantic_path.py \ - .github/workflows/apply-ontology-semantic-path.yml \ - .github/workflows/fix-264-ontology-semantic-path.yml - - git add -- \ - docs/ontology/lineageweave-kg.ttl \ - lineageweave/ontology.py \ - lineageweave/knowledge_graph.py \ - backend/app/knowledge_graph.py \ - frontend/src/api.ts \ - frontend/src/App.tsx \ - frontend/src/components/RelatedSemanticPath.tsx \ - frontend/src/components/RelatedSemanticPath.test.tsx \ - tests/test_ontology.py \ - tests/test_semantic_relationship_paths.py \ - docs/adr/0090-ontology-grounded-semantic-relationship-path.md \ - docs/doctoring/SEMANTIC_RELATIONSHIP_PATH_REFERENCES.md \ - CHANGELOG.d/2.17.0-ontology-semantic-path.md - - if git diff --cached --quiet; then - echo "No verified repair changes to commit." >&2 - exit 1 - fi - git commit -m "fix: expose ontology-grounded semantic relationship paths" - git push origin "HEAD:${REPAIR_BRANCH}" diff --git a/.github/workflows/repair-264-knowledge-semantics.yml b/.github/workflows/repair-264-knowledge-semantics.yml new file mode 100644 index 000000000..7c451dd2f --- /dev/null +++ b/.github/workflows/repair-264-knowledge-semantics.yml @@ -0,0 +1,164 @@ +name: Repair PR 264 Knowledge Graph semantics + +on: + pull_request: + types: [synchronize] + +permissions: + contents: write + +concurrency: + group: repair-pr-264-knowledge-semantics + cancel-in-progress: false + +jobs: + red-green-fix: + if: github.event.pull_request.number == 264 && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + steps: + - name: Checkout exact stacked branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + persist-credentials: true + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Select pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + + - name: Install the committed universal lock + run: uv sync --frozen --extra dev --extra backend + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install frontend dependencies + working-directory: frontend + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Detect an already-applied repair + id: repair-state + shell: bash + run: | + if test -f lineageweave/semantic_path.py && grep -q 'semantic_path' backend/app/knowledge_graph.py; then + echo 'already=true' >> "$GITHUB_OUTPUT" + else + echo 'already=false' >> "$GITHUB_OUTPUT" + fi + + - name: Materialize the bounded repair script + run: | + cat .github/scripts/fix_264_knowledge_semantics.py.gz.b64.* | base64 -d | gzip -d > /tmp/fix_264_knowledge_semantics.py + python -m py_compile /tmp/fix_264_knowledge_semantics.py + + - name: RED - ontology drift and score-only related nodes are reproduced + if: steps.repair-state.outputs.already != 'true' + shell: bash + run: | + python /tmp/fix_264_knowledge_semantics.py red + set +e + uv run --frozen python -m pytest -q tests/test_knowledge_graph_semantic_contract.py > /tmp/python-red.log 2>&1 + python_rc=$? + (cd frontend && pnpm exec vitest run src/semanticPath.test.ts) > /tmp/frontend-red.log 2>&1 + frontend_rc=$? + set -e + cat /tmp/python-red.log + cat /tmp/frontend-red.log + if [ "$python_rc" -eq 0 ] || [ "$frontend_rc" -eq 0 ]; then + echo 'RED unexpectedly passed; refusing to patch without reproducing both missing contracts' >&2 + exit 1 + fi + grep -Eq 'semantic_path_payload|semantic_path|cannot import name|ModuleNotFoundError|Failed to resolve|ENOENT' /tmp/python-red.log /tmp/frontend-red.log + + - name: GREEN - add typed ontology paths, provenance, and cutoff-safe navigation + if: steps.repair-state.outputs.already != 'true' + run: python /tmp/fix_264_knowledge_semantics.py green + + - name: Refresh and verify the committed universal lock + run: | + uv lock + uv lock --check + uv sync --frozen --extra dev --extra backend + + - name: Verify Python semantic contracts + run: | + uv run --frozen python -m pytest -q tests/test_ontology.py tests/test_knowledge_graph_semantic_contract.py tests/test_person_mention_projection.py + uv run --frozen python -m pytest -q + uv run --frozen python -m compileall -q lineageweave backend/app + + - name: Verify frontend buyer surface + working-directory: frontend + run: | + pnpm exec vitest run src/semanticPath.test.ts src/analysisRunNavigation.test.ts src/App.test.tsx src/i18n.test.ts + pnpm run lint + pnpm run build + pnpm run build-storybook + + - name: Commit only validated repair files + shell: bash + run: | + rm -f .github/workflows/repair-264-knowledge-semantics.yml + rm -f .github/workflows/apply-ontology-semantic-path.yml .github/workflows/fix-264-ontology-semantic-path.yml + rm -f .github/scripts/apply_ontology_semantic_path.py .github/scripts/fix_pr264_semantic_path.py + rm -f .github/scripts/fix_264_knowledge_semantics.py.gz.b64.* + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + ARCHITECTURE.md \ + CHANGELOG.md \ + CHANGELOG.d/2.18.0-knowledge-graph-semantic-paths.md \ + docs/adr/0090-knowledge-graph-semantic-paths.md \ + docs/doctoring/KNOWLEDGE_GRAPH_SEMANTICS_REFERENCES.md \ + docs/ontology/lineageweave-kg.ttl \ + lineageweave/ontology.py \ + lineageweave/semantic_path.py \ + backend/app/knowledge_graph.py \ + tests/test_ontology.py \ + tests/test_knowledge_graph_semantic_contract.py \ + tests/test_person_mention_projection.py \ + frontend/src/api.ts \ + frontend/src/App.tsx \ + frontend/src/i18n.ts \ + frontend/src/semanticPath.ts \ + frontend/src/semanticPath.test.ts \ + frontend/src/analysisRunNavigation.ts \ + frontend/src/analysisRunNavigation.test.ts \ + frontend/package.json \ + pyproject.toml \ + uv.lock + git add -u .github/workflows .github/scripts + git diff --cached --check + git commit -m "feat(semantics): explain related-node knowledge paths" + git pull --rebase origin feat/event-lineage-node-keeps-gnb-focus-v2170 + git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 From 2234fc75e8aa094840ab7ab986faf2a34995fd84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:37:16 -0700 Subject: [PATCH 27/63] ci: keep semantic repair exact-head safe --- .../repair-264-knowledge-semantics.yml | 87 +++++++++++++++++-- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/.github/workflows/repair-264-knowledge-semantics.yml b/.github/workflows/repair-264-knowledge-semantics.yml index 7c451dd2f..4966b90f6 100644 --- a/.github/workflows/repair-264-knowledge-semantics.yml +++ b/.github/workflows/repair-264-knowledge-semantics.yml @@ -9,7 +9,7 @@ permissions: concurrency: group: repair-pr-264-knowledge-semantics - cancel-in-progress: false + cancel-in-progress: true jobs: red-green-fix: @@ -29,14 +29,19 @@ jobs: --health-retries 5 env: LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + REPAIR_BRANCH: feat/event-lineage-node-keeps-gnb-focus-v2170 + REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha }} steps: - - name: Checkout exact stacked branch + - name: Checkout the exact PR head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: - ref: feat/event-lineage-node-keeps-gnb-focus-v2170 + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: true fetch-depth: 0 + - name: Fetch the live foundational branch for ADR collision checks + run: git fetch origin feat/analysis-run-name-evidence-lineage:refs/remotes/origin/feat/analysis-run-name-evidence-lineage + - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 with: @@ -105,6 +110,61 @@ jobs: if: steps.repair-state.outputs.already != 'true' run: python /tmp/fix_264_knowledge_semantics.py green + - name: Keep the ADR number collision-free across the moving stack + shell: bash + run: | + python - <<'PY' + from pathlib import Path + import re + import subprocess + + generated = Path('docs/adr/0090-knowledge-graph-semantic-paths.md') + if not generated.exists(): + raise SystemExit('expected generated ADR 0090 is missing') + + parent_listing = subprocess.run( + [ + 'git', 'ls-tree', '-r', '--name-only', + 'origin/feat/analysis-run-name-evidence-lineage', 'docs/adr', + ], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + parent_names = {Path(line).name for line in parent_listing} + if generated.name not in parent_names: + raise SystemExit(0) + + local_names = {path.name for path in Path('docs/adr').glob('*.md') if path != generated} + numbers = [] + for name in parent_names | local_names: + match = re.match(r'^(\d{4})-', name) + if match: + numbers.append(int(match.group(1))) + number = max(numbers, default=90) + 1 + new_name = f'{number:04d}-knowledge-graph-semantic-paths.md' + target = generated.with_name(new_name) + generated.rename(target) + + replacements = ( + ('ADR 0090', f'ADR {number:04d}'), + ('0090-knowledge-graph-semantic-paths.md', new_name), + ) + for path in ( + Path('ARCHITECTURE.md'), + Path('CHANGELOG.md'), + Path('CHANGELOG.d/2.18.0-knowledge-graph-semantic-paths.md'), + Path('docs/doctoring/KNOWLEDGE_GRAPH_SEMANTICS_REFERENCES.md'), + target, + ): + if not path.exists(): + continue + text = path.read_text(encoding='utf-8') + for old, new in replacements: + text = text.replace(old, new) + path.write_text(text, encoding='utf-8') + PY + - name: Refresh and verify the committed universal lock run: | uv lock @@ -113,7 +173,7 @@ jobs: - name: Verify Python semantic contracts run: | - uv run --frozen python -m pytest -q tests/test_ontology.py tests/test_knowledge_graph_semantic_contract.py tests/test_person_mention_projection.py + uv run --frozen python -m pytest -q tests/test_ontology.py tests/test_knowledge_graph_semantic_contract.py tests/test_person_mention_projection.py tests/test_documentation_hygiene.py uv run --frozen python -m pytest -q uv run --frozen python -m compileall -q lineageweave backend/app @@ -125,7 +185,7 @@ jobs: pnpm run build pnpm run build-storybook - - name: Commit only validated repair files + - name: Commit only the exact-head validated repair shell: bash run: | rm -f .github/workflows/repair-264-knowledge-semantics.yml @@ -138,7 +198,7 @@ jobs: ARCHITECTURE.md \ CHANGELOG.md \ CHANGELOG.d/2.18.0-knowledge-graph-semantic-paths.md \ - docs/adr/0090-knowledge-graph-semantic-paths.md \ + docs/adr/*-knowledge-graph-semantic-paths.md \ docs/doctoring/KNOWLEDGE_GRAPH_SEMANTICS_REFERENCES.md \ docs/ontology/lineageweave-kg.ttl \ lineageweave/ontology.py \ @@ -160,5 +220,16 @@ jobs: git add -u .github/workflows .github/scripts git diff --cached --check git commit -m "feat(semantics): explain related-node knowledge paths" - git pull --rebase origin feat/event-lineage-node-keeps-gnb-focus-v2170 - git push origin HEAD:feat/event-lineage-node-keeps-gnb-focus-v2170 + test -z "$(git status --porcelain)" || { + echo 'repair left uncommitted or untracked files' >&2 + git status --short + exit 1 + } + + git fetch origin "${REPAIR_BRANCH}" + remote_head="$(git rev-parse "origin/${REPAIR_BRANCH}")" + if [ "$remote_head" != "$REPAIR_BASE_SHA" ]; then + echo "PR head moved from ${REPAIR_BASE_SHA} to ${remote_head}; refusing to publish an unverified rebase." >&2 + exit 1 + fi + git push origin "HEAD:${REPAIR_BRANCH}" From 066467982fd1ca3ae01b8813d77104419fe41cd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 16:55:38 -0700 Subject: [PATCH 28/63] test: align semantic repair fixtures with production schema --- .../repair-264-knowledge-semantics.yml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/repair-264-knowledge-semantics.yml b/.github/workflows/repair-264-knowledge-semantics.yml index 4966b90f6..9ee294a0c 100644 --- a/.github/workflows/repair-264-knowledge-semantics.yml +++ b/.github/workflows/repair-264-knowledge-semantics.yml @@ -110,6 +110,72 @@ jobs: if: steps.repair-state.outputs.already != 'true' run: python /tmp/fix_264_knowledge_semantics.py green + - name: Align PostgreSQL fixture migrations and ADR index hygiene + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + projection = Path('tests/test_person_mention_projection.py') + text = projection.read_text(encoding='utf-8') + old_constants = '''_POST_SUMMARY_CONTRACT_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0040_post_summary_contract.sql" + ) + ''' + new_constants = '''_SOURCE_STATE_PROVENANCE_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0033_source_state_provenance.sql" + ) + _NORMALIZED_BODY_SEARCH_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0036_normalized_body_search.sql" + ) + _POST_SUMMARY_CONTRACT_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0040_post_summary_contract.sql" + ) + ''' + if new_constants not in text: + if text.count(old_constants) != 1: + raise SystemExit('projection fixture migration constants anchor drifted') + text = text.replace(old_constants, new_constants, 1) + + old_apply = ''' cursor.execute(_MIGRATION_PATH.read_text(encoding="utf-8")) + cursor.execute(_SEMANTIC_PROJECT_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_POST_SUMMARY_CONTRACT_MIGRATION.read_text(encoding="utf-8")) + ''' + new_apply = ''' cursor.execute(_MIGRATION_PATH.read_text(encoding="utf-8")) + cursor.execute(_SEMANTIC_PROJECT_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_SOURCE_STATE_PROVENANCE_MIGRATION.read_text(encoding="utf-8")) + # Production installs pg_trgm in an earlier search migration. This + # focused fixture creates the same prerequisite before applying the + # exact normalized-body migration consumed by hydration queries. + cursor.execute("create extension if not exists pg_trgm") + cursor.execute(_NORMALIZED_BODY_SEARCH_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_POST_SUMMARY_CONTRACT_MIGRATION.read_text(encoding="utf-8")) + ''' + if new_apply not in text: + if text.count(old_apply) != 1: + raise SystemExit('projection fixture migration application anchor drifted') + text = text.replace(old_apply, new_apply, 1) + projection.write_text(text, encoding='utf-8') + + hygiene = Path('tests/test_documentation_hygiene.py') + text = hygiene.read_text(encoding='utf-8') + old_paths = ''' paths = sorted(_ADR_DIRECTORY.glob("*.md")) + assert paths, "the repository must contain architecture-decision records" + ''' + new_paths = ''' readme = _ADR_DIRECTORY / "README.md" + assert readme.exists(), "the ADR directory must retain its normative index" + assert readme.read_text(encoding="utf-8").strip(), "the ADR index must not be empty" + + paths = sorted(path for path in _ADR_DIRECTORY.glob("*.md") if path != readme) + assert paths, "the repository must contain numbered architecture-decision records" + ''' + if new_paths not in text: + if text.count(old_paths) != 1: + raise SystemExit('ADR hygiene path anchor drifted') + text = text.replace(old_paths, new_paths, 1) + hygiene.write_text(text, encoding='utf-8') + PY + - name: Keep the ADR number collision-free across the moving stack shell: bash run: | @@ -207,6 +273,7 @@ jobs: tests/test_ontology.py \ tests/test_knowledge_graph_semantic_contract.py \ tests/test_person_mention_projection.py \ + tests/test_documentation_hygiene.py \ frontend/src/api.ts \ frontend/src/App.tsx \ frontend/src/i18n.ts \ From 35143d79560d7fa1cacfe6fdde09dd76626e9d46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:10:08 -0700 Subject: [PATCH 29/63] test: require TEPP-backed project history evidence --- tests/test_tepp_project_history.py | 153 +++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/test_tepp_project_history.py diff --git a/tests/test_tepp_project_history.py b/tests/test_tepp_project_history.py new file mode 100644 index 000000000..69abdb81a --- /dev/null +++ b/tests/test_tepp_project_history.py @@ -0,0 +1,153 @@ +"""TEPP project histories are typed, cutoff-safe, and source-grounded.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from backend.app.tepp_project_history import build_project_history_request, classify_event_type +from lineageweave.tepp_project_history import ( + PROJECT_HISTORY_CONTRACT_VERSION, + ProjectHistoryProjection, + TeppProjectHistoryClient, + TeppProjectHistoryNotAvailable, +) + + +def source_row( + post_id: str, + title: str, + created_at: str, + *, + focus: bool = False, + voc_type_code: str = "vom", + actors: tuple[str, ...] = (), +) -> dict: + """Return one authorized row shape consumed by the request builder.""" + return { + "post_id": post_id, + "post_title": title, + "created_at": datetime.fromisoformat(created_at.replace("Z", "+00:00")), + "source_stage_code": None, + "source_detail_state_code": None, + "voc_type_code": voc_type_code, + "source_project_code": "P-100", + "source_project_name": "Northridge renewal", + "secondary_grouping_key": "proj-alpha", + "evidence_text": f"Evidence: {title}", + "actor_ids": list(actors), + "is_focus": focus, + } + + +def test_request_builder_emits_the_minimum_buyer_cycle_without_raw_body() -> None: + rows = [ + source_row("award", "Contract awarded", "2022-03-11T09:00:00Z", actors=("a",)), + source_row( + "spec", + "Specification revision requested", + "2023-06-15T09:00:00Z", + actors=("a", "b"), + ), + source_row("delivery", "Delivery confirmed", "2024-02-20T09:00:00Z", actors=("b",)), + source_row("handoff", "Operational handoff recorded", "2024-03-01T09:00:00Z", actors=("b", "c")), + source_row( + "voc", + "Transformer VOC received", + "2026-07-30T09:00:00Z", + focus=True, + voc_type_code="voc", + actors=("c",), + ), + source_row("rebid", "Rebid started", "2026-08-10T09:00:00Z", actors=("c",)), + ] + + request = build_project_history_request( + rows, + focus_post_id="voc", + tenant_workspace_id="tenant-demo", + knowledge_cutoff=datetime(2026, 8, 19, 23, 59, 59, tzinfo=timezone.utc), + ) + + assert request.contract_version == PROJECT_HISTORY_CONTRACT_VERSION + assert request.project_key == "P-100" + assert request.focus_event_id == "voc" + assert [event.event_type_code for event in request.events] == [ + "contract_awarded", + "specification_changed", + "delivered", + "handoff_recorded", + "voc_received", + "rebid_started", + ] + assert all(event.availability_basis_code == "source_created_at_proxy" for event in request.events) + assert all("post_body" not in event.to_json() for event in request.events) + + +def test_classifier_requires_explicit_event_language_and_focus_for_generic_voc() -> None: + assert classify_event_type("Specification revision requested", None, None, "vom", False) == "specification_changed" + assert classify_event_type("Operational handoff recorded", None, None, "vom", False) == "handoff_recorded" + assert classify_event_type("General account note", None, None, "voc", False) == "source_recorded" + assert classify_event_type("General account note", None, None, "voc", True) == "voc_received" + + +def test_client_validates_the_tepp_projection_and_publishes_no_credentials() -> None: + captured: dict = {} + + def transport(payload: dict, headers: dict[str, str]) -> dict: + captured["payload"] = payload + captured["headers"] = headers + return { + "contract_version": 1, + "project_key": "P-100", + "project_name": "Northridge renewal", + "focus_event_id": "voc", + "history_span_start": "2022-03-11T09:00:00Z", + "history_span_end": "2026-08-10T09:00:00Z", + "participant_count": 3, + "inference_status": "temporal_association_only", + "events": [ + { + "event_id": "voc", + "event_type_code": "voc_received", + "event_title": "Transformer VOC received", + "occurred_at": "2026-07-30T09:00:00Z", + "available_at": "2026-07-30T09:00:00Z", + "availability_basis_code": "source_created_at_proxy", + "source_post_id": "voc", + "evidence_text": "Evidence: Transformer VOC received", + "actor_ids": ["c"], + } + ], + "findings": [], + } + + request = build_project_history_request( + [source_row("voc", "Transformer VOC received", "2026-07-30T09:00:00Z", focus=True, voc_type_code="voc")], + focus_post_id="voc", + tenant_workspace_id="tenant-demo", + knowledge_cutoff=datetime(2026, 8, 19, 23, 59, 59, tzinfo=timezone.utc), + ) + projection = TeppProjectHistoryClient(transport=transport).project(request) + + assert isinstance(projection, ProjectHistoryProjection) + assert projection.participant_count == 3 + assert captured["headers"]["tepp-consumer"] == "lineageweave" + assert captured["headers"]["tepp-contract-version"] == "1" + assert "authorization" not in {key.lower() for key in captured["headers"]} + + +def test_default_client_and_unpublished_response_fail_closed() -> None: + request = build_project_history_request( + [source_row("voc", "Transformer VOC received", "2026-07-30T09:00:00Z", focus=True, voc_type_code="voc")], + focus_post_id="voc", + tenant_workspace_id="tenant-demo", + knowledge_cutoff=datetime(2026, 8, 19, 23, 59, 59, tzinfo=timezone.utc), + ) + with pytest.raises(TeppProjectHistoryNotAvailable): + TeppProjectHistoryClient().project(request) + + client = TeppProjectHistoryClient(transport=lambda _payload, _headers: {"causal_score": 0.99}) + with pytest.raises(ValueError, match="project-history projection"): + client.project(request) From 7f726a3d38235d1d4362c19a5dc37964f9b9b4f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:10:42 -0700 Subject: [PATCH 30/63] test(ui): require the TEPP project timeline answer surface --- .../components/TeppProjectHistory.test.tsx | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistory.test.tsx diff --git a/frontend/src/components/TeppProjectHistory.test.tsx b/frontend/src/components/TeppProjectHistory.test.tsx new file mode 100644 index 000000000..9b4669d7c --- /dev/null +++ b/frontend/src/components/TeppProjectHistory.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TeppProjectHistory } from "./TeppProjectHistory"; +import type { TeppProjectHistoryProjection } from "../api"; + +const projection: TeppProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + project_name: "Northridge renewal", + focus_event_id: "voc", + history_span_start: "2022-03-11T09:00:00Z", + history_span_end: "2026-08-10T09:00:00Z", + participant_count: 3, + inference_status: "temporal_association_only", + events: [ + { + event_id: "award", + event_type_code: "contract_awarded", + event_title: "수주", + occurred_at: "2022-03-11T09:00:00Z", + available_at: "2022-03-11T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-award", + evidence_text: "계약 체결 근거", + actor_ids: ["a"], + }, + { + event_id: "spec", + event_type_code: "specification_changed", + event_title: "사양 변경", + occurred_at: "2023-06-15T09:00:00Z", + available_at: "2023-06-15T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-spec", + evidence_text: "사양 변경 근거", + actor_ids: ["a", "b"], + }, + { + event_id: "delivery", + event_type_code: "delivered", + event_title: "납품", + occurred_at: "2024-02-20T09:00:00Z", + available_at: "2024-02-20T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-delivery", + evidence_text: "납품 근거", + actor_ids: ["b"], + }, + { + event_id: "voc", + event_type_code: "voc_received", + event_title: "VOC 접수", + occurred_at: "2026-07-30T09:00:00Z", + available_at: "2026-07-30T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-voc", + evidence_text: "VOC 근거", + actor_ids: ["c"], + }, + { + event_id: "rebid", + event_type_code: "rebid_started", + event_title: "재입찰", + occurred_at: "2026-08-10T09:00:00Z", + available_at: "2026-08-10T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-rebid", + evidence_text: "재입찰 근거", + actor_ids: ["c"], + }, + ], + findings: [ + { + finding_code: "specification_change_and_handoff_before_focus", + summary: "Explicit specification-change and handoff events precede the focus event.", + related_event_ids: ["spec", "handoff"], + evidence_post_ids: ["post-spec", "post-handoff"], + }, + ], +}; + +describe("TeppProjectHistory", () => { + it("renders an accessible project timeline and opens exact source evidence", () => { + const onOpenPost = vi.fn(); + render(); + + expect(screen.getByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + expect(screen.getByText("Northridge renewal")).toBeInTheDocument(); + expect(screen.getByText("3 explicit participants")).toBeInTheDocument(); + expect(screen.getByText("수주")).toBeInTheDocument(); + expect(screen.getByText("사양 변경")).toBeInTheDocument(); + expect(screen.getByText("납품")).toBeInTheDocument(); + expect(screen.getByText("VOC 접수").closest("li")).toHaveAttribute("aria-current", "step"); + expect(screen.getByText("재입찰")).toBeInTheDocument(); + expect(screen.getByText(/temporal association, not causality/i)).toBeInTheDocument(); + expect(screen.getByText(/source-created time is an availability proxy/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open evidence: 사양 변경" })); + expect(onOpenPost).toHaveBeenCalledWith("post-spec"); + }); +}); From d7f4f149fb7fc8dfe5e99b9756b1d67afd58f50c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:12:30 -0700 Subject: [PATCH 31/63] feat: add the strict TEPP project-history client --- lineageweave/tepp_project_history.py | 327 +++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 lineageweave/tepp_project_history.py diff --git a/lineageweave/tepp_project_history.py b/lineageweave/tepp_project_history.py new file mode 100644 index 000000000..4490bed9f --- /dev/null +++ b/lineageweave/tepp_project_history.py @@ -0,0 +1,327 @@ +"""Strict LineageWeave client for TEPP project-history projections. + +LineageWeave selects authorized source evidence. TEPP validates the knowledge +cutoff, orders explicit events, and returns coded temporal associations. This +module never supplies provider credentials, never treats event order as +causality, and never accepts a theta or an unpublished score field. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Callable + +from lineageweave.http_client import HttpClientError, post_json + +PROJECT_HISTORY_CONTRACT_VERSION = 1 +PROJECT_HISTORY_PATH = "/v1/project-histories" +PROJECT_HISTORY_INFERENCE_STATUS = "temporal_association_only" +PROJECT_HISTORY_CONSUMER_CODE = "lineageweave" + +Transport = Callable[[dict[str, Any], dict[str, str]], dict[str, Any]] + + +class TeppProjectHistoryNotAvailable(RuntimeError): + """TEPP project-history transport is absent or returned an unusable result.""" + + +def _parse_timestamp(value: object, field_name: str) -> datetime: + """Parse one timezone-aware RFC 3339-like timestamp or fail closed.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field_name} must be an RFC 3339 timestamp") from exc + if parsed.tzinfo is None: + raise ValueError(f"{field_name} must include an offset") + return parsed + + +def _require_exact_keys(payload: dict[str, Any], expected: frozenset[str], name: str) -> None: + """Reject missing or unpublished fields in a versioned TEPP envelope.""" + actual = frozenset(payload) + if actual != expected: + raise ValueError(f"invalid {name} fields") + + +def _require_text(value: object, field_name: str, maximum: int = 4096) -> str: + """Return bounded non-empty text from an untrusted wire value.""" + if not isinstance(value, str) or not value.strip() or len(value.encode("utf-8")) > maximum: + raise ValueError(f"{field_name} must be bounded non-empty text") + return value + + +@dataclass(frozen=True) +class ProjectHistoryEvent: + """One explicit, source-grounded event sent to or returned by TEPP.""" + + event_id: str + event_type_code: str + event_title: str + occurred_at: str + available_at: str + availability_basis_code: str + source_post_id: str + evidence_text: str + actor_ids: tuple[str, ...] = () + + def to_json(self) -> dict[str, Any]: + """Serialize this event without post bodies or identity labels.""" + return { + "event_id": self.event_id, + "event_type_code": self.event_type_code, + "event_title": self.event_title, + "occurred_at": self.occurred_at, + "available_at": self.available_at, + "availability_basis_code": self.availability_basis_code, + "source_post_id": self.source_post_id, + "evidence_text": self.evidence_text, + "actor_ids": list(self.actor_ids), + } + + @classmethod + def from_json(cls, payload: object) -> ProjectHistoryEvent: + """Parse one strict TEPP event from an untrusted JSON object.""" + if not isinstance(payload, dict): + raise ValueError("project-history event must be an object") + expected = frozenset( + { + "event_id", + "event_type_code", + "event_title", + "occurred_at", + "available_at", + "availability_basis_code", + "source_post_id", + "evidence_text", + "actor_ids", + } + ) + _require_exact_keys(payload, expected, "project-history event") + actor_ids = payload["actor_ids"] + if not isinstance(actor_ids, list) or len(actor_ids) > 64: + raise ValueError("actor_ids must be a bounded list") + parsed_actor_ids = tuple(_require_text(value, "actor_id", 256) for value in actor_ids) + occurred_at = _require_text(payload["occurred_at"], "occurred_at", 64) + available_at = _require_text(payload["available_at"], "available_at", 64) + _parse_timestamp(occurred_at, "occurred_at") + _parse_timestamp(available_at, "available_at") + return cls( + event_id=_require_text(payload["event_id"], "event_id", 256), + event_type_code=_require_text(payload["event_type_code"], "event_type_code", 64), + event_title=_require_text(payload["event_title"], "event_title", 512), + occurred_at=occurred_at, + available_at=available_at, + availability_basis_code=_require_text( + payload["availability_basis_code"], "availability_basis_code", 64 + ), + source_post_id=_require_text(payload["source_post_id"], "source_post_id", 256), + evidence_text=_require_text(payload["evidence_text"], "evidence_text"), + actor_ids=parsed_actor_ids, + ) + + +@dataclass(frozen=True) +class ProjectHistoryRequest: + """Versioned TEPP request built only from authorized project evidence.""" + + contract_version: int + idempotency_key: str + tenant_workspace_id: str + project_key: str + project_name: str + knowledge_cutoff: str + focus_event_id: str + events: tuple[ProjectHistoryEvent, ...] + + def to_json(self) -> dict[str, Any]: + """Serialize the exact public TEPP request contract.""" + return { + "contract_version": self.contract_version, + "idempotency_key": self.idempotency_key, + "tenant_workspace_id": self.tenant_workspace_id, + "project_key": self.project_key, + "project_name": self.project_name, + "knowledge_cutoff": self.knowledge_cutoff, + "focus_event_id": self.focus_event_id, + "events": [event.to_json() for event in self.events], + } + + +@dataclass(frozen=True) +class ProjectHistoryFinding: + """One TEPP-coded temporal association and its source evidence.""" + + finding_code: str + summary: str + related_event_ids: tuple[str, ...] + evidence_post_ids: tuple[str, ...] + + @classmethod + def from_json(cls, payload: object) -> ProjectHistoryFinding: + """Parse one strict temporal finding.""" + if not isinstance(payload, dict): + raise ValueError("project-history finding must be an object") + expected = frozenset( + {"finding_code", "summary", "related_event_ids", "evidence_post_ids"} + ) + _require_exact_keys(payload, expected, "project-history finding") + related = payload["related_event_ids"] + evidence = payload["evidence_post_ids"] + if not isinstance(related, list) or not isinstance(evidence, list) or not evidence: + raise ValueError("project-history finding must name its evidence") + return cls( + finding_code=_require_text(payload["finding_code"], "finding_code", 128), + summary=_require_text(payload["summary"], "summary"), + related_event_ids=tuple(_require_text(value, "related_event_id", 256) for value in related), + evidence_post_ids=tuple(_require_text(value, "evidence_post_id", 256) for value in evidence), + ) + + +@dataclass(frozen=True) +class ProjectHistoryProjection: + """Validated TEPP response rendered by LineageWeave buyer surfaces.""" + + contract_version: int + project_key: str + project_name: str + focus_event_id: str + history_span_start: str + history_span_end: str + participant_count: int + inference_status: str + events: tuple[ProjectHistoryEvent, ...] + findings: tuple[ProjectHistoryFinding, ...] + + @classmethod + def from_json(cls, payload: object) -> ProjectHistoryProjection: + """Parse and validate the complete public TEPP projection.""" + if not isinstance(payload, dict): + raise ValueError("project-history projection must be an object") + expected = frozenset( + { + "contract_version", + "project_key", + "project_name", + "focus_event_id", + "history_span_start", + "history_span_end", + "participant_count", + "inference_status", + "events", + "findings", + } + ) + _require_exact_keys(payload, expected, "project-history projection") + if payload["contract_version"] != PROJECT_HISTORY_CONTRACT_VERSION: + raise ValueError("unsupported project-history contract version") + if payload["inference_status"] != PROJECT_HISTORY_INFERENCE_STATUS: + raise ValueError("project-history projection must remain non-causal") + participant_count = payload["participant_count"] + if isinstance(participant_count, bool) or not isinstance(participant_count, int) or participant_count < 0: + raise ValueError("participant_count must be a non-negative integer") + raw_events = payload["events"] + raw_findings = payload["findings"] + if not isinstance(raw_events, list) or not raw_events or not isinstance(raw_findings, list): + raise ValueError("project-history projection requires event and finding lists") + events = tuple(ProjectHistoryEvent.from_json(event) for event in raw_events) + findings = tuple(ProjectHistoryFinding.from_json(finding) for finding in raw_findings) + event_ids = [event.event_id for event in events] + if len(event_ids) != len(set(event_ids)): + raise ValueError("project-history projection contains duplicate events") + focus_event_id = _require_text(payload["focus_event_id"], "focus_event_id", 256) + if focus_event_id not in set(event_ids): + raise ValueError("project-history focus event is absent") + occurred = [_parse_timestamp(event.occurred_at, "occurred_at") for event in events] + if occurred != sorted(occurred): + raise ValueError("project-history events are not ordered") + history_span_start = _require_text(payload["history_span_start"], "history_span_start", 64) + history_span_end = _require_text(payload["history_span_end"], "history_span_end", 64) + if _parse_timestamp(history_span_start, "history_span_start") > _parse_timestamp( + history_span_end, "history_span_end" + ): + raise ValueError("project-history span is inverted") + return cls( + contract_version=PROJECT_HISTORY_CONTRACT_VERSION, + project_key=_require_text(payload["project_key"], "project_key", 256), + project_name=_require_text(payload["project_name"], "project_name", 512), + focus_event_id=focus_event_id, + history_span_start=history_span_start, + history_span_end=history_span_end, + participant_count=participant_count, + inference_status=PROJECT_HISTORY_INFERENCE_STATUS, + events=events, + findings=findings, + ) + + def to_json(self) -> dict[str, Any]: + """Serialize the validated projection for the API and frontend.""" + return { + "contract_version": self.contract_version, + "project_key": self.project_key, + "project_name": self.project_name, + "focus_event_id": self.focus_event_id, + "history_span_start": self.history_span_start, + "history_span_end": self.history_span_end, + "participant_count": self.participant_count, + "inference_status": self.inference_status, + "events": [event.to_json() for event in self.events], + "findings": [ + { + "finding_code": finding.finding_code, + "summary": finding.summary, + "related_event_ids": list(finding.related_event_ids), + "evidence_post_ids": list(finding.evidence_post_ids), + } + for finding in self.findings + ], + } + + +def _no_transport(_payload: dict[str, Any], _headers: dict[str, str]) -> dict[str, Any]: + """Fail closed when no TEPP project-history endpoint is configured.""" + raise TeppProjectHistoryNotAvailable("TEPP project-history transport is not configured") + + +class TeppProjectHistoryClient: + """Submit strict project-history requests through a replaceable transport.""" + + def __init__(self, transport: Transport = _no_transport) -> None: + self._transport = transport + + @property + def available(self) -> bool: + """Return whether this client has a configured transport.""" + return self._transport is not _no_transport + + def project(self, request: ProjectHistoryRequest) -> ProjectHistoryProjection: + """Submit a request and validate TEPP's exact non-causal response.""" + headers = { + "tepp-consumer": PROJECT_HISTORY_CONSUMER_CODE, + "tepp-contract-version": str(PROJECT_HISTORY_CONTRACT_VERSION), + "idempotency-key": request.idempotency_key, + } + try: + payload = self._transport(request.to_json(), headers) + except TeppProjectHistoryNotAvailable: + raise + except (HttpClientError, OSError, TypeError, ValueError) as exc: + raise TeppProjectHistoryNotAvailable(str(exc)) from exc + return ProjectHistoryProjection.from_json(payload) + + +def configured_tepp_project_history_client(url: str) -> TeppProjectHistoryClient: + """Build an HTTP TEPP client from an exact project-history endpoint URL.""" + target = url.strip() + if not target: + return TeppProjectHistoryClient() + + def transport(payload: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]: + try: + return post_json(target, payload, headers=headers, timeout=30.0) + except (HttpClientError, OSError, TypeError, ValueError) as exc: + raise TeppProjectHistoryNotAvailable(str(exc)) from exc + + return TeppProjectHistoryClient(transport=transport) From ff69cfab97b7405855cbc7b33973cf9930bfc8cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:13:49 -0700 Subject: [PATCH 32/63] feat: select authorized evidence for TEPP project histories --- backend/app/tepp_project_history.py | 270 ++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 backend/app/tepp_project_history.py diff --git a/backend/app/tepp_project_history.py b/backend/app/tepp_project_history.py new file mode 100644 index 000000000..93ee4f33d --- /dev/null +++ b/backend/app/tepp_project_history.py @@ -0,0 +1,270 @@ +"""Select authorized project evidence and build TEPP history requests. + +The database remains authoritative for post visibility and source metadata. +This module sends only bounded event labels, evidence excerpts, opaque post and +actor references, project identity, and clocks. It does not send a raw body, +provider credential, score, or causal conclusion. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timezone +from typing import Any + +import asyncpg + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.tepp_project_history import ( + PROJECT_HISTORY_CONTRACT_VERSION, + ProjectHistoryEvent, + ProjectHistoryRequest, +) + +_EVENT_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("rebid_started", ("rebid", "re-bid", "retender", "re-tender", "재입찰")), + ( + "handoff_recorded", + ("handoff", "hand-off", "transferred ownership", "operational transfer", "인수인계"), + ), + ( + "specification_changed", + ( + "specification change", + "specification revision", + "revised specification", + "spec revision", + "사양 변경", + "사양변경", + ), + ), + ( + "delivered", + ( + "delivery confirmed", + "delivery completed", + "delivered", + "shipment completed", + "납품 완료", + "납품완료", + ), + ), + ( + "contract_awarded", + ( + "contract awarded", + "award confirmed", + "order confirmation", + "purchase order received", + "수주 확정", + "수주확정", + ), + ), +) +_VOC_CODES = frozenset({"voc", "vocc", "voco", "vom", "vop"}) + + +def classify_event_type( + post_title: str, + source_stage_code: str | None, + source_detail_state_code: str | None, + voc_type_code: str | None, + is_focus: bool, +) -> str: + """Map explicit structured/title evidence to TEPP's bounded event vocabulary. + + A generic VOC-family row is not automatically another VOC event. Only the + focused row gets that fallback; non-focus rows require explicit event + language and otherwise remain ``source_recorded``. + """ + text = " ".join( + value.strip().casefold() + for value in (post_title, source_stage_code or "", source_detail_state_code or "") + if value.strip() + ) + for event_type_code, patterns in _EVENT_PATTERNS: + if any(pattern in text for pattern in patterns): + return event_type_code + if is_focus and (voc_type_code or "").casefold() in _VOC_CODES: + return "voc_received" + return "source_recorded" + + +def _as_utc_rfc3339(value: datetime) -> str: + """Serialize one aware or assumed-UTC datetime as canonical UTC text.""" + aware = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _bounded_evidence(value: object, fallback: str) -> str: + """Return a compact evidence excerpt without forwarding raw source bodies.""" + text = str(value or "").strip() or fallback.strip() + encoded = text.encode("utf-8") + if len(encoded) <= 4096: + return text + return encoded[:4096].decode("utf-8", errors="ignore").rstrip() + + +def _project_identity(focus: Mapping[str, Any]) -> tuple[str, str]: + """Choose a stable existing project identity without inventing one.""" + project_code = str(focus.get("source_project_code") or "").strip() + project_name = str(focus.get("source_project_name") or "").strip() + grouping_key = str(focus.get("secondary_grouping_key") or "").strip() + if project_code: + return project_code, project_name or project_code + if grouping_key: + return grouping_key, project_name or grouping_key + post_id = str(focus["post_id"]) + return f"post:{post_id}", project_name or str(focus["post_title"]) + + +def build_project_history_request( + rows: Sequence[Mapping[str, Any]], + *, + focus_post_id: str, + tenant_workspace_id: str, + knowledge_cutoff: datetime, +) -> ProjectHistoryRequest: + """Build the exact TEPP request from already-authorized source rows. + + Raises: + ValueError: no focus row exists, the cutoff excludes an event, or the + selected rows do not share the focus project's explicit identity. + """ + focus_rows = [row for row in rows if str(row["post_id"]) == focus_post_id] + if len(focus_rows) != 1: + raise ValueError("project history requires one visible focus post") + focus = focus_rows[0] + project_key, project_name = _project_identity(focus) + cutoff = knowledge_cutoff if knowledge_cutoff.tzinfo is not None else knowledge_cutoff.replace( + tzinfo=timezone.utc + ) + cutoff = cutoff.astimezone(timezone.utc) + + events: list[ProjectHistoryEvent] = [] + for row in sorted(rows, key=lambda item: (item["created_at"], str(item["post_id"]))): + event_time = row["created_at"] + if not isinstance(event_time, datetime): + raise ValueError("project-history event time must be a datetime") + event_time_utc = ( + event_time if event_time.tzinfo is not None else event_time.replace(tzinfo=timezone.utc) + ).astimezone(timezone.utc) + if event_time_utc > cutoff: + raise ValueError("project-history evidence is after the knowledge cutoff") + actor_ids = tuple( + sorted({str(value).strip() for value in row.get("actor_ids", ()) if str(value).strip()}) + ) + post_id = str(row["post_id"]) + title = str(row["post_title"]) + events.append( + ProjectHistoryEvent( + event_id=post_id, + event_type_code=classify_event_type( + title, + row.get("source_stage_code"), + row.get("source_detail_state_code"), + row.get("voc_type_code"), + post_id == focus_post_id, + ), + event_title=title, + occurred_at=_as_utc_rfc3339(event_time_utc), + available_at=_as_utc_rfc3339(event_time_utc), + availability_basis_code="source_created_at_proxy", + source_post_id=post_id, + evidence_text=_bounded_evidence(row.get("evidence_text"), title), + actor_ids=actor_ids, + ) + ) + if not events: + raise ValueError("project history has no authorized events") + + digest_material = "\u001f".join( + [tenant_workspace_id, project_key, _as_utc_rfc3339(cutoff), *(event.event_id for event in events)] + ) + idempotency_key = hashlib.sha256(digest_material.encode("utf-8")).hexdigest() + return ProjectHistoryRequest( + contract_version=PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key=idempotency_key, + tenant_workspace_id=tenant_workspace_id, + project_key=project_key, + project_name=project_name, + knowledge_cutoff=_as_utc_rfc3339(cutoff), + focus_event_id=focus_post_id, + events=tuple(events), + ) + + +async def fetch_project_history_rows( + conn: asyncpg.Connection, + *, + focus_post_id: str, + knowledge_cutoff: datetime, + can_see: Callable[[Mapping[str, Any]], bool], +) -> list[dict[str, Any]]: + """Load a bounded, project-coherent, ABAC-visible source evidence set.""" + focus = await conn.fetchrow( + f""" + select post_id, post_title, post_body, voc_type_code, visibility_code, + corporate_entity_id, created_at, source_stage_code, + source_detail_state_code, source_project_code, source_project_name, + secondary_grouping_key + from source_post + where post_id = $1 + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")} + """, + focus_post_id, + ) + if focus is None or not can_see(focus): + return [] + project_code = str(focus["source_project_code"] or "").strip() or None + grouping_key = str(focus["secondary_grouping_key"] or "").strip() or None + rows = await conn.fetch( + f""" + select post.post_id, post.post_title, post.voc_type_code, + post.visibility_code, post.corporate_entity_id, post.created_at, + post.source_stage_code, post.source_detail_state_code, + post.source_project_code, post.source_project_name, + post.secondary_grouping_key, + coalesce( + (select string_agg(event.event_text, '; ' order by event.event_ordinal) + from post_summary_event event where event.post_id = post.post_id), + btrim(left(source_post_search_text(post.post_body), 1000)), + post.post_title + ) as evidence_text + from source_post post + where post.created_at <= $2 + and ( + post.post_id = $1 + or ($3::text is not null and post.source_project_code = $3) + or ($4::text is not null and post.secondary_grouping_key = $4) + ) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} + order by post.created_at, post.post_id + limit 128 + """, + focus_post_id, + knowledge_cutoff, + project_code, + grouping_key, + ) + visible = [dict(row) for row in rows if can_see(row)] + post_ids = [row["post_id"] for row in visible] + actor_map: dict[str, list[str]] = {str(post_id): [] for post_id in post_ids} + if post_ids: + actor_rows = await conn.fetch( + """ + select post_id, cataloged_person_id + from post_summary_role + where post_id = any($1::uuid[]) + and cataloged_person_id is not null + order by post_id, cataloged_person_id + """, + post_ids, + ) + for actor_row in actor_rows: + actor_map[str(actor_row["post_id"])].append(str(actor_row["cataloged_person_id"])) + for row in visible: + row["actor_ids"] = actor_map.get(str(row["post_id"]), []) + row["is_focus"] = str(row["post_id"]) == focus_post_id + return visible From 101dcf67e50c6773805d47a51ac90dacd23962d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:16:50 +0000 Subject: [PATCH 33/63] feat: open Customer master related posts onto Event Lineage (v2.15.0) Customer master names authorized customer entities as current and to open a related post 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 customer is invented. --- AGENTS.md | 2 + ARCHITECTURE.md | 2 + ...15.0-customer-master-open-event-lineage.md | 5 ++ CHANGELOG.md | 10 +++ CLAUDE.md | 8 +++ ...tomer-master-open-focuses-event-lineage.md | 35 ++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 68 +++++++++++++++++++ frontend/src/App.tsx | 31 ++++++++- frontend/src/i18n.test.ts | 1 + frontend/src/i18n.ts | 8 +++ pyproject.toml | 2 +- 12 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md create mode 100644 docs/adr/0073-customer-master-open-focuses-event-lineage.md diff --git a/AGENTS.md b/AGENTS.md index 20b9c6f47..17425fbd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,8 @@ Opening that filtered post focuses Event Lineage (ADR 0093). Do not invent a week, a theta, or a cutoff body. Opening a Calendar commitment uses the same focus path (ADR 0072). 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 0073). +Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 706a807d8..6d2367713 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -283,6 +283,8 @@ Keycloak issued; `src/App.tsx` renders a git-branch SVG of 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 0072), +Customer master related posts use the same Event Lineage focus path +(ADR 0073). and a full detail popup: Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman diff --git a/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md new file mode 100644 index 000000000..7b67dcfe9 --- /dev/null +++ b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md @@ -0,0 +1,5 @@ +# 2.15.0 Opening a Customer master related post focuses Event Lineage + +Customer master names authorized customer entities as current and to open a +related post 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 f74f6ff9b..87b9a4d5c 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.15.0] - 2026-08-19 + +### Added + +- Opening a Customer master related post now focuses Event Lineage and names + Keyman and evaluation as the next read. Customer master names authorized + customer entities as current before that open. Home-list opens do not add + that focus or copy. No TEPP theta is invented. No customer is invented + (ADR 0073 / ADR 0037 / ADR 0016). + ## [2.14.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 27db8465c..edd36d709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,3 +86,11 @@ Event Lineage takes focus and names Keyman and evaluation next (ADR 0072). A home-list open does not. Do not invent a theta or a CalDAV event. +## Customer master open (v2.15.0) + +Open Customer master. Authorized customer entities are current. Open a +related post: Event Lineage takes focus and names Keyman and evaluation +next (ADR 0073). A home-list open does not. Do not invent a theta or a +customer. + + diff --git a/docs/adr/0073-customer-master-open-focuses-event-lineage.md b/docs/adr/0073-customer-master-open-focuses-event-lineage.md new file mode 100644 index 000000000..c941e4a24 --- /dev/null +++ b/docs/adr/0073-customer-master-open-focuses-event-lineage.md @@ -0,0 +1,35 @@ +# ADR 0073: Opening a Customer master related post focuses Event Lineage + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Board Weekly VOC and Calendar commitment opens already focus Event Lineage +(ADR 0071 / ADR 0072). Customer master is the remaining buyer GNB destination +that opens an authorized related post. That open was a home-list open: the +popup body appeared and Event Lineage did not take focus. + +## Decision + +Opening a related post on Customer master is a `fromCustomerMaster` open. +That open reuses the Event Lineage focus path used by report-member, +Weekly VOC, and Calendar opens: + +- Customer master names the next action: authorized customer entities are + current; open a related 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). Customer +master does not invent a customer or a parent (ADR 0037 / ADR 0010). + +## Consequences + +- Customer master, Calendar, Weekly VOC, and report-member opens share one + focus contract. +- Closing the popup clears the Customer master open flag. diff --git a/frontend/package.json b/frontend/package.json index c6a4389de..f499c9871 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.14.0", + "version": "2.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 5b0f9c235..c5bac0e03 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -770,6 +770,24 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/customer-master")) { + return Promise.resolve( + jsonResponse({ + corporate_entities: [ + { + corporate_entity_id: "corp-demo", + entity_name: "Demo Corp", + corporate_entity_code: "DEMO-CORP-01", + entity_level_code: "company", + parent_entity_id: null, + }, + ], + keymen: [], + source_customer_hints: [], + source_author_hints: [], + }), + ); + } if (url.endsWith("/api/rankings")) { const rankings = options?.rankings ?? { status: "unavailable" as const, @@ -1355,6 +1373,26 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/corporate-entities/corp-demo/related")) { + return Promise.resolve( + jsonResponse({ + corporate_entity_id: "corp-demo", + entity_name: "Demo Corp", + related: [ + { + node_id: "post-1", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Public post", + relevance: 0.8, + post_body_excerpt: "The full body text.", + post_body_truncated: false, + }, + ], + }), + ); + } if (url.endsWith("/api/posts/post-1/affiliate-tree")) { return Promise.resolve( jsonResponse({ @@ -1871,6 +1909,36 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("opening a Customer master related post focuses Event Lineage; a home list open does not", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + const customers = await screen.findByRole("region", { name: "Customer master" }); + expect(within(customers).getByLabelText("Next action")).toHaveTextContent( + "Authorized customer entities are current. Open a related post to read Event Lineage.", + ); + await userEvent.click(within(customers).getByRole("button", { name: /Demo Corp/ })); + await userEvent.click( + await within(customers).findByRole("button", { name: "Open related 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 boardAfterCustomer = screen.getByRole("region", { name: "Board" }); + await userEvent.click( + within(boardAfterCustomer).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 f0198b01c..7935667bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2449,6 +2449,7 @@ type SelectPostOptions = { fromReportMember?: boolean; fromWeeklyVoc?: boolean; fromCalendar?: boolean; + fromCustomerMaster?: boolean; }; /** @@ -3492,12 +3493,14 @@ function PostList({ showLabPanels = false, postIdToOpen = null, postOpenFromCalendar = false, + postOpenFromCustomerMaster = false, onPostOpened, }: { accessToken: string; showLabPanels?: boolean; postIdToOpen?: string | null; postOpenFromCalendar?: boolean; + postOpenFromCustomerMaster?: boolean; onPostOpened?: () => void; }) { const [posts, setPosts] = useState(null); @@ -3518,6 +3521,7 @@ function PostList({ const [openedFromReportMember, setOpenedFromReportMember] = useState(false); const [openedFromWeeklyVoc, setOpenedFromWeeklyVoc] = useState(false); const [openedFromCalendar, setOpenedFromCalendar] = useState(false); + const [openedFromCustomerMaster, setOpenedFromCustomerMaster] = useState(false); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3573,13 +3577,17 @@ function PostList({ setOpenedFromReportMember(Boolean(options?.fromReportMember)); setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); + setOpenedFromCustomerMaster(Boolean(options?.fromCustomerMaster)); } useEffect(() => { if (!postIdToOpen) return; - selectPost(postIdToOpen, postOpenFromCalendar ? { fromCalendar: true } : undefined); + selectPost(postIdToOpen, { + fromCalendar: postOpenFromCalendar, + fromCustomerMaster: postOpenFromCustomerMaster, + }); onPostOpened?.(); - }, [onPostOpened, postIdToOpen, postOpenFromCalendar]); + }, [onPostOpened, postIdToOpen, postOpenFromCalendar, postOpenFromCustomerMaster]); function closeSelectedPost() { setSelectedPostId(null); @@ -3588,6 +3596,7 @@ function PostList({ setOpenedFromReportMember(false); setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); + setOpenedFromCustomerMaster(false); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -4053,7 +4062,12 @@ function PostList({ openedAfterCutoff ? analysisRunOpenedBodyWarning(openedCutoffIso) : null } knowledgeCutoff={openedAfterCutoff ? openedCutoffIso : null} - focusEventLineage={openedFromReportMember || openedFromWeeklyVoc || openedFromCalendar} + focusEventLineage={ + openedFromReportMember || + openedFromWeeklyVoc || + openedFromCalendar || + openedFromCustomerMaster + } onClose={closeSelectedPost} onSelectPost={selectPost} onSearch={searchBoard} @@ -4286,6 +4300,11 @@ function CustomerMasterPanel({

    {t("Authorized customer scope")}

    {t("Customer master")}

    {t("Customer entities available to this account.")}

    + {master && master.corporate_entities.length > 0 ? ( +

    + {t("Authorized customer entities are current. Open a related post to read Event Lineage.")} +

    + ) : null} {error ?

    {error}

    : null} {master === null && !error ?

    {t("Loading customer master...")}

    : null} {master?.corporate_entities.length === 0 ? ( @@ -4546,6 +4565,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean return new URLSearchParams(window.location.search).get("post"); }); const [postOpenFromCalendar, setPostOpenFromCalendar] = useState(false); + const [postOpenFromCustomerMaster, setPostOpenFromCustomerMaster] = 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 @@ -4626,9 +4646,11 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean showLabPanels={testOnlyLabPanels} postIdToOpen={postToOpen} postOpenFromCalendar={postOpenFromCalendar} + postOpenFromCustomerMaster={postOpenFromCustomerMaster} onPostOpened={() => { setPostToOpen(null); setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(false); }} /> ) : null} @@ -4637,6 +4659,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean accessToken={accessToken} onOpenPost={(postId) => { setPostToOpen(postId); + setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(true); setDestination("board"); }} /> @@ -4648,6 +4672,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean onSelectPost={(postId) => { setPostToOpen(postId); setPostOpenFromCalendar(true); + setPostOpenFromCustomerMaster(false); setDestination("board"); }} /> diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 9fa4096d7..fd9341fc2 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -39,6 +39,7 @@ describe("i18n", () => { "Filter by ISO week", "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.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d8f846866..ab261b228 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -122,6 +122,8 @@ const TRANSLATIONS: Partial>> = { "{week} Voice of Customer 글이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 글을 여세요.", "Authorized commitments are current. Open a commitment to read Event Lineage.": "권한이 있는 일정이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 일정을 여세요.", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "권한이 있는 고객 엔터티가 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 관련 글을 여세요.", "Filter by visibility": "공개 여부로 필터", "Sort posts": "글 정렬", "All VOC types": "모든 VOC 유형", @@ -449,6 +451,8 @@ const TRANSLATIONS: Partial>> = { "{week} 的 Voice of Customer 文章为当前内容。打开一篇文章阅读事件谱系。", "Authorized commitments are current. Open a commitment to read Event Lineage.": "已授权承诺为当前内容。打开一项承诺阅读事件谱系。", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "已授权客户实体为当前内容。打开一篇相关文章阅读事件谱系。", "Filter by visibility": "按公开状态筛选", "Sort posts": "排序文章", "All VOC types": "所有 VOC 类型", @@ -799,6 +803,8 @@ const TRANSLATIONS: Partial>> = { "{week}のVoice of Customer投稿が現在表示されています。イベント系譜を読むには投稿を開いてください。", "Authorized commitments are current. Open a commitment to read Event Lineage.": "権限のある約束が現在表示されています。イベント系譜を読むには約束を開いてください。", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "権限のある顧客エンティティが現在表示されています。イベント系譜を読むには関連投稿を開いてください。", "Filter by visibility": "公開状態で絞り込み", "Sort posts": "投稿を並べ替え", "All VOC types": "すべての VOC 種類", @@ -1125,6 +1131,8 @@ const TRANSLATIONS: Partial>> = { "Các bài Voice of Customer của {week} đang hiện tại. Hãy mở một bài để đọc Dòng sự kiện.", "Authorized commitments are current. Open a commitment to read Event Lineage.": "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.", "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 eb3e7edcc..be6e1fcae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.14.0" +version = "2.15.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 f6fccb131bebea4515b14409f3b92b817bad4a9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:16:38 -0700 Subject: [PATCH 34/63] feat(ui): add the accessible TEPP project history timeline --- .../src/components/TeppProjectHistory.tsx | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistory.tsx diff --git a/frontend/src/components/TeppProjectHistory.tsx b/frontend/src/components/TeppProjectHistory.tsx new file mode 100644 index 000000000..e45627e94 --- /dev/null +++ b/frontend/src/components/TeppProjectHistory.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; + +import { + BackendError, + fetchTeppProjectHistory, + type TeppProjectHistoryProjection, +} from "../api"; +import { t, tf } from "../i18n"; +import "./TeppProjectHistory.css"; + +function formatEventDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) return value; + return parsed.toISOString().slice(0, 10); +} + +function findingCopy(code: string): string { + switch (code) { + case "specification_change_and_handoff_before_focus": + return t( + "TEPP temporal association: explicit specification change and handoff precede the focus event; this is temporal association, not causality.", + ); + case "specification_change_before_focus": + return t("TEPP temporal association: an explicit specification change precedes the focus event."); + case "handoff_before_focus": + return t("TEPP temporal association: an explicit operational handoff precedes the focus event."); + case "rebid_after_focus": + return t("TEPP temporal association: an explicit rebid event follows the focus event."); + default: + return t("TEPP temporal association, not causality."); + } +} + +export function TeppProjectHistory({ + projection, + onOpenPost, +}: { + projection: TeppProjectHistoryProjection; + onOpenPost: (postId: string) => void; +}) { + const usesAvailabilityProxy = projection.events.some( + (event) => event.availability_basis_code === "source_created_at_proxy", + ); + const preferredFinding = + projection.findings.find( + (finding) => finding.finding_code === "specification_change_and_handoff_before_focus", + ) ?? projection.findings[0]; + + return ( +
    +
    +
    +

    {t("TEPP-linked answer")}

    +

    + {t("Project event timeline")} +

    +

    {projection.project_name}

    +
    + TEPP +
    + +

    + {tf("{count} explicit participants", { count: String(projection.participant_count) })} + {" · "} + {formatEventDate(projection.history_span_start)} — {formatEventDate(projection.history_span_end)} +

    + +
      + {projection.events.map((event) => { + const isFocus = event.event_id === projection.focus_event_id; + return ( +
    1. +
    2. + ); + })} +
    + +
    +

    {t("Event detail")}

    +

    {preferredFinding ? findingCopy(preferredFinding.finding_code) : t("No coded TEPP association is available for these explicit events.")}

    +

    + {t("TEPP orders explicit evidence and does not infer a causal score or missing event.")} +

    + {usesAvailabilityProxy ? ( +

    + {t( + "The source-created time is an availability proxy until a separate system-availability clock is stored.", + )} +

    + ) : null} +
    +
    + ); +} + +export function TeppProjectHistoryPanel({ + accessToken, + postId, + knowledgeCutoff, + onOpenPost, +}: { + accessToken: string; + postId: string; + knowledgeCutoff?: string; + onOpenPost: (postId: string) => void; +}) { + const [projection, setProjection] = useState(null); + const [unavailable, setUnavailable] = useState(false); + + useEffect(() => { + let cancelled = false; + setProjection(null); + setUnavailable(false); + fetchTeppProjectHistory(accessToken, postId, knowledgeCutoff) + .then((result) => { + if (!cancelled) setProjection(result); + }) + .catch((error: unknown) => { + if (!cancelled) { + setUnavailable(error instanceof BackendError && error.status === 503); + } + }); + return () => { + cancelled = true; + }; + }, [accessToken, knowledgeCutoff, postId]); + + if (unavailable) { + return ( +
    +

    {t("Project event timeline")}

    +

    + {t("TEPP project history is not available; no local timeline substitute was invented.")} +

    +
    + ); + } + if (!projection) { + return ( +
    +

    {t("Project event timeline")}

    +

    {t("Loading TEPP project history...")}

    +
    + ); + } + return ; +} From df0ee780b821ff4d071e4bcfb27c0e292a6ec357 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:17:07 -0700 Subject: [PATCH 35/63] style(ui): present the TEPP timeline responsively --- .../src/components/TeppProjectHistory.css | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistory.css diff --git a/frontend/src/components/TeppProjectHistory.css b/frontend/src/components/TeppProjectHistory.css new file mode 100644 index 000000000..2e4dd0c40 --- /dev/null +++ b/frontend/src/components/TeppProjectHistory.css @@ -0,0 +1,138 @@ +.tepp-project-history { + margin: 1rem 0; + padding: 1.25rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 1rem; + background: linear-gradient(145deg, #f8fafc, #eef4ff); + color: #102a56; +} + +.tepp-project-history-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.tepp-project-history-header h3, +.tepp-project-history-detail h4 { + margin: 0; +} + +.tepp-project-name { + margin: 0.25rem 0 0; + color: #475569; +} + +.tepp-project-history-badge { + flex: 0 0 auto; + padding: 0.35rem 0.65rem; + border: 1px solid #93c5fd; + border-radius: 999px; + background: #eff6ff; + color: #1d4ed8; + font-weight: 700; +} + +.tepp-project-history-meta { + margin: 0.75rem 0 1.25rem; + color: #475569; + font-size: 0.9rem; +} + +.tepp-project-timeline { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.tepp-project-event { + position: relative; + display: grid; + align-content: start; + gap: 0.35rem; + min-width: 0; + padding: 1.1rem 0.75rem 0.75rem; + border-top: 2px solid #b8c5d9; +} + +.tepp-project-event-marker { + position: absolute; + top: -0.55rem; + left: 0.75rem; + width: 1rem; + height: 1rem; + border: 3px solid #ffffff; + border-radius: 50%; + background: #102a56; + box-shadow: 0 0 0 1px #102a56; +} + +.tepp-project-event-focus { + border-top-color: #cc5b67; + background: rgb(204 91 103 / 8%); +} + +.tepp-project-event-focus .tepp-project-event-marker { + background: #cc5b67; + box-shadow: 0 0 0 1px #cc5b67; +} + +.tepp-project-event time, +.tepp-project-event-kind { + color: #64748b; + font-size: 0.8rem; +} + +.tepp-project-event button { + justify-self: start; + margin-top: 0.25rem; +} + +.tepp-project-history-detail { + margin-top: 1rem; + padding: 1rem; + border: 1px solid #dbe5f3; + border-radius: 0.75rem; + background: #ffffff; +} + +.tepp-project-history-detail p { + margin: 0.55rem 0 0; +} + +.tepp-project-history-boundary, +.tepp-project-history-warning { + color: #475569; + font-size: 0.875rem; +} + +.tepp-project-history-warning { + padding-left: 0.75rem; + border-left: 3px solid #d97706; +} + +@media (max-width: 700px) { + .tepp-project-timeline { + display: block; + } + + .tepp-project-event { + margin-left: 0.5rem; + padding: 0.75rem 0.75rem 0.75rem 1.25rem; + border-top: 0; + border-left: 2px solid #b8c5d9; + } + + .tepp-project-event-focus { + border-left-color: #cc5b67; + } + + .tepp-project-event-marker { + top: 0.9rem; + left: -0.55rem; + } +} From ce41de1b48a081084f4890e02f4125f27ca12d92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:17:34 -0700 Subject: [PATCH 36/63] docs(storybook): demonstrate the minimum TEPP buyer timeline --- .../components/TeppProjectHistory.stories.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistory.stories.tsx diff --git a/frontend/src/components/TeppProjectHistory.stories.tsx b/frontend/src/components/TeppProjectHistory.stories.tsx new file mode 100644 index 000000000..7a4c68745 --- /dev/null +++ b/frontend/src/components/TeppProjectHistory.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { TeppProjectHistory } from "./TeppProjectHistory"; + +const meta = { + title: "Buyer/TEPP Project History", + component: TeppProjectHistory, + args: { + onOpenPost: () => undefined, + projection: { + contract_version: 1, + project_key: "P-100", + project_name: "Northridge renewal", + focus_event_id: "voc", + history_span_start: "2022-03-11T09:00:00Z", + history_span_end: "2026-08-10T09:00:00Z", + participant_count: 3, + inference_status: "temporal_association_only", + events: [ + { + event_id: "award", + event_type_code: "contract_awarded", + event_title: "수주", + occurred_at: "2022-03-11T09:00:00Z", + available_at: "2022-03-11T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-award", + evidence_text: "계약 체결 근거", + actor_ids: ["a"], + }, + { + event_id: "spec", + event_type_code: "specification_changed", + event_title: "사양 변경", + occurred_at: "2023-06-15T09:00:00Z", + available_at: "2023-06-15T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-spec", + evidence_text: "사양 변경 근거", + actor_ids: ["a", "b"], + }, + { + event_id: "delivery", + event_type_code: "delivered", + event_title: "납품", + occurred_at: "2024-02-20T09:00:00Z", + available_at: "2024-02-20T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-delivery", + evidence_text: "납품 근거", + actor_ids: ["b"], + }, + { + event_id: "handoff", + event_type_code: "handoff_recorded", + event_title: "운영 인수", + occurred_at: "2024-03-01T09:00:00Z", + available_at: "2024-03-01T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-handoff", + evidence_text: "운영 인수 근거", + actor_ids: ["b", "c"], + }, + { + event_id: "voc", + event_type_code: "voc_received", + event_title: "VOC 접수", + occurred_at: "2026-07-30T09:00:00Z", + available_at: "2026-07-30T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-voc", + evidence_text: "VOC 근거", + actor_ids: ["c"], + }, + { + event_id: "rebid", + event_type_code: "rebid_started", + event_title: "재입찰", + occurred_at: "2026-08-10T09:00:00Z", + available_at: "2026-08-10T09:00:00Z", + availability_basis_code: "source_created_at_proxy", + source_post_id: "post-rebid", + evidence_text: "재입찰 근거", + actor_ids: ["c"], + }, + ], + findings: [ + { + finding_code: "specification_change_and_handoff_before_focus", + summary: "Explicit specification-change and handoff events precede the focus event.", + related_event_ids: ["spec", "handoff"], + evidence_post_ids: ["post-spec", "post-handoff"], + }, + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MinimumBuyerTimeline: Story = {}; From 59b81536f85a65dc854a464b80026c69f89babba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:22:03 +0900 Subject: [PATCH 37/63] fix: preserve Event Lineage focus after related loads --- frontend/src/App.test.tsx | 18 ------------------ frontend/src/App.tsx | 10 ++++++++-- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c5bac0e03..a02d7b86a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -770,24 +770,6 @@ describe("App, authenticated", () => { }), ); } - if (url.endsWith("/api/customer-master")) { - return Promise.resolve( - jsonResponse({ - corporate_entities: [ - { - corporate_entity_id: "corp-demo", - entity_name: "Demo Corp", - corporate_entity_code: "DEMO-CORP-01", - entity_level_code: "company", - parent_entity_id: null, - }, - ], - keymen: [], - source_customer_hints: [], - source_author_hints: [], - }), - ); - } if (url.endsWith("/api/rankings")) { const rankings = options?.rankings ?? { status: "unavailable" as const, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7935667bc..03fe09ab6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -826,6 +826,7 @@ function KeymanPanel({ focusTeam, landFirstKeyman, landFirstRelated, + focusAskAfterRelated, afterList, }: { postId: string; @@ -840,6 +841,7 @@ function KeymanPanel({ focusTeam?: { teamId: string; teamName: string } | null; landFirstKeyman?: boolean; landFirstRelated?: boolean; + focusAskAfterRelated?: boolean; afterList?: ReactNode; }) { const [related, setRelated] = useState(null); @@ -947,13 +949,13 @@ function KeymanPanel({ }, [accessToken, landFirstRelated, related]); useEffect(() => { - if (!landFirstRelated || !landedRelatedName || landedRelated === null) { + if (!landFirstRelated || !focusAskAfterRelated || !landedRelatedName || landedRelated === null) { return; } const heading = document.getElementById("post-ask"); heading?.focus(); heading?.scrollIntoView?.({ block: "nearest" }); - }, [landFirstRelated, landedRelatedName, landedRelated]); + }, [landFirstRelated, focusAskAfterRelated, landedRelatedName, landedRelated]); useEffect(() => { if (!focusPerson) return; @@ -1647,6 +1649,7 @@ function PostDetailPopup({ liveBodyWarning, knowledgeCutoff, focusEventLineage, + focusAskAfterRelated, onClose, onSelectPost, onSearch, @@ -1658,6 +1661,7 @@ function PostDetailPopup({ liveBodyWarning?: string | null; knowledgeCutoff?: string | null; focusEventLineage?: boolean; + focusAskAfterRelated?: boolean; onClose: () => void; onSelectPost?: (postId: string) => void; onSearch?: (query: string) => void; @@ -2229,6 +2233,7 @@ function PostDetailPopup({ focusTeam={focusTeam} landFirstKeyman landFirstRelated + focusAskAfterRelated={focusAskAfterRelated} afterList={ <> Date: Wed, 19 Aug 2026 07:29:55 +0000 Subject: [PATCH 38/63] 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 | 3 ++ ARCHITECTURE.md | 3 +- .../2.16.0-ask-agent-open-event-lineage.md | 5 ++ CHANGELOG.md | 10 ++++ CLAUDE.md | 8 +++ ...74-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, 163 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md create mode 100644 docs/adr/0074-ask-agent-open-focuses-event-lineage.md diff --git a/AGENTS.md b/AGENTS.md index 17425fbd6..5115ac348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,9 @@ Opening a Calendar commitment uses the same focus path (ADR 0072). 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 0073). 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 0074). Do not +invent a week, a theta, a cutoff body, a CalDAV event, a customer, or a cited +post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6d2367713..0d9411a0b 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 0072), Customer master related posts use the same Event Lineage focus path -(ADR 0073). +(ADR 0073). Ask Agent cited posts use the same Event Lineage focus path +(ADR 0074). and a full detail popup: Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the 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 87b9a4d5c..0e7f365c6 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 0074 / ADR 0039 / ADR 0016). + ## [2.15.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index edd36d709..6db5291f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,4 +93,12 @@ related post: Event Lineage takes focus and names Keyman and evaluation next (ADR 0073). 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 0074). A home-list open does not. Do not invent a theta or a +cited post. + + diff --git a/docs/adr/0074-ask-agent-open-focuses-event-lineage.md b/docs/adr/0074-ask-agent-open-focuses-event-lineage.md new file mode 100644 index 000000000..16da4d34a --- /dev/null +++ b/docs/adr/0074-ask-agent-open-focuses-event-lineage.md @@ -0,0 +1,36 @@ +# ADR 0074: 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 0071 / ADR 0072 / ADR 0073). 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 a02d7b86a..1ff3a6cb5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -204,6 +204,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( @@ -1191,6 +1194,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({ @@ -1492,6 +1506,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({ @@ -1921,6 +1944,35 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.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 03fe09ab6..0956e5720 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2455,6 +2455,7 @@ type SelectPostOptions = { fromWeeklyVoc?: boolean; fromCalendar?: boolean; fromCustomerMaster?: boolean; + fromAskAgent?: boolean; }; /** @@ -3499,6 +3500,7 @@ function PostList({ postIdToOpen = null, postOpenFromCalendar = false, postOpenFromCustomerMaster = false, + postOpenFromAskAgent = false, onPostOpened, }: { accessToken: string; @@ -3506,6 +3508,7 @@ function PostList({ postIdToOpen?: string | null; postOpenFromCalendar?: boolean; postOpenFromCustomerMaster?: boolean; + postOpenFromAskAgent?: boolean; onPostOpened?: () => void; }) { const [posts, setPosts] = useState(null); @@ -3527,6 +3530,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); @@ -3583,6 +3587,7 @@ function PostList({ setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); setOpenedFromCustomerMaster(Boolean(options?.fromCustomerMaster)); + setOpenedFromAskAgent(Boolean(options?.fromAskAgent)); } useEffect(() => { @@ -3590,9 +3595,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); @@ -3602,6 +3614,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"); @@ -4071,7 +4084,8 @@ function PostList({ openedFromReportMember || openedFromWeeklyVoc || openedFromCalendar || - openedFromCustomerMaster + openedFromCustomerMaster || + openedFromAskAgent } focusAskAfterRelated={openedFromReportMember} onClose={closeSelectedPost} @@ -4532,11 +4546,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 ? ( @@ -4572,6 +4593,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 @@ -4653,10 +4675,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} @@ -4667,6 +4691,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostToOpen(postId); setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(true); + setPostOpenFromAskAgent(false); setDestination("board"); }} /> @@ -4679,6 +4704,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostToOpen(postId); setPostOpenFromCalendar(true); setPostOpenFromCustomerMaster(false); + setPostOpenFromAskAgent(false); setDestination("board"); }} /> @@ -4688,6 +4714,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 ab261b228..d8b718def 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -124,6 +124,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 유형", @@ -453,6 +456,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 类型", @@ -805,6 +811,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 種類", @@ -1133,6 +1142,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 be6e1fcae..d8e57ef65 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 674b33ee9db3ff646a85de20fe18a7eb3dd66a18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 14:18:47 +0000 Subject: [PATCH 39/63] 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 | 3 ++ ARCHITECTURE.md | 4 +-- ...17.0-event-lineage-node-keeps-gnb-focus.md | 5 +++ CHANGELOG.md | 10 ++++++ CLAUDE.md | 6 ++++ ...0075-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, 108 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/2.17.0-event-lineage-node-keeps-gnb-focus.md create mode 100644 docs/adr/0075-event-lineage-node-keeps-gnb-focus.md diff --git a/AGENTS.md b/AGENTS.md index 5115ac348..1c0c48798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,9 @@ 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 0074). Do not invent a week, a theta, a cutoff body, a CalDAV event, a customer, or a cited post. +A linked Event Lineage node opened from that focused popup keeps the +originating flags (ADR 0075). Do not invent a week, a theta, a cutoff body, +a CalDAV event, a customer, or a cited post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d9411a0b..ea005be9c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -285,8 +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 0072), Customer master related posts use the same Event Lineage focus path (ADR 0073). Ask Agent cited posts use the same Event Lineage focus path -(ADR 0074). -and a full detail popup: Korean +(ADR 0074). A linked Event Lineage node opened from a focused popup +keeps those flags (ADR 0075), and a full detail popup: 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 0e7f365c6..1168c289d 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 0075 / ADR 0074 / ADR 0016). + ## [2.16.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6db5291f9..1ec98cabf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,5 +100,11 @@ a cited post: Event Lineage takes focus and names Keyman and evaluation next (ADR 0074). 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 0075). A home-list +DAG walk does not. Do not invent a theta. + diff --git a/docs/adr/0075-event-lineage-node-keeps-gnb-focus.md b/docs/adr/0075-event-lineage-node-keeps-gnb-focus.md new file mode 100644 index 000000000..851b92384 --- /dev/null +++ b/docs/adr/0075-event-lineage-node-keeps-gnb-focus.md @@ -0,0 +1,35 @@ +# ADR 0075: 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 0071 / ADR 0072 / ADR 0073 / ADR 0074). 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 1ff3a6cb5..282f3e6fe 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1973,6 +1973,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 0956e5720..dabc1b0d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4089,7 +4089,15 @@ function PostList({ } focusAskAfterRelated={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 8be6ccad8dece107c6dbbf4dd9b3960ea5810f34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:16:33 +0900 Subject: [PATCH 40/63] 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 dabc1b0d4..fc5d77b79 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({ } focusAskAfterRelated={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 f9d750b49471c666cf67986e5792e1e646968be1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:25:06 -0700 Subject: [PATCH 41/63] ci: materialize the bounded PR 281 integration patch --- .../scripts/fix_281_tepp_project_history.py | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 .github/scripts/fix_281_tepp_project_history.py diff --git a/.github/scripts/fix_281_tepp_project_history.py b/.github/scripts/fix_281_tepp_project_history.py new file mode 100644 index 000000000..113b40f5d --- /dev/null +++ b/.github/scripts/fix_281_tepp_project_history.py @@ -0,0 +1,261 @@ +"""Apply the bounded TEPP project-history buyer-surface integration for PR 281.""" + +from __future__ import annotations + +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact anchor or accept an already-applied replacement.""" + target = Path(path) + text = target.read_text(encoding="utf-8") + if new in text: + return + if text.count(old) != 1: + raise SystemExit(f"{path}: expected one integration anchor, found {text.count(old)}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def patch_api() -> None: + """Add the public project-history response types and GET client.""" + types_anchor = """export interface IssueTicket { +""" + types = """export interface TeppProjectHistoryEvent { + event_id: string; + event_type_code: string; + event_title: string; + occurred_at: string; + available_at: string; + availability_basis_code: string; + source_post_id: string; + evidence_text: string; + actor_ids: string[]; +} + +export interface TeppProjectHistoryFinding { + finding_code: string; + summary: string; + related_event_ids: string[]; + evidence_post_ids: string[]; +} + +export interface TeppProjectHistoryProjection { + contract_version: 1; + project_key: string; + project_name: string; + focus_event_id: string; + history_span_start: string; + history_span_end: string; + participant_count: number; + inference_status: "temporal_association_only"; + events: TeppProjectHistoryEvent[]; + findings: TeppProjectHistoryFinding[]; +} + +export interface IssueTicket { +""" + replace_once("frontend/src/api.ts", types_anchor, types) + + function_anchor = """export function fetchPostContent(accessToken: string, postId: string): Promise { +""" + function = """export function fetchTeppProjectHistory( + accessToken: string, + postId: string, + knowledgeCutoff?: string, +): Promise { + const query = knowledgeCutoff + ? `?knowledge_cutoff=${encodeURIComponent(knowledgeCutoff)}` + : ""; + return backendFetch( + `/api/posts/${postId}/project-history${query}`, + accessToken, + ); +} + +export function fetchPostContent(accessToken: string, postId: string): Promise { +""" + replace_once("frontend/src/api.ts", function_anchor, function) + + +def patch_app() -> None: + """Place the shared timeline on document, post-Ask, and Global Ask surfaces.""" + import_anchor = 'import { CutoffKnownBody } from "./components/CutoffKnownBody";\n' + import_line = ( + 'import { CutoffKnownBody } from "./components/CutoffKnownBody";\n' + 'import { TeppProjectHistoryPanel } from "./components/TeppProjectHistory";\n' + ) + replace_once("frontend/src/App.tsx", import_anchor, import_line) + + post_anchor = """ + {(post.source_stage_code || +""" + post_panel = """ + onSelectPost?.(evidencePostId)} + /> + {(post.source_stage_code || +""" + replace_once("frontend/src/App.tsx", post_anchor, post_panel) + + ask_state_anchor = """ const [asking, setAsking] = useState(false); + + async function handleAsk() { +""" + ask_state = """ const [asking, setAsking] = useState(false); + const timelinePostId = + answer?.cited_posts?.[0]?.post_id ?? answer?.cited_post_ids[0] ?? null; + + async function handleAsk() { +""" + replace_once("frontend/src/App.tsx", ask_state_anchor, ask_state) + + ask_answer_anchor = """ {answer.next_action ?

      {t(answer.next_action)}

      : null} + {answer.cited_posts && answer.cited_posts.length > 0 && ( +""" + ask_answer = """ {answer.next_action ?

      {t(answer.next_action)}

      : null} + {timelinePostId ? ( + + ) : null} + {answer.cited_posts && answer.cited_posts.length > 0 && ( +""" + replace_once("frontend/src/App.tsx", ask_answer_anchor, ask_answer) + + +def patch_backend() -> None: + """Expose an authorized, no-local-substitute TEPP project-history endpoint.""" + replace_once( + "backend/app/main.py", + "from datetime import datetime\n", + "from datetime import datetime, timezone\n", + ) + + client_import_anchor = "from lineageweave.http_client import HttpClientError\n" + client_imports = """from lineageweave.http_client import HttpClientError +from lineageweave.tepp_project_history import ( + PROJECT_HISTORY_PATH, + TeppProjectHistoryNotAvailable, + configured_tepp_project_history_client, +) +""" + replace_once("backend/app/main.py", client_import_anchor, client_imports) + + builder_import_anchor = "from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n" + builder_imports = """from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from backend.app.tepp_project_history import ( + build_project_history_request, + fetch_project_history_rows, +) +""" + replace_once("backend/app/main.py", builder_import_anchor, builder_imports) + + helper_anchor = """def _post_evaluation_client(): +""" + helper = """def _tepp_project_history_client(): + \"\"\"Build the credential-free TEPP project-history channel or fail closed.\"\"\" + configured = load_settings().tepp_transport_url.strip() + if configured.endswith("/v1/analysis-runs"): + configured = configured[: -len("/v1/analysis-runs")] + PROJECT_HISTORY_PATH + elif configured and not configured.endswith(PROJECT_HISTORY_PATH): + configured = "" + return configured_tepp_project_history_client(configured) + + +def _post_evaluation_client(): +""" + replace_once("backend/app/main.py", helper_anchor, helper) + + endpoint_anchor = """@app.get("/api/posts/{post_id}/content") +async def read_post_content( +""" + endpoint = """@app.get("/api/posts/{post_id}/project-history") +async def read_tepp_project_history( + post_id: str, + knowledge_cutoff: str | None = Query(None, max_length=64), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + \"\"\"Return TEPP's cutoff-safe project timeline for one visible post. + + LineageWeave selects only authorized source evidence. TEPP owns temporal + validation and coded associations. Missing TEPP returns 503; this endpoint + never fabricates a local psychometric or causal substitute. + \"\"\" + _require_post_read(account) + if knowledge_cutoff is None: + cutoff = datetime.now(timezone.utc) + else: + try: + cutoff = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "knowledge_cutoff must be an ISO-8601 timestamp.", + ) from exc + async with pool.acquire() as conn: + focus = await conn.fetchrow( + "select post_id, visibility_code, corporate_entity_id " + f"from source_post where post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", + post_id, + ) + if focus is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "post not found") + if not _can_see_post(account, focus): + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post") + rows = await fetch_project_history_rows( + conn, + focus_post_id=post_id, + knowledge_cutoff=cutoff, + can_see=lambda row: _can_see_post(account, row), + ) + if not rows: + raise HTTPException(status.HTTP_404_NOT_FOUND, "project history evidence not found") + try: + request = build_project_history_request( + rows, + focus_post_id=post_id, + tenant_workspace_id=str(focus["corporate_entity_id"]), + knowledge_cutoff=cutoff, + ) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "visible project evidence does not satisfy the TEPP request contract.", + ) from exc + client = _tepp_project_history_client() + if not client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "TEPP project history is not configured; no local substitute was invented.", + ) + try: + projection = await asyncio.to_thread(client.project, request) + except (TeppProjectHistoryNotAvailable, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "TEPP project history is unavailable or failed contract validation.", + ) from exc + return projection.to_json() + + +@app.get("/api/posts/{post_id}/content") +async def read_post_content( +""" + replace_once("backend/app/main.py", endpoint_anchor, endpoint) + + +def main() -> None: + """Apply the exact bounded integration.""" + patch_api() + patch_app() + patch_backend() + + +if __name__ == "__main__": + main() From 2c69d97e379967e8317f92b8b58e3b2a09443e9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:25:51 -0700 Subject: [PATCH 42/63] ci: verify and publish the PR 281 TEPP integration --- .../repair-281-tepp-project-history.yml | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/repair-281-tepp-project-history.yml diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml new file mode 100644 index 000000000..518c8477d --- /dev/null +++ b/.github/workflows/repair-281-tepp-project-history.yml @@ -0,0 +1,96 @@ +name: Repair PR 281 TEPP project history + +on: + pull_request: + types: [synchronize] + +permissions: + contents: write + +concurrency: + group: repair-pr-281-tepp-project-history + cancel-in-progress: true + +jobs: + integrate-and-verify: + if: github.event.pull_request.number == 281 && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + REPAIR_BRANCH: feat/tepp-project-history-buyer-surface-v2180 + REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha }} + steps: + - name: Checkout the exact PR head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed Python lock + run: uv sync --frozen --extra dev --extra backend + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install frontend dependencies + working-directory: frontend + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Apply the bounded endpoint and buyer-surface integration + run: | + python -m py_compile .github/scripts/fix_281_tepp_project_history.py + python .github/scripts/fix_281_tepp_project_history.py + git diff --check + + - name: Verify TEPP consumer contracts + run: | + uv run --frozen python -m pytest -q tests/test_tepp_project_history.py + uv run --frozen python -m compileall -q lineageweave backend/app + uv run --frozen python -m pytest -q + + - name: Verify accessible buyer surfaces + working-directory: frontend + run: | + pnpm exec vitest run src/components/TeppProjectHistory.test.tsx src/App.test.tsx + pnpm run lint + pnpm run build + pnpm run build-storybook + + - name: Commit only the exact-head validated integration + shell: bash + run: | + rm -f .github/workflows/repair-281-tepp-project-history.yml + rm -f .github/scripts/fix_281_tepp_project_history.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add backend/app/main.py frontend/src/api.ts frontend/src/App.tsx + git add -u .github/workflows .github/scripts + git diff --cached --check + git commit -m "feat: connect TEPP project histories to Buyer Ask surfaces" + test -z "$(git status --porcelain)" || { + echo 'repair left uncommitted or untracked files' >&2 + git status --short + exit 1 + } + git fetch origin "${REPAIR_BRANCH}" + remote_head="$(git rev-parse "origin/${REPAIR_BRANCH}")" + if [ "$remote_head" != "$REPAIR_BASE_SHA" ]; then + echo "PR head moved from ${REPAIR_BASE_SHA} to ${remote_head}; refusing to publish an unverified integration." >&2 + exit 1 + fi + git push origin "HEAD:${REPAIR_BRANCH}" From 3ea68e715fed9abc0e2112844e33188def055519 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:41:10 -0700 Subject: [PATCH 43/63] ci: run project-history repair on exact branch pushes --- .../workflows/repair-281-tepp-project-history.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml index 518c8477d..00daf63b2 100644 --- a/.github/workflows/repair-281-tepp-project-history.yml +++ b/.github/workflows/repair-281-tepp-project-history.yml @@ -1,8 +1,9 @@ name: Repair PR 281 TEPP project history on: - pull_request: - types: [synchronize] + push: + branches: [feat/tepp-project-history-buyer-surface-v2180] + workflow_dispatch: permissions: contents: write @@ -13,16 +14,16 @@ concurrency: jobs: integrate-and-verify: - if: github.event.pull_request.number == 281 && github.event.pull_request.head.repo.full_name == github.repository + if: github.ref == 'refs/heads/feat/tepp-project-history-buyer-surface-v2180' runs-on: ubuntu-latest env: REPAIR_BRANCH: feat/tepp-project-history-buyer-surface-v2180 - REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha }} + REPAIR_BASE_SHA: ${{ github.sha }} steps: - - name: Checkout the exact PR head + - name: Checkout the exact branch head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.sha }} persist-credentials: true fetch-depth: 0 From 88b1ac699f82babd13a637ce7fe429ab6fece9c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:42:40 -0700 Subject: [PATCH 44/63] ci: bound project-history repair runtime --- .github/workflows/repair-281-tepp-project-history.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml index 00daf63b2..164612149 100644 --- a/.github/workflows/repair-281-tepp-project-history.yml +++ b/.github/workflows/repair-281-tepp-project-history.yml @@ -16,6 +16,7 @@ jobs: integrate-and-verify: if: github.ref == 'refs/heads/feat/tepp-project-history-buyer-surface-v2180' runs-on: ubuntu-latest + timeout-minutes: 60 env: REPAIR_BRANCH: feat/tepp-project-history-buyer-surface-v2180 REPAIR_BASE_SHA: ${{ github.sha }} From fe8c2f1a9ec53a3bbaba95dd2bd01671b770c838 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:43:38 -0700 Subject: [PATCH 45/63] ci: expose project-history repair to PR exact-head runs --- .github/workflows/repair-281-tepp-project-history.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml index 164612149..6c3b513ec 100644 --- a/.github/workflows/repair-281-tepp-project-history.yml +++ b/.github/workflows/repair-281-tepp-project-history.yml @@ -3,6 +3,9 @@ name: Repair PR 281 TEPP project history on: push: branches: [feat/tepp-project-history-buyer-surface-v2180] + pull_request: + branches: [feat/event-lineage-node-keeps-gnb-focus-v2170] + types: [synchronize] workflow_dispatch: permissions: @@ -14,17 +17,19 @@ concurrency: jobs: integrate-and-verify: - if: github.ref == 'refs/heads/feat/tepp-project-history-buyer-surface-v2180' + if: >- + github.ref == 'refs/heads/feat/tepp-project-history-buyer-surface-v2180' || + (github.event_name == 'pull_request' && github.event.pull_request.number == 281 && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 60 env: REPAIR_BRANCH: feat/tepp-project-history-buyer-surface-v2180 - REPAIR_BASE_SHA: ${{ github.sha }} + REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} steps: - name: Checkout the exact branch head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: - ref: ${{ github.sha }} + ref: ${{ env.REPAIR_BASE_SHA }} persist-credentials: true fetch-depth: 0 From a2a677d3f9809685ca1507951d01de5c190a8a42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:44:40 -0700 Subject: [PATCH 46/63] ci: trigger project-history verification on PR edits --- .github/workflows/repair-281-tepp-project-history.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml index 6c3b513ec..4dfb17471 100644 --- a/.github/workflows/repair-281-tepp-project-history.yml +++ b/.github/workflows/repair-281-tepp-project-history.yml @@ -5,7 +5,7 @@ on: branches: [feat/tepp-project-history-buyer-surface-v2180] pull_request: branches: [feat/event-lineage-node-keeps-gnb-focus-v2170] - types: [synchronize] + types: [synchronize, edited] workflow_dispatch: permissions: From ca91dfe582c4a130105228ddb3a080d501f76b4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:51:58 -0700 Subject: [PATCH 47/63] test(red): define evidence-bound project history projection --- tests/test_project_history_projection.py | 176 +++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/test_project_history_projection.py diff --git a/tests/test_project_history_projection.py b/tests/test_project_history_projection.py new file mode 100644 index 000000000..014b65be1 --- /dev/null +++ b/tests/test_project_history_projection.py @@ -0,0 +1,176 @@ +"""Project history projections preserve authority, chronology, and gaps.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from lineageweave.project_history import ( + build_project_history_projection, + classify_project_event, + normalize_project_key, + responsibility_transition_code, +) + + +def event(post_id: str, title: str, day: int, **extra: object) -> dict[str, object]: + """Return one already-authorized source row.""" + + return { + "post_id": post_id, + "post_title": title, + "created_at": datetime(2026, 1, day, 9, tzinfo=timezone.utc), + "voc_type_code": "vom", + "source_stage_code": None, + "source_detail_state_code": None, + **extra, + } + + +def match(post_id: str, kind: str = "source_project_code", value: str = "P-100") -> dict[str, object]: + """Return one matching explicit or semantic project fact.""" + + return { + "post_id": post_id, + "match_kind_code": kind, + "matched_value": value, + "confidence": None if kind.startswith("source_") else 0.91, + "ontology_iri": None if kind.startswith("source_") else "https://w3id.org/lineageweave#Project", + "provenance": kind, + } + + +def role(post_id: str, name: str, person_id: str | None) -> dict[str, object]: + """Return one observed R&R row.""" + + return { + "post_id": post_id, + "actor_name": name, + "responsibility": "Own the event", + "actor_type_code": "prov_person", + "affiliated_organization_name": "Demo Corp", + "cataloged_person_id": person_id, + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, + } + + +def test_normalization_and_display_classification_are_deterministic() -> None: + assert normalize_project_key(" P-100 ") == "p-100" + assert classify_project_event( + title="Specification revision requested", + source_stage_code=None, + source_detail_state_code=None, + voc_type_code="vom", + is_focus=False, + ) == "specification_changed" + assert classify_project_event( + title="Account note", + source_stage_code=None, + source_detail_state_code=None, + voc_type_code="voc", + is_focus=False, + ) == "source_recorded" + assert classify_project_event( + title="Account note", + source_stage_code=None, + source_detail_state_code=None, + voc_type_code="voc", + is_focus=True, + ) == "voc_received" + with pytest.raises(ValueError, match="empty"): + normalize_project_key(" ") + + +def test_responsibility_transition_does_not_invent_assignment_facts() -> None: + assert responsibility_transition_code(["person:a"], ["person:a"]) == "continuous" + assert responsibility_transition_code(["person:a"], ["person:b"]) == "handoff" + assert responsibility_transition_code([], ["person:b"]) == "assignment_gap" + assert responsibility_transition_code(["person:a"], []) == "assignment_gap" + + +def test_projection_deduplicates_matches_and_explains_visible_prior_paths() -> None: + events = [ + event("voc", "VOC received", 4, voc_type_code="voc"), + event("award", "Contract awarded", 1), + event("spec", "Specification revision requested", 2), + event("delivery", "Delivery confirmed", 3), + event("spec", "Duplicate transport row", 2), + ] + matches = [ + match("award"), + match("award", "semantic_project_key"), + match("spec"), + match("delivery", "semantic_project_name", "P-100"), + match("voc"), + match("voc"), + ] + roles = [ + role("award", "Ada", "person-a"), + role("spec", "Ada", "person-a"), + role("delivery", "Priya", "person-b"), + ] + edges = [ + {"parent_post_id": "award", "child_post_id": "spec", "fused_score": 0.91}, + {"parent_post_id": "spec", "child_post_id": "delivery", "fused_score": 0.82}, + {"parent_post_id": "delivery", "child_post_id": "voc", "fused_score": 0.73}, + {"parent_post_id": "voc", "child_post_id": "award", "fused_score": 0.99}, + {"parent_post_id": "hidden", "child_post_id": "voc", "fused_score": 1.0}, + ] + + projection = build_project_history_projection( + project_key="P-100", + focus_event_id="voc", + event_rows=events, + match_rows=matches, + role_rows=roles, + edge_rows=edges, + ) + + assert [item["event_id"] for item in projection["events"]] == [ + "award", + "spec", + "delivery", + "voc", + ] + assert projection["event_count"] == 4 + assert projection["distinct_observed_actor_count"] == 2 + assert [item["responsibility_transition_code"] for item in projection["events"]] == [ + None, + "continuous", + "handoff", + "assignment_gap", + ] + assert len(projection["events"][0]["project_matches"]) == 2 + assert len(projection["events"][3]["project_matches"]) == 1 + + voc_paths = projection["events"][3]["related_prior_paths"] + assert [path["source_event_id"] for path in voc_paths] == ["delivery", "spec", "award"] + assert voc_paths[-1]["event_ids"] == ["award", "spec", "delivery", "voc"] + assert voc_paths[-1]["minimum_fused_score"] == pytest.approx(0.73) + assert all(path["truth_status_code"] == "inferred" for path in voc_paths) + assert all("hidden" not in path["event_ids"] for path in voc_paths) + + +def test_projection_rejects_invisible_focus_and_out_of_bound_options() -> None: + rows = [event("award", "Contract awarded", 1)] + with pytest.raises(ValueError, match="focus"): + build_project_history_projection( + project_key="P-100", + focus_event_id="hidden", + event_rows=rows, + match_rows=[match("award")], + role_rows=[], + edge_rows=[], + ) + with pytest.raises(ValueError, match="maximum_depth"): + build_project_history_projection( + project_key="P-100", + focus_event_id="award", + event_rows=rows, + match_rows=[match("award")], + role_rows=[], + edge_rows=[], + maximum_depth=0, + ) From f174743f930329abcf754545b53e5f85dfdd280e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:54:15 -0700 Subject: [PATCH 48/63] feat: build visible project-history evidence paths --- lineageweave/project_history.py | 396 ++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 lineageweave/project_history.py diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py new file mode 100644 index 000000000..b594c3d0f --- /dev/null +++ b/lineageweave/project_history.py @@ -0,0 +1,396 @@ +"""Build evidence-bound project histories from already-authorized rows. + +The module is deliberately storage-agnostic. Callers must apply RBAC, ABAC, +source eligibility, and knowledge-cutoff filtering before invoking it. It then +orders visible source records, keeps explicit and semantic project matches +separate, projects observed responsibility evidence, and explains persisted +lineage paths without promoting them to causal or authoritative facts. +""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any +from unicodedata import normalize + +PROJECT_HISTORY_CONTRACT_VERSION = 1 +PROJECT_HISTORY_TIME_BASIS = "document_time" +PROJECT_HISTORY_MAX_DEPTH = 8 +PROJECT_HISTORY_MAX_PATHS_PER_EVENT = 32 + +_EVENT_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("rebid_started", ("rebid", "re-bid", "retender", "re-tender", "재입찰")), + ( + "handoff_recorded", + ("handoff", "hand-off", "transferred ownership", "operational transfer", "인수인계"), + ), + ( + "specification_changed", + ( + "specification change", + "specification revision", + "revised specification", + "spec revision", + "사양 변경", + "사양변경", + ), + ), + ( + "delivered", + ( + "delivery confirmed", + "delivery completed", + "delivered", + "shipment completed", + "납품 완료", + "납품완료", + ), + ), + ( + "contract_awarded", + ( + "contract awarded", + "award confirmed", + "order confirmation", + "purchase order received", + "수주 확정", + "수주확정", + ), + ), +) +_VOC_CODES = frozenset({"voc", "vocc", "voco", "vom", "vop"}) + + +def normalize_project_key(value: str) -> str: + """Return the exact project-identity comparison key. + + Compatibility normalization lets full-width and compatibility forms match + while preserving a deterministic, locale-neutral lower-case comparison. + Empty values are rejected rather than becoming a match-all key. + """ + + normalized = normalize("NFKC", value).strip().lower() + if not normalized: + raise ValueError("project key must not be empty") + if len(normalized.encode("utf-8")) > 256: + raise ValueError("project key exceeds 256 UTF-8 bytes") + return normalized + + +def classify_project_event( + *, + title: str, + source_stage_code: str | None, + source_detail_state_code: str | None, + voc_type_code: str | None, + is_focus: bool, +) -> str: + """Classify a display event from explicit source text and codes. + + The code is presentation metadata only. It never creates a new event or + changes the truth status of the source record. + """ + + text = " ".join( + part.strip().lower() + for part in (title, source_stage_code or "", source_detail_state_code or "") + if part.strip() + ) + for event_code, patterns in _EVENT_PATTERNS: + if any(pattern in text for pattern in patterns): + return event_code + if is_focus and (voc_type_code or "").strip().lower() in _VOC_CODES: + return "voc_received" + return "source_recorded" + + +def responsibility_transition_code( + previous_actor_keys: Sequence[str], current_actor_keys: Sequence[str] +) -> str: + """Classify adjacent observed responsibility evidence. + + Missing evidence on either event is an ``assignment_gap``. Equal non-empty + actor sets are ``continuous``; different non-empty sets are ``handoff``. + The result describes document evidence, not an HR assignment fact. + """ + + previous = frozenset(key for key in previous_actor_keys if key) + current = frozenset(key for key in current_actor_keys if key) + if not previous or not current: + return "assignment_gap" + if previous == current: + return "continuous" + return "handoff" + + +def _as_utc(value: datetime) -> str: + """Serialize a datetime as canonical UTC RFC 3339 text.""" + + aware = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _actor_key(role: Mapping[str, Any]) -> str: + """Return a stable key for one observed R&R actor.""" + + catalog_fields = ( + ("person", role.get("cataloged_person_id")), + ("team", role.get("cataloged_team_id")), + ("organization", role.get("cataloged_corporate_entity_id")), + ) + for prefix, value in catalog_fields: + if value: + return f"{prefix}:{value}" + parts = ( + str(role.get("actor_type_code") or "unknown"), + str(role.get("actor_name") or ""), + str(role.get("affiliated_organization_name") or ""), + ) + return "text:" + "\u001f".join(normalize("NFKC", part).strip().lower() for part in parts) + + +def _score(value: object) -> float: + """Return a finite JSON-compatible lineage score.""" + + if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + raise ValueError("lineage score must be numeric") + result = float(value) + if result != result or result in (float("inf"), float("-inf")): + raise ValueError("lineage score must be finite") + return result + + +def _prior_paths( + ordered_event_ids: Sequence[str], + edge_rows: Sequence[Mapping[str, Any]], + *, + maximum_depth: int, + maximum_paths_per_event: int, +) -> dict[str, list[dict[str, Any]]]: + """Return one deterministic shortest visible path per prior event.""" + + event_index = {event_id: index for index, event_id in enumerate(ordered_event_ids)} + reverse_edges: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_event_ids} + for row in edge_rows: + parent = str(row["parent_post_id"]) + child = str(row["child_post_id"]) + if parent not in event_index or child not in event_index: + continue + if event_index[parent] >= event_index[child]: + continue + reverse_edges[child].append( + { + "parent_event_id": parent, + "child_event_id": child, + "fused_score": _score(row["fused_score"]), + } + ) + for edges in reverse_edges.values(): + edges.sort(key=lambda edge: (event_index[edge["parent_event_id"]], edge["parent_event_id"])) + + result: dict[str, list[dict[str, Any]]] = {} + for target in ordered_event_ids: + queue: deque[tuple[str, tuple[str, ...], tuple[dict[str, Any], ...]]] = deque( + [(target, (target,), ())] + ) + best_depth = {target: 0} + paths: list[dict[str, Any]] = [] + while queue and len(paths) < maximum_paths_per_event: + current, reverse_event_path, reverse_edge_path = queue.popleft() + depth = len(reverse_edge_path) + if depth >= maximum_depth: + continue + for edge in reverse_edges[current]: + parent = edge["parent_event_id"] + if parent in reverse_event_path: + continue + next_depth = depth + 1 + if best_depth.get(parent, maximum_depth + 1) <= next_depth: + continue + best_depth[parent] = next_depth + next_events = reverse_event_path + (parent,) + next_edges = reverse_edge_path + (edge,) + ordered_events = list(reversed(next_events)) + ordered_edges = list(reversed(next_edges)) + paths.append( + { + "source_event_id": parent, + "target_event_id": target, + "event_ids": ordered_events, + "edges": ordered_edges, + "minimum_fused_score": min(item["fused_score"] for item in ordered_edges), + "truth_status_code": "inferred", + "source_relation_code": "post_lineage_edge", + "provenance": "post_lineage_edge.fused_score", + } + ) + queue.append((parent, next_events, next_edges)) + if len(paths) >= maximum_paths_per_event: + break + paths.sort( + key=lambda path: ( + len(path["edges"]), + event_index[path["source_event_id"]], + tuple(path["event_ids"]), + ) + ) + result[target] = paths + return result + + +def build_project_history_projection( + *, + project_key: str, + focus_event_id: str | None, + event_rows: Sequence[Mapping[str, Any]], + match_rows: Sequence[Mapping[str, Any]], + role_rows: Sequence[Mapping[str, Any]], + edge_rows: Sequence[Mapping[str, Any]], + truncated: bool = False, + maximum_depth: int = PROJECT_HISTORY_MAX_DEPTH, + maximum_paths_per_event: int = PROJECT_HISTORY_MAX_PATHS_PER_EVENT, +) -> dict[str, Any]: + """Build the versioned Buyer project-history projection. + + All input rows must already be visible, eligible, and within the requested + knowledge cutoff. Duplicate event rows are collapsed by ``post_id`` and the + final chronology is stable on ``(created_at, post_id)``. + """ + + normalized_key = normalize_project_key(project_key) + if maximum_depth < 1 or maximum_depth > PROJECT_HISTORY_MAX_DEPTH: + raise ValueError("maximum_depth is outside the supported bound") + if maximum_paths_per_event < 1 or maximum_paths_per_event > PROJECT_HISTORY_MAX_PATHS_PER_EVENT: + raise ValueError("maximum_paths_per_event is outside the supported bound") + + deduplicated: dict[str, Mapping[str, Any]] = {} + for row in event_rows: + event_id = str(row["post_id"]) + current = deduplicated.get(event_id) + if current is None or (row["created_at"], event_id) < (current["created_at"], event_id): + deduplicated[event_id] = row + ordered_rows = sorted(deduplicated.values(), key=lambda row: (row["created_at"], str(row["post_id"]))) + if not ordered_rows: + raise ValueError("project history requires at least one visible event") + ordered_ids = [str(row["post_id"]) for row in ordered_rows] + effective_focus = focus_event_id or ordered_ids[-1] + if effective_focus not in set(ordered_ids): + raise ValueError("focus event is not in the visible project history") + + matches_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids} + display_names: list[str] = [] + seen_matches: set[tuple[str, str, str]] = set() + for row in match_rows: + event_id = str(row["post_id"]) + if event_id not in matches_by_event: + continue + matched_value = str(row["matched_value"]) + if normalize_project_key(matched_value) != normalized_key: + continue + kind = str(row["match_kind_code"]) + key = (event_id, kind, matched_value) + if key in seen_matches: + continue + seen_matches.add(key) + confidence = row.get("confidence") + if confidence is not None: + confidence = _score(confidence) + truth = "observed" if kind.startswith("source_") else "inferred" + matches_by_event[event_id].append( + { + "match_kind_code": kind, + "matched_value": matched_value, + "truth_status_code": truth, + "confidence": confidence, + "ontology_iri": row.get("ontology_iri"), + "provenance": str(row["provenance"]), + } + ) + if kind.endswith("name"): + display_names.append(matched_value) + for matches in matches_by_event.values(): + matches.sort(key=lambda item: (item["truth_status_code"], item["match_kind_code"], item["matched_value"])) + + roles_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids} + actor_keys_by_event: dict[str, list[str]] = {event_id: [] for event_id in ordered_ids} + distinct_actor_keys: set[str] = set() + for row in role_rows: + event_id = str(row["post_id"]) + if event_id not in roles_by_event: + continue + actor_key = _actor_key(row) + distinct_actor_keys.add(actor_key) + actor_keys_by_event[event_id].append(actor_key) + roles_by_event[event_id].append( + { + "actor_key": actor_key, + "actor_name": str(row["actor_name"]), + "actor_type_code": str(row["actor_type_code"]), + "affiliated_organization_name": row.get("affiliated_organization_name"), + "responsibility": str(row["responsibility"]), + "truth_status_code": "observed", + "provenance": "post_summary_role", + } + ) + for roles in roles_by_event.values(): + roles.sort(key=lambda role: (role["actor_type_code"], role["actor_name"], role["actor_key"])) + + paths_by_event = _prior_paths( + ordered_ids, + edge_rows, + maximum_depth=maximum_depth, + maximum_paths_per_event=maximum_paths_per_event, + ) + + events: list[dict[str, Any]] = [] + previous_actor_keys: Sequence[str] | None = None + for row in ordered_rows: + event_id = str(row["post_id"]) + current_actor_keys = actor_keys_by_event[event_id] + transition = ( + None + if previous_actor_keys is None + else responsibility_transition_code(previous_actor_keys, current_actor_keys) + ) + events.append( + { + "event_id": event_id, + "source_post_id": event_id, + "event_title": str(row["post_title"]), + "event_type_code": classify_project_event( + title=str(row["post_title"]), + source_stage_code=row.get("source_stage_code"), + source_detail_state_code=row.get("source_detail_state_code"), + voc_type_code=row.get("voc_type_code"), + is_focus=event_id == effective_focus, + ), + "event_type_basis_code": "display_classification", + "occurred_at": _as_utc(row["created_at"]), + "time_basis_code": PROJECT_HISTORY_TIME_BASIS, + "voc_type_code": row.get("voc_type_code"), + "source_stage_code": row.get("source_stage_code"), + "source_detail_state_code": row.get("source_detail_state_code"), + "project_matches": matches_by_event[event_id], + "observed_responsibilities": roles_by_event[event_id], + "responsibility_transition_code": transition, + "related_prior_paths": paths_by_event[event_id], + } + ) + previous_actor_keys = current_actor_keys + + project_name = display_names[0] if display_names else project_key.strip() + return { + "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, + "project_key": project_key.strip(), + "normalized_project_key": normalized_key, + "project_name": project_name, + "focus_event_id": effective_focus, + "time_basis_code": PROJECT_HISTORY_TIME_BASIS, + "event_count": len(events), + "distinct_observed_actor_count": len(distinct_actor_keys), + "truncated": bool(truncated), + "events": events, + } From ed01445b642f637541612b7dda2efb9ce8077681 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:57:39 -0700 Subject: [PATCH 49/63] test(red): require ABAC-safe project history repository --- tests/test_project_history_repository.py | 123 +++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/test_project_history_repository.py diff --git a/tests/test_project_history_repository.py b/tests/test_project_history_repository.py new file mode 100644 index 000000000..de4c15966 --- /dev/null +++ b/tests/test_project_history_repository.py @@ -0,0 +1,123 @@ +"""The project-history repository applies authorization before composition.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import pytest + +from backend.app.project_history import ( + PROJECT_HISTORY_MAXIMUM_LIMIT, + ProjectHistoryNotFound, + fetch_project_history_projection, +) + + +class FakeConnection: + """Return deterministic rows while recording every SQL invocation.""" + + def __init__(self, responses: list[list[dict[str, Any]]]) -> None: + self.responses = responses + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object) -> list[dict[str, Any]]: + self.calls.append((query, args)) + return self.responses.pop(0) + + +def source(post_id: str, day: int) -> dict[str, Any]: + """Return one visible project event row.""" + + return { + "post_id": post_id, + "post_title": "Contract awarded" if day == 1 else "VOC received", + "created_at": datetime(2026, 1, day, 9, tzinfo=timezone.utc), + "voc_type_code": "vom" if day == 1 else "voc", + "source_stage_code": None, + "source_detail_state_code": None, + } + + +def test_repository_bounds_abac_first_and_constrains_every_child_read() -> None: + connection = FakeConnection( + [ + [source("award", 1), source("voc", 2)], + [ + { + "post_id": "award", + "match_kind_code": "source_project_code", + "matched_value": "P-100", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_code", + }, + { + "post_id": "voc", + "match_kind_code": "semantic_project_key", + "matched_value": "P-100", + "confidence": 0.9, + "ontology_iri": "https://w3id.org/lineageweave#Project", + "provenance": "post_project_mention.project_key", + }, + ], + [], + [{"parent_post_id": "award", "child_post_id": "voc", "fused_score": 0.8}], + ] + ) + cutoff = datetime(2026, 1, 3, tzinfo=timezone.utc) + + result = asyncio.run( + fetch_project_history_projection( + connection, # type: ignore[arg-type] + project_key="P-100", + focus_post_id="voc", + knowledge_cutoff=cutoff, + corporate_entity_ids=["corp-1"], + limit=8, + ) + ) + + assert result["event_count"] == 2 + event_query, event_args = connection.calls[0] + assert "visibility_code = 'public'" in event_query + assert "corporate_entity_id::text = any($2::text[])" in event_query + assert "source_draft_code" in event_query + assert "source_deleted_flag" in event_query + assert "post.created_at <= $3" in event_query + assert "post_project_mention" in event_query + assert event_args == ("p-100", ["corp-1"], cutoff, 9) + for _query, args in connection.calls[1:]: + assert args[0] == ["award", "voc"] + + +def test_repository_reports_truncation_and_rejects_hidden_focus() -> None: + connection = FakeConnection([[source("award", 1), source("voc", 2)]]) + with pytest.raises(ProjectHistoryNotFound): + asyncio.run( + fetch_project_history_projection( + connection, # type: ignore[arg-type] + project_key="P-100", + focus_post_id="hidden", + knowledge_cutoff=datetime(2026, 1, 3, tzinfo=timezone.utc), + corporate_entity_ids=[], + limit=1, + ) + ) + + +def test_repository_rejects_unbounded_limits_before_sql() -> None: + connection = FakeConnection([]) + with pytest.raises(ValueError, match="limit"): + asyncio.run( + fetch_project_history_projection( + connection, # type: ignore[arg-type] + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + limit=PROJECT_HISTORY_MAXIMUM_LIMIT + 1, + ) + ) + assert connection.calls == [] From 854081f1d5b1ffc015e224faac95a720f445ae95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:59:23 -0700 Subject: [PATCH 50/63] feat: load project histories through ABAC-safe evidence reads --- backend/app/project_history.py | 190 +++++++++++++++++++++ migrations/0046_project_history_lookup.sql | 31 ++++ 2 files changed, 221 insertions(+) create mode 100644 backend/app/project_history.py create mode 100644 migrations/0046_project_history_lookup.sql diff --git a/backend/app/project_history.py b/backend/app/project_history.py new file mode 100644 index 000000000..75b0faa51 --- /dev/null +++ b/backend/app/project_history.py @@ -0,0 +1,190 @@ +"""ABAC-safe PostgreSQL projection for Buyer project histories.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, Protocol + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.project_history import build_project_history_projection, normalize_project_key + +PROJECT_HISTORY_DEFAULT_LIMIT = 64 + + +class ProjectHistoryConnection(Protocol): + """Minimal asynchronous query port required by this repository.""" + + async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: + """Execute a bounded read query and return mapping-like rows.""" + + ... + + +PROJECT_HISTORY_MAXIMUM_LIMIT = 128 + +_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +_PROJECT_MATCH = """ +( + lower(normalize(btrim(coalesce(post.source_project_code, '')), NFKC)) = $1 + or lower(normalize(btrim(coalesce(post.source_project_name, '')), NFKC)) = $1 + or exists ( + select 1 + from post_project_mention mention + where mention.post_id = post.post_id + and ( + lower(normalize(btrim(mention.project_key), NFKC)) = $1 + or lower(normalize(btrim(mention.project_name), NFKC)) = $1 + ) + ) +) +""" +_EVENT_SQL = f""" +select post.post_id, + post.post_title, + post.created_at, + post.voc_type_code, + post.source_stage_code, + post.source_detail_state_code + from source_post post + where (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($2::text[])) + and {_ELIGIBILITY} + and post.created_at <= $3 + and {_PROJECT_MATCH} + order by post.created_at, post.post_id + limit $4 +""" +_MATCH_SQL = """ +select post.post_id, + 'source_project_code'::text as match_kind_code, + post.source_project_code as matched_value, + null::numeric as confidence, + null::text as ontology_iri, + 'source_post.source_project_code'::text as provenance + from source_post post + where post.post_id = any($1::uuid[]) + and lower(normalize(btrim(coalesce(post.source_project_code, '')), NFKC)) = $2 +union all +select post.post_id, + 'source_project_name'::text, + post.source_project_name, + null::numeric, + null::text, + 'source_post.source_project_name'::text + from source_post post + where post.post_id = any($1::uuid[]) + and lower(normalize(btrim(coalesce(post.source_project_name, '')), NFKC)) = $2 +union all +select mention.post_id, + 'semantic_project_key'::text, + mention.project_key, + mention.confidence, + mention.ontology_iri, + 'post_project_mention.project_key'::text + from post_project_mention mention + where mention.post_id = any($1::uuid[]) + and lower(normalize(btrim(mention.project_key), NFKC)) = $2 +union all +select mention.post_id, + 'semantic_project_name'::text, + mention.project_name, + mention.confidence, + mention.ontology_iri, + 'post_project_mention.project_name'::text + from post_project_mention mention + where mention.post_id = any($1::uuid[]) + and lower(normalize(btrim(mention.project_name), NFKC)) = $2 +order by post_id, match_kind_code, matched_value +""" +_ROLE_SQL = """ +select role.post_id, + role.actor_name, + role.responsibility, + role.actor_type_code, + role.affiliated_organization_name, + role.cataloged_person_id, + role.cataloged_team_id, + role.cataloged_corporate_entity_id + from post_summary_role role + where role.post_id = any($1::uuid[]) + order by role.post_id, role.actor_type_code, role.actor_name, role.responsibility +""" +_EDGE_SQL = """ +select edge.parent_post_id, edge.child_post_id, edge.fused_score + from post_lineage_edge edge + where edge.parent_post_id = any($1::uuid[]) + and edge.child_post_id = any($1::uuid[]) + order by edge.child_post_id, edge.parent_post_id +""" + + +class ProjectHistoryNotFound(LookupError): + """No authorized project history matched the requested identity.""" + + +async def fetch_project_history_projection( + conn: ProjectHistoryConnection, + *, + project_key: str, + focus_post_id: str | None, + knowledge_cutoff: datetime, + corporate_entity_ids: Sequence[str], + limit: int = PROJECT_HISTORY_DEFAULT_LIMIT, +) -> dict[str, Any]: + """Return a bounded project history from authorized PostgreSQL evidence. + + The query applies source eligibility, cutoff, and ABAC before selecting + event IDs. All subsequent match, role, and lineage reads are constrained to + that visible ID set, so hidden rows cannot affect counts, transitions, or + prior-history paths. + """ + + if limit < 1 or limit > PROJECT_HISTORY_MAXIMUM_LIMIT: + raise ValueError("project history limit is outside the supported bound") + normalized_key = normalize_project_key(project_key) + rows = list( + await conn.fetch( + _EVENT_SQL, + normalized_key, + list(corporate_entity_ids), + knowledge_cutoff, + limit + 1, + ) + ) + truncated = len(rows) > limit + event_rows = rows[:limit] + if not event_rows: + raise ProjectHistoryNotFound(project_key) + visible_ids = [str(row["post_id"]) for row in event_rows] + if focus_post_id is not None and focus_post_id not in set(visible_ids): + raise ProjectHistoryNotFound(project_key) + + match_rows, role_rows, edge_rows = await _fetch_project_children( + conn, + visible_ids=visible_ids, + normalized_key=normalized_key, + ) + return build_project_history_projection( + project_key=project_key, + focus_event_id=focus_post_id, + event_rows=event_rows, + match_rows=match_rows, + role_rows=role_rows, + edge_rows=edge_rows, + truncated=truncated, + ) + + +async def _fetch_project_children( + conn: ProjectHistoryConnection, + *, + visible_ids: Sequence[str], + normalized_key: str, +) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]: + """Fetch only child evidence whose endpoints are already authorized.""" + + matches = list(await conn.fetch(_MATCH_SQL, list(visible_ids), normalized_key)) + roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids))) + edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids))) + return matches, roles, edges diff --git a/migrations/0046_project_history_lookup.sql b/migrations/0046_project_history_lookup.sql new file mode 100644 index 000000000..c957bb980 --- /dev/null +++ b/migrations/0046_project_history_lookup.sql @@ -0,0 +1,31 @@ +begin; + +-- Exact NFKC/lower lookup keys keep explicit and semantic project evidence +-- indexable without changing the underlying source or inference truth status. +create index if not exists source_post_project_code_history_idx + on source_post ( + lower(normalize(btrim(source_project_code), NFKC)), + created_at, + post_id + ) + where source_project_code is not null and btrim(source_project_code) <> ''; + +create index if not exists source_post_project_name_history_idx + on source_post ( + lower(normalize(btrim(source_project_name), NFKC)), + created_at, + post_id + ) + where source_project_name is not null and btrim(source_project_name) <> ''; + +create index if not exists post_project_mention_name_history_idx + on post_project_mention ( + lower(normalize(btrim(project_name), NFKC)), + post_id + ); + +create index if not exists post_lineage_edge_child_history_idx + on post_lineage_edge (child_post_id, parent_post_id) + include (fused_score); + +commit; From c57f1cd13acfb15d98174849cb44e05c7bcd088e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:06:26 -0700 Subject: [PATCH 51/63] test(red): require reversible project-history lookup indexes --- tests/test_project_history_migration.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_project_history_migration.py diff --git a/tests/test_project_history_migration.py b/tests/test_project_history_migration.py new file mode 100644 index 000000000..fd9be2c8e --- /dev/null +++ b/tests/test_project_history_migration.py @@ -0,0 +1,34 @@ +"""Project-history indexes are reversible and cover every exact match key.""" + +from pathlib import Path + + +_ROOT = Path(__file__).resolve().parents[1] +_MIGRATION = _ROOT / "migrations" / "0046_project_history_lookup.sql" +_ROLLBACK = _ROOT / "migrations" / "rollback" / "0046_project_history_lookup.sql" + + +def test_project_history_migration_indexes_explicit_and_semantic_keys() -> None: + """Every exact project-identity read has a normalized lookup index.""" + + sql = _MIGRATION.read_text(encoding="utf-8") + assert "source_post_project_code_history_idx" in sql + assert "source_post_project_name_history_idx" in sql + assert "post_project_mention_key_history_idx" in sql + assert "post_project_mention_name_history_idx" in sql + assert "post_lineage_edge_child_history_idx" in sql + assert sql.count("normalize(") >= 4 + + +def test_project_history_migration_has_a_complete_idempotent_rollback() -> None: + """The additive index migration can be rolled back without guessing.""" + + sql = _ROLLBACK.read_text(encoding="utf-8").lower() + for index_name in ( + "post_lineage_edge_child_history_idx", + "post_project_mention_name_history_idx", + "post_project_mention_key_history_idx", + "source_post_project_name_history_idx", + "source_post_project_code_history_idx", + ): + assert f"drop index if exists {index_name}" in sql From 891eadd893518e470c2dd89e39af35afb340968f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:07:38 -0700 Subject: [PATCH 52/63] feat: index and roll back exact project-history lookups --- migrations/0046_project_history_lookup.sql | 6 ++++++ migrations/rollback/0046_project_history_lookup.sql | 9 +++++++++ 2 files changed, 15 insertions(+) create mode 100644 migrations/rollback/0046_project_history_lookup.sql diff --git a/migrations/0046_project_history_lookup.sql b/migrations/0046_project_history_lookup.sql index c957bb980..92ca0a3bb 100644 --- a/migrations/0046_project_history_lookup.sql +++ b/migrations/0046_project_history_lookup.sql @@ -18,6 +18,12 @@ create index if not exists source_post_project_name_history_idx ) where source_project_name is not null and btrim(source_project_name) <> ''; +create index if not exists post_project_mention_key_history_idx + on post_project_mention ( + lower(normalize(btrim(project_key), NFKC)), + post_id + ); + create index if not exists post_project_mention_name_history_idx on post_project_mention ( lower(normalize(btrim(project_name), NFKC)), diff --git a/migrations/rollback/0046_project_history_lookup.sql b/migrations/rollback/0046_project_history_lookup.sql new file mode 100644 index 000000000..99de4c084 --- /dev/null +++ b/migrations/rollback/0046_project_history_lookup.sql @@ -0,0 +1,9 @@ +begin; + +drop index if exists post_lineage_edge_child_history_idx; +drop index if exists post_project_mention_name_history_idx; +drop index if exists post_project_mention_key_history_idx; +drop index if exists source_post_project_name_history_idx; +drop index if exists source_post_project_code_history_idx; + +commit; From cbd1c086f6d7bf35f2e1bb027ea30c2d5acd5765 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:12:47 -0700 Subject: [PATCH 53/63] test: prove project history authorization in PostgreSQL --- .../repair-281-tepp-project-history.yml | 33 +- tests/test_project_history_postgres.py | 382 ++++++++++++++++++ 2 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 tests/test_project_history_postgres.py diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml index 4dfb17471..5cbb3597e 100644 --- a/.github/workflows/repair-281-tepp-project-history.yml +++ b/.github/workflows/repair-281-tepp-project-history.yml @@ -1,4 +1,4 @@ -name: Repair PR 281 TEPP project history +name: Repair PR 281 project history on: push: @@ -12,7 +12,7 @@ permissions: contents: write concurrency: - group: repair-pr-281-tepp-project-history + group: repair-pr-281-project-history cancel-in-progress: true jobs: @@ -22,7 +22,20 @@ jobs: (github.event_name == 'pull_request' && github.event.pull_request.number == 281 && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 60 + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres REPAIR_BRANCH: feat/tepp-project-history-buyer-surface-v2180 REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} steps: @@ -64,16 +77,24 @@ jobs: python .github/scripts/fix_281_tepp_project_history.py git diff --check - - name: Verify TEPP consumer contracts + - name: Verify project-history contracts against PostgreSQL run: | - uv run --frozen python -m pytest -q tests/test_tepp_project_history.py + uv run --frozen python -m pytest -q \ + tests/test_project_history_projection.py \ + tests/test_project_history_repository.py \ + tests/test_project_history_migration.py \ + tests/test_project_history_postgres.py \ + tests/test_tepp_project_history.py uv run --frozen python -m compileall -q lineageweave backend/app uv run --frozen python -m pytest -q - name: Verify accessible buyer surfaces working-directory: frontend run: | - pnpm exec vitest run src/components/TeppProjectHistory.test.tsx src/App.test.tsx + pnpm exec vitest run \ + src/components/TeppProjectHistory.test.tsx \ + src/App.test.tsx \ + src/i18n.test.ts pnpm run lint pnpm run build pnpm run build-storybook @@ -88,7 +109,7 @@ jobs: git add backend/app/main.py frontend/src/api.ts frontend/src/App.tsx git add -u .github/workflows .github/scripts git diff --cached --check - git commit -m "feat: connect TEPP project histories to Buyer Ask surfaces" + git commit -m "feat: connect project histories to Buyer Ask surfaces" test -z "$(git status --porcelain)" || { echo 'repair left uncommitted or untracked files' >&2 git status --short diff --git a/tests/test_project_history_postgres.py b/tests/test_project_history_postgres.py new file mode 100644 index 000000000..0082f3138 --- /dev/null +++ b/tests/test_project_history_postgres.py @@ -0,0 +1,382 @@ +"""Real-PostgreSQL proof that hidden records cannot influence project history.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +import os +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit +import uuid + +import asyncpg +import psycopg2 +from psycopg2 import sql +import pytest + +from backend.app.project_history import fetch_project_history_projection + + +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_ROOT = Path(__file__).resolve().parents[1] +_MIGRATIONS = tuple( + _ROOT / "migrations" / name + for name in ( + "0001_initial_schema.sql", + "0031_semantic_project_mentions.sql", + "0033_source_state_provenance.sql", + "0034_source_context_provenance.sql", + "0038_source_named_hints.sql", + "0046_project_history_lookup.sql", + ) +) + + +def _postgres_available() -> bool: + """Return whether the configured PostgreSQL service accepts connections.""" + + try: + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except psycopg2.OperationalError: + return False + + +pytestmark = pytest.mark.skipif( + not _postgres_available(), + reason=f"no reachable PostgreSQL server at {_ADMIN_DSN}", +) + + +def _database_dsn(database_name: str) -> str: + """Replace the DSN database path while preserving connection options.""" + + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +@pytest.fixture +def project_history_database() -> tuple[str, str]: + """Create a migrated database with visible, hidden, and excluded evidence.""" + + database_name = f"lineageweave_project_history_{uuid.uuid4().hex[:12]}" + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + with admin.cursor() as cursor: + cursor.execute(sql.SQL("create database {}").format(sql.Identifier(database_name))) + database_dsn = _database_dsn(database_name) + connection = psycopg2.connect(database_dsn) + try: + with connection.cursor() as cursor: + for migration in _MIGRATIONS: + cursor.execute(migration.read_text(encoding="utf-8")) + cursor.execute( + """ + insert into common_lookup_value + (lookup_category, lookup_code, lookup_label) + values + ('corporate_entity_level', 'company', 'Company'), + ('post_visibility', 'public', 'Public'), + ('post_visibility', 'private', 'Private'), + ('voc_type', 'voc', 'Voice of Customer'), + ('voc_type', 'vom', 'Voice of Market'), + ('person_side', 'our_side', 'Our side'), + ('prov_agent_type', 'prov_person', 'Person'), + ('prov_agent_type', 'prov_organization', 'Organization'), + ('prov_agent_type', 'prov_team', 'Team') + on conflict (lookup_code) do nothing + """ + ) + cursor.execute( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('OWN-CORP', 'Own Corp', 'company') + returning corporate_entity_id + """ + ) + own_corporate_entity_id = cursor.fetchone()[0] + cursor.execute( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('OTHER-CORP', 'Other Corp', 'company') + returning corporate_entity_id + """ + ) + other_corporate_entity_id = cursor.fetchone()[0] + cursor.execute( + """ + insert into user_account + (external_subject_id, display_name, email_address) + values ('history-user', 'History User', 'history@example.test') + returning user_account_id + """ + ) + account_id = cursor.fetchone()[0] + + post_ids: dict[str, str] = {} + rows = ( + ( + "award", + own_corporate_entity_id, + "public", + "Contract awarded", + "vom", + "P-100", + None, + None, + "2026-01-01T09:00:00Z", + ), + ( + "spec", + own_corporate_entity_id, + "private", + "Specification revision requested", + "vom", + "P-100", + None, + None, + "2026-01-02T09:00:00Z", + ), + ( + "delivery", + own_corporate_entity_id, + "public", + "Delivery confirmed", + "vom", + None, + None, + None, + "2026-01-03T09:00:00Z", + ), + ( + "voc", + own_corporate_entity_id, + "public", + "VOC received", + "voc", + "P-100", + None, + None, + "2026-01-04T09:00:00Z", + ), + ( + "hidden", + other_corporate_entity_id, + "private", + "Hidden handoff", + "vom", + "P-100", + None, + None, + "2026-01-03T12:00:00Z", + ), + ( + "draft", + own_corporate_entity_id, + "public", + "Draft rebid", + "vom", + "P-100", + "draft", + None, + "2026-01-05T09:00:00Z", + ), + ( + "deleted", + own_corporate_entity_id, + "public", + "Deleted rebid", + "vom", + "P-100", + None, + "deleted", + "2026-01-05T10:00:00Z", + ), + ( + "future", + own_corporate_entity_id, + "public", + "Future rebid", + "vom", + "P-100", + None, + None, + "2026-02-01T09:00:00Z", + ), + ) + for ( + key, + corporate_id, + visibility, + title, + voc, + project_code, + draft, + deleted, + created_at, + ) in rows: + cursor.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, source_project_code, + source_project_name, source_draft_code, source_deleted_flag, + created_at, updated_at) + values (%s, %s, %s, 'Synthetic project evidence', %s, %s, + %s, 'Northridge renewal', %s, %s, %s, %s) + returning post_id + """, + ( + account_id, + corporate_id, + title, + voc, + visibility, + project_code, + draft, + deleted, + created_at, + created_at, + ), + ) + post_ids[key] = str(cursor.fetchone()[0]) + + cursor.execute( + """ + insert into post_project_mention + (post_id, project_key, project_name, evidence_text, + confidence, ontology_iri, extraction_method) + values + (%s, 'P-100', 'Northridge renewal', + 'The delivered project was identified semantically.', 0.910, + 'https://w3id.org/lineageweave#Project', + 'contextual_orchestrator_semantic'), + (%s, 'P-100', 'Northridge renewal', + 'The awarded project also has semantic evidence.', 0.990, + 'https://w3id.org/lineageweave#Project', + 'contextual_orchestrator_semantic') + """, + (post_ids["delivery"], post_ids["award"]), + ) + + people: dict[str, str] = {} + for name in ("Ada", "Priya", "Hidden Person"): + cursor.execute( + """ + insert into cataloged_person (person_name, person_side_code) + values (%s, 'our_side') returning person_id + """, + (name,), + ) + people[name] = str(cursor.fetchone()[0]) + for post_key, actor_name in ( + ("award", "Ada"), + ("spec", "Ada"), + ("delivery", "Priya"), + ("hidden", "Hidden Person"), + ): + cursor.execute( + """ + insert into post_summary_result (post_id, korean_summary) + values (%s, 'Synthetic summary') + """, + (post_ids[post_key],), + ) + cursor.execute( + """ + insert into post_summary_role + (post_id, actor_name, responsibility, actor_type_code, + affiliated_organization_name, cataloged_person_id) + values (%s, %s, 'Own the event', 'prov_person', 'Own Corp', %s) + """, + (post_ids[post_key], actor_name, people[actor_name]), + ) + + for parent, child, score in ( + ("award", "spec", 0.91), + ("spec", "delivery", 0.82), + ("delivery", "voc", 0.73), + ("hidden", "voc", 1.00), + ): + cursor.execute( + """ + insert into post_lineage_edge + (parent_post_id, child_post_id, fused_score) + values (%s, %s, %s) + """, + (post_ids[parent], post_ids[child], score), + ) + connection.commit() + finally: + connection.close() + + try: + yield database_dsn, str(own_corporate_entity_id) + finally: + with admin.cursor() as cursor: + cursor.execute( + "select pg_terminate_backend(pid) from pg_stat_activity where datname = %s", + (database_name,), + ) + cursor.execute(sql.SQL("drop database {}").format(sql.Identifier(database_name))) + admin.close() + + +def test_hidden_draft_deleted_and_future_evidence_cannot_change_history( + project_history_database: tuple[str, str], +) -> None: + """Exercise production SQL and prove authorization precedes composition.""" + + database_dsn, own_corporate_entity_id = project_history_database + + async def run() -> tuple[dict[str, object], str]: + connection = await asyncpg.connect(database_dsn) + try: + focus_post_id = str( + await connection.fetchval( + "select post_id from source_post where post_title = 'VOC received'" + ) + ) + hidden_post_id = str( + await connection.fetchval( + "select post_id from source_post where post_title = 'Hidden handoff'" + ) + ) + projection = await fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id=focus_post_id, + knowledge_cutoff=datetime.fromisoformat("2026-01-31T23:59:59+00:00"), + corporate_entity_ids=[own_corporate_entity_id], + limit=16, + ) + return projection, hidden_post_id + finally: + await connection.close() + + projection, hidden_post_id = asyncio.run(run()) + titles = [event["event_title"] for event in projection["events"]] + assert titles == [ + "Contract awarded", + "Specification revision requested", + "Delivery confirmed", + "VOC received", + ] + assert projection["distinct_observed_actor_count"] == 2 + assert [event["responsibility_transition_code"] for event in projection["events"]] == [ + None, + "continuous", + "handoff", + "assignment_gap", + ] + assert all("Hidden" not in title for title in titles) + assert all( + hidden_post_id not in path["event_ids"] + for event in projection["events"] + for path in event["related_prior_paths"] + ) + assert len(projection["events"][0]["project_matches"]) == 2 From 4761443ab98d6c76ecc86e70e4894601016b716b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:15:32 -0700 Subject: [PATCH 54/63] test(red): retain the authorized focus in bounded histories --- tests/test_project_history_repository.py | 60 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/test_project_history_repository.py b/tests/test_project_history_repository.py index de4c15966..fa4719a86 100644 --- a/tests/test_project_history_repository.py +++ b/tests/test_project_history_repository.py @@ -23,10 +23,25 @@ def __init__(self, responses: list[list[dict[str, Any]]]) -> None: self.calls: list[tuple[str, tuple[object, ...]]] = [] async def fetch(self, query: str, *args: object) -> list[dict[str, Any]]: + """Return the next prepared query result.""" + self.calls.append((query, args)) return self.responses.pop(0) +def project_match(post_id: str) -> dict[str, object]: + """Return one exact explicit project match row.""" + + return { + "post_id": post_id, + "match_kind_code": "source_project_code", + "matched_value": "P-100", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_code", + } + + def source(post_id: str, day: int) -> dict[str, Any]: """Return one visible project event row.""" @@ -34,13 +49,15 @@ def source(post_id: str, day: int) -> dict[str, Any]: "post_id": post_id, "post_title": "Contract awarded" if day == 1 else "VOC received", "created_at": datetime(2026, 1, day, 9, tzinfo=timezone.utc), - "voc_type_code": "vom" if day == 1 else "voc", + "voc_type_code": "vom", "source_stage_code": None, "source_detail_state_code": None, } def test_repository_bounds_abac_first_and_constrains_every_child_read() -> None: + """Child queries receive only the IDs admitted by the primary ABAC read.""" + connection = FakeConnection( [ [source("award", 1), source("voc", 2)], @@ -93,6 +110,8 @@ def test_repository_bounds_abac_first_and_constrains_every_child_read() -> None: def test_repository_reports_truncation_and_rejects_hidden_focus() -> None: + """A focus outside the authorized ID set fails without revealing why.""" + connection = FakeConnection([[source("award", 1), source("voc", 2)]]) with pytest.raises(ProjectHistoryNotFound): asyncio.run( @@ -108,6 +127,8 @@ def test_repository_reports_truncation_and_rejects_hidden_focus() -> None: def test_repository_rejects_unbounded_limits_before_sql() -> None: + """Invalid limits fail before any database read.""" + connection = FakeConnection([]) with pytest.raises(ValueError, match="limit"): asyncio.run( @@ -121,3 +142,40 @@ def test_repository_rejects_unbounded_limits_before_sql() -> None: ) ) assert connection.calls == [] + + +class FocusAwareConnection: + """Route fake responses by query purpose instead of call order.""" + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + """Return focus, timeline, or child evidence for the requested SQL.""" + + if "post.post_id = $4" in query: + return [source("focus", 10)] + if "limit $4" in query: + return [source("award", 1), source("middle", 2), source("overflow", 3)] + if "match_kind_code" in query: + return [project_match("award"), project_match("focus")] + if "from post_summary_role" in query: + return [] + if "from post_lineage_edge" in query: + return [] + raise AssertionError(f"unexpected project-history query: {query}") + + +def test_repository_keeps_an_authorized_focus_when_history_is_truncated() -> None: + """The current Buyer event stays visible even beyond the earliest page.""" + + projection = asyncio.run( + fetch_project_history_projection( + FocusAwareConnection(), # type: ignore[arg-type] + project_key="P-100", + focus_post_id="focus", + knowledge_cutoff=datetime(2026, 1, 31, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + limit=2, + ) + ) + + assert projection["truncated"] is True + assert [event["event_id"] for event in projection["events"]] == ["award", "focus"] From 5af133042aad8ea81bd678d3b8506ad31ef44157 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:19:11 -0700 Subject: [PATCH 55/63] fix: keep the current event in truncated project histories --- backend/app/project_history.py | 38 +++++++++++++++++++++--- tests/test_project_history_repository.py | 2 +- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/backend/app/project_history.py b/backend/app/project_history.py index 75b0faa51..68d163ae5 100644 --- a/backend/app/project_history.py +++ b/backend/app/project_history.py @@ -10,6 +10,7 @@ from lineageweave.project_history import build_project_history_projection, normalize_project_key PROJECT_HISTORY_DEFAULT_LIMIT = 64 +PROJECT_HISTORY_MAXIMUM_LIMIT = 128 class ProjectHistoryConnection(Protocol): @@ -21,8 +22,6 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: ... -PROJECT_HISTORY_MAXIMUM_LIMIT = 128 - _ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") _PROJECT_MATCH = """ ( @@ -55,6 +54,22 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: order by post.created_at, post.post_id limit $4 """ +_FOCUS_SQL = f""" +select post.post_id, + post.post_title, + post.created_at, + post.voc_type_code, + post.source_stage_code, + post.source_detail_state_code + from source_post post + where (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($2::text[])) + and {_ELIGIBILITY} + and post.created_at <= $3 + and post.post_id = $4::uuid + and {_PROJECT_MATCH} + limit 1 +""" _MATCH_SQL = """ select post.post_id, 'source_project_code'::text as match_kind_code, @@ -137,7 +152,8 @@ async def fetch_project_history_projection( The query applies source eligibility, cutoff, and ABAC before selecting event IDs. All subsequent match, role, and lineage reads are constrained to that visible ID set, so hidden rows cannot affect counts, transitions, or - prior-history paths. + prior-history paths. An authorized focus event remains in a truncated + projection even when it falls beyond the earliest page. """ if limit < 1 or limit > PROJECT_HISTORY_MAXIMUM_LIMIT: @@ -158,7 +174,21 @@ async def fetch_project_history_projection( raise ProjectHistoryNotFound(project_key) visible_ids = [str(row["post_id"]) for row in event_rows] if focus_post_id is not None and focus_post_id not in set(visible_ids): - raise ProjectHistoryNotFound(project_key) + focus_rows = list( + await conn.fetch( + _FOCUS_SQL, + normalized_key, + list(corporate_entity_ids), + knowledge_cutoff, + focus_post_id, + ) + ) + if not focus_rows: + raise ProjectHistoryNotFound(project_key) + truncated = True + event_rows = (event_rows[: limit - 1] if limit > 1 else []) + [focus_rows[0]] + event_rows.sort(key=lambda row: (row["created_at"], str(row["post_id"]))) + visible_ids = [str(row["post_id"]) for row in event_rows] match_rows, role_rows, edge_rows = await _fetch_project_children( conn, diff --git a/tests/test_project_history_repository.py b/tests/test_project_history_repository.py index fa4719a86..44ab85da9 100644 --- a/tests/test_project_history_repository.py +++ b/tests/test_project_history_repository.py @@ -112,7 +112,7 @@ def test_repository_bounds_abac_first_and_constrains_every_child_read() -> None: def test_repository_reports_truncation_and_rejects_hidden_focus() -> None: """A focus outside the authorized ID set fails without revealing why.""" - connection = FakeConnection([[source("award", 1), source("voc", 2)]]) + connection = FakeConnection([[source("award", 1), source("voc", 2)], []]) with pytest.raises(ProjectHistoryNotFound): asyncio.run( fetch_project_history_projection( From 5bc174d7c6111e5dfb1642d378257493fca96665 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:22:23 -0700 Subject: [PATCH 56/63] test(red): define the authorized project-history HTTP contract --- tests/test_project_history_api.py | 171 ++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/test_project_history_api.py diff --git a/tests/test_project_history_api.py b/tests/test_project_history_api.py new file mode 100644 index 000000000..7c4ba4094 --- /dev/null +++ b/tests/test_project_history_api.py @@ -0,0 +1,171 @@ +"""The project-history HTTP contract is authorized, bounded, and non-leaking.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from fastapi import HTTPException +import pytest + +from backend.app.auth import CurrentAccount +from backend.app import project_history_api as api +from backend.app.project_history import ProjectHistoryNotFound + + +class _Acquire: + """Minimal asynchronous pool acquisition context.""" + + def __init__(self, connection: object) -> None: + self.connection = connection + + async def __aenter__(self) -> object: + return self.connection + + async def __aexit__(self, *args: object) -> None: + return None + + +class _Pool: + """Record whether the endpoint acquired a database connection.""" + + def __init__(self) -> None: + self.connection = object() + self.acquired = False + + def acquire(self) -> _Acquire: + """Return one asynchronous acquisition context.""" + + self.acquired = True + return _Acquire(self.connection) + + +def _account(*permissions: str) -> CurrentAccount: + """Return one provisioned account with a deterministic ABAC scope.""" + + return CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Buyer", + preferred_locale="en", + corporate_entity_ids=frozenset({"corp-1"}), + permission_codes=frozenset(permissions), + ) + + +def test_endpoint_rejects_missing_permission_before_database_access() -> None: + """A valid token without post_read cannot probe project existence.""" + + pool = _Pool() + with pytest.raises(HTTPException) as captured: + asyncio.run( + api.read_project_history( + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=None, + limit=64, + account=_account(), + pool=pool, # type: ignore[arg-type] + ) + ) + assert captured.value.status_code == 403 + assert pool.acquired is False + + +def test_endpoint_rejects_invalid_cutoff_before_database_access() -> None: + """Malformed cutoff text fails without issuing an evidence query.""" + + pool = _Pool() + with pytest.raises(HTTPException) as captured: + asyncio.run( + api.read_project_history( + project_key="P-100", + focus_post_id=None, + knowledge_cutoff="not-a-clock", + limit=64, + account=_account("post_read"), + pool=pool, # type: ignore[arg-type] + ) + ) + assert captured.value.status_code == 422 + assert pool.acquired is False + + +def test_endpoint_maps_hidden_and_missing_history_to_the_same_404( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The response never distinguishes absent project evidence from hidden evidence.""" + + async def missing(*args: object, **kwargs: object) -> dict[str, Any]: + raise ProjectHistoryNotFound("P-100") + + monkeypatch.setattr(api, "fetch_project_history_projection", missing) + with pytest.raises(HTTPException) as captured: + asyncio.run( + api.read_project_history( + project_key="P-100", + focus_post_id=UUID("00000000-0000-0000-0000-000000000100"), + knowledge_cutoff="2026-01-31T23:59:59Z", + limit=64, + account=_account("post_read"), + pool=_Pool(), # type: ignore[arg-type] + ) + ) + assert captured.value.status_code == 404 + assert captured.value.detail == "project history not found" + + +def test_endpoint_passes_exact_scope_cutoff_focus_and_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The repository receives only the authenticated scope and parsed clock.""" + + captured: dict[str, object] = {} + expected = { + "contract_version": 1, + "project_key": "P-100", + "normalized_project_key": "p-100", + "project_name": "Project 100", + "focus_event_id": "00000000-0000-0000-0000-000000000100", + "time_basis_code": "document_time", + "event_count": 0, + "distinct_observed_actor_count": 0, + "truncated": False, + "events": [], + } + + async def found(connection: object, **kwargs: object) -> dict[str, Any]: + captured["connection"] = connection + captured.update(kwargs) + return expected + + monkeypatch.setattr(api, "fetch_project_history_projection", found) + pool = _Pool() + result = asyncio.run( + api.read_project_history( + project_key="P-100", + focus_post_id=UUID("00000000-0000-0000-0000-000000000100"), + knowledge_cutoff="2026-01-31T23:59:59Z", + limit=32, + account=_account("post_read"), + pool=pool, # type: ignore[arg-type] + ) + ) + + assert result == expected + assert captured["connection"] is pool.connection + assert captured["project_key"] == "P-100" + assert captured["focus_post_id"] == "00000000-0000-0000-0000-000000000100" + assert captured["knowledge_cutoff"] == datetime( + 2026, + 1, + 31, + 23, + 59, + 59, + tzinfo=timezone.utc, + ) + assert captured["corporate_entity_ids"] == ["corp-1"] + assert captured["limit"] == 32 From 244388a2a380d7920070619952b586530ce4c397 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:24:59 -0700 Subject: [PATCH 57/63] feat: expose a strict project-history API router --- backend/app/project_history_api.py | 170 +++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 backend/app/project_history_api.py diff --git a/backend/app/project_history_api.py b/backend/app/project_history_api.py new file mode 100644 index 000000000..d226672d0 --- /dev/null +++ b/backend/app/project_history_api.py @@ -0,0 +1,170 @@ +"""Versioned HTTP contract for evidence-bound project-history timelines.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, ConfigDict, Field + +from backend.app.auth import CurrentAccount, get_current_account +from backend.app.db import get_pool +from backend.app.project_history import ( + PROJECT_HISTORY_DEFAULT_LIMIT, + PROJECT_HISTORY_MAXIMUM_LIMIT, + ProjectHistoryNotFound, + fetch_project_history_projection, +) +from backend.app.source_post_revision import parse_as_of_clock + +router = APIRouter() + + +class ProjectHistoryMatch(BaseModel): + """One explicit or semantic fact binding a source record to a project.""" + + model_config = ConfigDict(extra="forbid") + + match_kind_code: str + matched_value: str + truth_status_code: Literal["observed", "inferred"] + confidence: float | None + ontology_iri: str | None + provenance: str + + +class ProjectHistoryResponsibility(BaseModel): + """One responsibility observed in a source record, not an HR assignment.""" + + model_config = ConfigDict(extra="forbid") + + actor_key: str + actor_name: str + actor_type_code: str + affiliated_organization_name: str | None + responsibility: str + truth_status_code: Literal["observed"] + provenance: Literal["post_summary_role"] + + +class ProjectHistoryPathEdge(BaseModel): + """One persisted inferred lineage edge inside a visible prior path.""" + + model_config = ConfigDict(extra="forbid") + + parent_event_id: str + child_event_id: str + fused_score: float + + +class ProjectHistoryPriorPath(BaseModel): + """A visible-only, non-causal shortest path from a prior event.""" + + model_config = ConfigDict(extra="forbid") + + source_event_id: str + target_event_id: str + event_ids: list[str] + edges: list[ProjectHistoryPathEdge] + minimum_fused_score: float + truth_status_code: Literal["inferred"] + source_relation_code: Literal["post_lineage_edge"] + provenance: Literal["post_lineage_edge.fused_score"] + + +class ProjectHistoryEvent(BaseModel): + """One authorized source record on the chronological Buyer timeline.""" + + model_config = ConfigDict(extra="forbid") + + event_id: str + source_post_id: str + event_title: str + event_type_code: str + event_type_basis_code: Literal["display_classification"] + occurred_at: str + time_basis_code: Literal["document_time"] + voc_type_code: str | None + source_stage_code: str | None + source_detail_state_code: str | None + project_matches: list[ProjectHistoryMatch] + observed_responsibilities: list[ProjectHistoryResponsibility] + responsibility_transition_code: Literal["continuous", "handoff", "assignment_gap"] | None + related_prior_paths: list[ProjectHistoryPriorPath] + + +class ProjectHistoryProjection(BaseModel): + """Strict version-one project-history response contract.""" + + model_config = ConfigDict(extra="forbid") + + contract_version: Literal[1] + project_key: str + normalized_project_key: str + project_name: str + focus_event_id: str + time_basis_code: Literal["document_time"] + event_count: int = Field(ge=0) + distinct_observed_actor_count: int = Field(ge=0) + truncated: bool + events: list[ProjectHistoryEvent] + + +def _parse_knowledge_cutoff(value: str | None) -> datetime: + """Return the explicit cutoff or the current UTC clock for a live read.""" + + if value is None: + return datetime.now(timezone.utc) + try: + return parse_as_of_clock(value) + except (TypeError, ValueError) as exc: + raise HTTPException( + 422, + "knowledge_cutoff must be an ISO-8601 timestamp", + ) from exc + + +@router.get("/api/project-history", response_model=ProjectHistoryProjection) +async def read_project_history( + project_key: str = Query(min_length=1, max_length=512), + focus_post_id: UUID | None = Query(default=None), + knowledge_cutoff: str | None = Query(default=None), + limit: int = Query( + default=PROJECT_HISTORY_DEFAULT_LIMIT, + ge=1, + le=PROJECT_HISTORY_MAXIMUM_LIMIT, + ), + account: CurrentAccount = Depends(get_current_account), + pool: Any = Depends(get_pool), +) -> dict[str, Any]: + """Return one ABAC-safe project timeline without revealing hidden matches.""" + + if not account.has_permission("post_read"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "account lacks the post_read permission", + ) + cutoff = _parse_knowledge_cutoff(knowledge_cutoff) + try: + async with pool.acquire() as connection: + projection = await fetch_project_history_projection( + connection, + project_key=project_key, + focus_post_id=str(focus_post_id) if focus_post_id is not None else None, + knowledge_cutoff=cutoff, + corporate_entity_ids=sorted(account.corporate_entity_ids), + limit=limit, + ) + except ProjectHistoryNotFound as exc: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "project history not found", + ) from exc + except ValueError as exc: + raise HTTPException( + 422, + "project history request is invalid", + ) from exc + return ProjectHistoryProjection.model_validate(projection).model_dump(mode="json") From f66e5c86565c2a03d386b2a69c3579ef760b061b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:29:10 +0900 Subject: [PATCH 58/63] merge: restack onto feat/event-lineage-node-keeps-gnb-focus-v2170 Cascade-merges the current #264 tip into this branch, which was still on much older ADR/prop naming (this branch forked before the 0070-0075 -> 0092-0097 ADR renumbering and before the focusAskAfterRelated -> landOnAsk/focusAskOnLand rename landed upstream). Conflicts were the routine kind: doc prose taking origin's already-correct ADR numbers, and re-applying the established landOnAsk/focusAskOnLand naming over this branch's stale focusAskAfterRelated. isoWeek.ts/isoWeek.test.ts add/add conflict resolved by taking origin's version (validates the Gregorian calendar date before parsing). Also fixes a real bug found while resolving: this branch's own new docs/adr/0072-0075-*.md files were byte-identical duplicates of the already-renumbered 0094-0097 ADRs (content differed only in cross-reference numbers) -- deleted the four stale duplicates. Two pre-existing failures were investigated and left alone (confirmed via git stash against this branch's own pristine tip, not introduced by this merge): a "test(red)" frontend HTTP contract test for TeppProjectHistory and a "test(red)" PostgreSQL authorization test referencing a not-yet-migrated source_company_name column. Both are this branch's own in-progress TDD red state, not something to implement here. 142 frontend tests pass (excluding the known-red TeppProjectHistory suite), 564 python tests pass excluding the two known-red project- history tests, tsc -b passes for everything except the pending TeppProjectHistory component. --- .../repair/apply_tepp_buyer_integration.py | 1020 ----------------- .../repair-tepp-buyer-integration.yml | 102 -- AGENTS.md | 12 +- ARCHITECTURE.md | 8 +- CHANGELOG.md | 8 +- CLAUDE.md | 11 +- backend/app/main.py | 18 +- backend/tests/test_api.py | 2 +- backend/tests/test_auth_jwks.py | 2 +- backend/tests/test_config.py | 1 + docker/contextual-orchestrator/Dockerfile | 7 +- docker/contextual-orchestrator/start.py | 7 - .../0083-orchestrator-runtime-commit-pin.md | 20 +- ...94-calendar-open-focuses-event-lineage.md} | 4 +- ...omer-master-open-focuses-event-lineage.md} | 4 +- ...6-ask-agent-open-focuses-event-lineage.md} | 4 +- ...097-event-lineage-node-keeps-gnb-focus.md} | 4 +- frontend/src/App.test.tsx | 65 +- frontend/src/App.tsx | 129 ++- frontend/src/isoWeek.test.ts | 11 + frontend/src/isoWeek.ts | 17 + lineageweave/commitment_extraction.py | 2 +- .../entity_relationship_classification.py | 2 +- lineageweave/image_content.py | 2 +- lineageweave/keyman_extraction.py | 2 +- lineageweave/post_chat.py | 7 +- lineageweave/post_content_normalization.py | 149 ++- lineageweave/post_evaluation.py | 4 +- lineageweave/post_summary.py | 4 +- tests/test_orchestrator_bootstrap.py | 39 - tests/test_post_chat_ingestion.py | 4 +- tests/test_post_content_normalization.py | 35 + 32 files changed, 389 insertions(+), 1317 deletions(-) delete mode 100755 .github/repair/apply_tepp_buyer_integration.py delete mode 100644 .github/workflows/repair-tepp-buyer-integration.yml rename docs/adr/{0072-calendar-open-focuses-event-lineage.md => 0094-calendar-open-focuses-event-lineage.md} (89%) rename docs/adr/{0073-customer-master-open-focuses-event-lineage.md => 0095-customer-master-open-focuses-event-lineage.md} (90%) rename docs/adr/{0074-ask-agent-open-focuses-event-lineage.md => 0096-ask-agent-open-focuses-event-lineage.md} (90%) rename docs/adr/{0075-event-lineage-node-keeps-gnb-focus.md => 0097-event-lineage-node-keeps-gnb-focus.md} (90%) delete mode 100644 tests/test_orchestrator_bootstrap.py diff --git a/.github/repair/apply_tepp_buyer_integration.py b/.github/repair/apply_tepp_buyer_integration.py deleted file mode 100755 index b0d138421..000000000 --- a/.github/repair/apply_tepp_buyer_integration.py +++ /dev/null @@ -1,1020 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the one-shot TEPP buyer integration repair to the #258 branch.""" - -from __future__ import annotations - -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - - -def read(path: str) -> str: - """Read one repository-relative UTF-8 file.""" - return (ROOT / path).read_text(encoding="utf-8") - - -def write(path: str, content: str) -> None: - """Write one repository-relative UTF-8 file.""" - target = ROOT / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace exactly one literal occurrence or fail closed.""" - content = read(path) - count = content.count(old) - if count != 1: - raise RuntimeError(f"{path}: expected one exact match, found {count}") - write(path, content.replace(old, new, 1)) - - -def sub_once(path: str, pattern: str, replacement: str, *, flags: int = 0) -> None: - """Replace exactly one regular-expression occurrence or fail closed.""" - content = read(path) - updated, count = re.subn(pattern, replacement, content, count=1, flags=flags) - if count != 1: - raise RuntimeError( - f"{path}: expected one regex match, found {count}: {pattern!r}" - ) - write(path, updated) - - -replace_once( - "lineageweave/http_client.py", - "def post_form(\n", - '''def post_json_exact( - url: str, - payload: dict, - *, - headers: dict[str, str], - timeout: float, -) -> dict: - """POST an exact JSON object without LLM metadata enrichment. - - Closed external contracts such as TEPP reject unknown fields. This helper - deliberately preserves the caller's wire object modulo JSON serialization - while retaining the shared HTTP(S), TLS, timeout, and response-validation - boundary. - - Raises: - ValueError: ``url`` is not an ``http`` / ``https`` URL with a host. - HttpClientError: the server responded with HTTP >= 400 or non-JSON. - """ - status, raw = _request( - "POST", - url, - body=json.dumps(payload).encode("utf-8"), - headers={"content-type": "application/json", **headers}, - timeout=timeout, - ) - hostname = urlparse(url).hostname or url - if status >= 400: - raise HttpClientError(f"HTTP {status} from {hostname}") - return _decode_json_object(raw, hostname) - - -def post_form( -''', -) - -write( - "lineageweave/tepp_result.py", - '''"""Strict TEPP accepted-envelope evidence for LineageWeave. - -TEPP publishes an asynchronous ``AnalysisRunAccepted`` envelope containing -``contract_version``, opaque ``run_id``, ``run_state=accepted``, and the -caller's ``idempotency_key``. LineageWeave may store that acknowledgement as -aggregate transport evidence. It is not a completed psychometric result and -must never be presented as theta, uncertainty, a topic score, an item -parameter, or a calibrated estimate. -""" - -from __future__ import annotations - -import hashlib -import json -from dataclasses import dataclass -from typing import Any - -_ACCEPTED_FIELDS = frozenset( - {"contract_version", "run_id", "run_state", "idempotency_key"} -) -_FORBIDDEN_KEY_PARTS = ( - "theta", - "topic", - "item_parameter", - "membership", - "uncertainty", - "affiliation_count", - "interval_count", - "level_count", -) - - -@dataclass(frozen=True) -class TeppAcceptedEvidence: - """Published transport acknowledgement that is safe to persist.""" - - contract_version: int - accepted_run_id: str - run_state: str - idempotency_key: str - - def evidence_sha256(self) -> str: - """Return a stable digest of the four published acknowledgement fields.""" - material = json.dumps( - { - "accepted_run_id": self.accepted_run_id, - "contract_version": self.contract_version, - "idempotency_key": self.idempotency_key, - "run_state": self.run_state, - }, - separators=(",", ":"), - sort_keys=True, - ) - return hashlib.sha256(material.encode("utf-8")).hexdigest() - - -def _nonempty_text(value: Any) -> str | None: - """Return stripped nonempty text, otherwise ``None``.""" - if not isinstance(value, str): - return None - stripped = value.strip() - return stripped or None - - -def _contains_forbidden_measurement_key(value: Any) -> bool: - """Return whether a nested object names an unpublished measurement field.""" - if isinstance(value, dict): - for key, nested in value.items(): - normalized = str(key).casefold().replace("-", "_") - if any(part in normalized for part in _FORBIDDEN_KEY_PARTS): - return True - if _contains_forbidden_measurement_key(nested): - return True - return False - if isinstance(value, list): - return any(_contains_forbidden_measurement_key(item) for item in value) - return False - - -def parse_tepp_accepted_evidence( - envelope: Any, - *, - expected_idempotency_key: str | None = None, -) -> TeppAcceptedEvidence | None: - """Parse the exact published acknowledgement or fail closed. - - Unknown fields, a mismatched idempotency key, any non-``accepted`` state, - and any measurement-looking key are rejected. This prevents a transport - acknowledgement from being silently upgraded into a scientific result. - """ - if not isinstance(envelope, dict) or set(envelope) != _ACCEPTED_FIELDS: - return None - if _contains_forbidden_measurement_key(envelope): - return None - if envelope.get("contract_version") != 1: - return None - accepted_run_id = _nonempty_text(envelope.get("run_id")) - run_state = _nonempty_text(envelope.get("run_state")) - idempotency_key = _nonempty_text(envelope.get("idempotency_key")) - if accepted_run_id is None or run_state != "accepted" or idempotency_key is None: - return None - if ( - expected_idempotency_key is not None - and idempotency_key != expected_idempotency_key - ): - return None - return TeppAcceptedEvidence( - contract_version=1, - accepted_run_id=accepted_run_id, - run_state=run_state, - idempotency_key=idempotency_key, - ) -''', -) - -replace_once( - "backend/app/analysis_run_start.py", - "from lineageweave.http_client import HttpClientError, post_json\n", - "from lineageweave.http_client import HttpClientError, post_json_exact\n", -) -replace_once( - "backend/app/analysis_run_start.py", - "from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable\n", - '''from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable -from lineageweave.tepp_result import TeppAcceptedEvidence, parse_tepp_accepted_evidence -''', -) -sub_once( - "backend/app/analysis_run_start.py", - r'''def configured_tepp_client\(transport_url: str = "", api_key: str = ""\) -> TeppClient: -.*? - return TeppClient\(transport=transport\) -''', - '''def configured_tepp_client(transport_url: str = "", api_key: str = "") -> TeppClient: - """Build the credential-free LineageWeave -> TEPP HTTP client. - - ``TEPP_API_KEY`` remains an accepted legacy configuration argument so - existing deployments do not crash during rollout, but its value is never - forwarded. TEPP identifies LineageWeave using the published consumer - header and rejects credential headers. - """ - del api_key - url = transport_url.strip() - if not url: - return TeppClient() - - def transport(payload: dict[str, Any]) -> dict[str, Any]: - try: - headers = { - "tepp-consumer": "lineageweave", - "tepp-contract-version": str(payload["contract_version"]), - "idempotency-key": str(payload["idempotency_key"]), - } - return post_json_exact(url, payload, headers=headers, timeout=30.0) - except (HttpClientError, OSError, ValueError, TypeError, KeyError) as exc: - raise TeppNotAvailable(str(exc)) from exc - - return TeppClient(transport=transport) -''', - flags=re.S, -) -sub_once( - "backend/app/analysis_run_start.py", - r'''def _tepp_submission\( - client: TeppClient, - request: AnalysisRunRequest, -\) -> tuple\[str, str, dict\[str, Any\] \| None\]: -.*? -def tepp_submit_outcome\( - client: TeppClient, - request: AnalysisRunRequest, -\) -> tuple\[str, str\]: - """Compatibility projection of the TEPP submission outcome\.""" - status_code, failure_code, _ = _tepp_submission\(client, request\) - return status_code, failure_code -''', - '''def _tepp_submission( - client: TeppClient, - request: AnalysisRunRequest, -) -> tuple[str, str, TeppAcceptedEvidence | dict[str, Any] | None]: - """Submit through ``tepp_client`` and classify the returned evidence. - - The published asynchronous acknowledgement leaves the run in ``Running``. - It is persisted separately from a future provider-authoritative completed - result. Malformed evidence and absent transports fail closed. - """ - try: - response = client.submit_analysis_run(request) - except TeppNotAvailable: - return _FAILED, "tepp_not_available", None - accepted = parse_tepp_accepted_evidence( - response, - expected_idempotency_key=request.idempotency_key, - ) - if accepted is not None: - return _RUNNING, "", accepted - if not isinstance(response, dict): - return _FAILED, "tepp_result_not_persisted", None - if response.get("status") not in {"completed", "succeeded"}: - return _FAILED, "tepp_result_not_persisted", None - if not isinstance(response.get("result"), dict): - return _FAILED, "tepp_result_not_persisted", None - remote_run_id = response.get("analysis_run_id") or response.get("run_id") - if not isinstance(remote_run_id, str) or not remote_run_id.strip(): - return _FAILED, "tepp_result_not_persisted", None - return _SUCCEEDED, "", response - - -def tepp_submit_outcome( - client: TeppClient, - request: AnalysisRunRequest, -) -> tuple[str, str]: - """Return the lifecycle projection without exposing provider payloads.""" - status_code, failure_code, _ = _tepp_submission(client, request) - return status_code, failure_code -''', - flags=re.S, -) -replace_once( - "backend/app/analysis_run_start.py", - ''' return True - - -def start_write_conflict_error() -> AnalysisRunStartError: -''', - ''' return True - - -async def _persist_tepp_accepted( - conn: asyncpg.Connection, - *, - analysis_run_id: str, - evidence: TeppAcceptedEvidence, - received_at: datetime, -) -> bool: - """Persist one idempotent TEPP acknowledgement without later mutation.""" - try: - inserted = await conn.fetchval( - """ - insert into analysis_run_tepp_accepted - (analysis_run_id, contract_version, accepted_run_id, - run_state, idempotency_key, evidence_sha256, - received_at, recorded_at) - values ($1, $2, $3, $4, $5, $6, $7, clock_timestamp()) - on conflict (analysis_run_id) do nothing - returning true - """, - analysis_run_id, - evidence.contract_version, - evidence.accepted_run_id, - evidence.run_state, - evidence.idempotency_key, - evidence.evidence_sha256(), - received_at, - ) - if inserted: - return True - stored = await conn.fetchrow( - """ - select contract_version, accepted_run_id, run_state, - idempotency_key, evidence_sha256 - from analysis_run_tepp_accepted - where analysis_run_id = $1 - """, - analysis_run_id, - ) - except (asyncpg.PostgresError, TypeError, ValueError): - return False - if stored is None: - return False - return ( - int(stored["contract_version"]) == evidence.contract_version - and stored["accepted_run_id"] == evidence.accepted_run_id - and stored["run_state"] == evidence.run_state - and stored["idempotency_key"] == evidence.idempotency_key - and stored["evidence_sha256"] == evidence.evidence_sha256() - ) - - -def start_write_conflict_error() -> AnalysisRunStartError: -''', -) -sub_once( - "backend/app/analysis_run_start.py", - r'''async def _deliver_tepp_measurement\( - conn: asyncpg.Connection, - \*, - analysis_run_id: str, - locked: asyncpg.Record, - tepp_client: TeppClient, -\) -> None: -.*?\n await _append_status\( - conn, - analysis_run_id, - await _next_status_ordinal\(conn, analysis_run_id\), - status_code, - finished, - failure_code, - \) -''', - '''async def _deliver_tepp_measurement( - conn: asyncpg.Connection, - *, - analysis_run_id: str, - locked: asyncpg.Record, - tepp_client: TeppClient, -) -> None: - """Submit the frozen snapshot and persist only published TEPP evidence.""" - received_at = datetime.now(timezone.utc) - request = tepp_run_request( - idempotency_key=str(locked["idempotency_key"]), - snapshot_sha256=str(locked["snapshot_sha256"]), - knowledge_cutoff=locked["knowledge_cutoff"], - corporate_entity_id=str(locked["corporate_entity_id"]), - ) - status_code, failure_code, evidence = _tepp_submission(tepp_client, request) - if status_code == _RUNNING and isinstance(evidence, TeppAcceptedEvidence): - if await _persist_tepp_accepted( - conn, - analysis_run_id=analysis_run_id, - evidence=evidence, - received_at=received_at, - ): - return - status_code = _FAILED - failure_code = "tepp_accepted_not_persisted" - elif status_code == _SUCCEEDED and isinstance(evidence, dict): - if not await _persist_tepp_result( - conn, - analysis_run_id=analysis_run_id, - envelope=evidence, - ): - status_code = _FAILED - failure_code = "tepp_result_not_persisted" - finished = datetime.now(timezone.utc) - if finished < received_at: - finished = received_at - await _append_status( - conn, - analysis_run_id, - await _next_status_ordinal(conn, analysis_run_id), - status_code, - finished, - failure_code, - ) -''', - flags=re.S, -) - -replace_once( - "backend/app/analysis_run_ingestion.py", - "async def fetch_outbox_deliveries(\n", - '''async def fetch_tepp_accepted_evidence( - conn: asyncpg.Connection, - analysis_run_id: str, -) -> dict[str, Any] | None: - """Return redacted TEPP acknowledgement evidence for a visible run.""" - try: - row = await conn.fetchrow( - """ - select contract_version, accepted_run_id, run_state, - evidence_sha256, received_at, recorded_at - from analysis_run_tepp_accepted - where analysis_run_id = $1::uuid - """, - analysis_run_id, - ) - except asyncpg.UndefinedTableError: - return None - if row is None: - return None - return { - "contract_version": int(row["contract_version"]), - "accepted_run_id": row["accepted_run_id"], - "run_state": row["run_state"], - "evidence_sha256": row["evidence_sha256"], - "received_at": _iso(row["received_at"]), - "recorded_at": _iso(row["recorded_at"]), - "evidence_kind": "aggregate_transport_evidence", - } - - -async def fetch_outbox_deliveries( -''', -) -replace_once( - "backend/app/analysis_run_ingestion.py", - ''' detail["outbox_deliveries"] = await fetch_outbox_deliveries(conn, analysis_run_id) - detail["visible_posts"] = await fetch_visible_scope_posts( -''', - ''' detail["outbox_deliveries"] = await fetch_outbox_deliveries(conn, analysis_run_id) - tepp_accepted = await fetch_tepp_accepted_evidence(conn, analysis_run_id) - if tepp_accepted is not None: - detail["tepp_accepted"] = tepp_accepted - detail["visible_posts"] = await fetch_visible_scope_posts( -''', -) - -write( - "migrations/0047_analysis_run_tepp_accepted.sql", - r'''-- Published TEPP AnalysisRunAccepted transport evidence (ADR 0090). --- This is not a completed result, theta, topic score, item parameter, or --- uncertainty estimate. - -create table if not exists analysis_run_tepp_accepted ( - analysis_run_id uuid primary key references analysis_run (analysis_run_id), - contract_version integer not null, - accepted_run_id text not null, - run_state text not null, - idempotency_key text not null, - evidence_sha256 text not null, - received_at timestamptz not null, - recorded_at timestamptz not null default clock_timestamp(), - constraint analysis_run_tepp_accepted_contract_check check (contract_version = 1), - constraint analysis_run_tepp_accepted_run_state_check check (run_state = 'accepted'), - constraint analysis_run_tepp_accepted_run_id_check check (btrim(accepted_run_id) <> ''), - constraint analysis_run_tepp_accepted_idempotency_check check (btrim(idempotency_key) <> ''), - constraint analysis_run_tepp_accepted_digest_check check (evidence_sha256 ~ '^[0-9a-f]{64}$'), - constraint analysis_run_tepp_accepted_time_check check (received_at <= recorded_at) -); - -create unique index if not exists analysis_run_tepp_accepted_remote_idx - on analysis_run_tepp_accepted (accepted_run_id); - -comment on table analysis_run_tepp_accepted is - 'One immutable TEPP accepted acknowledgement per analysis run; aggregate ' - 'transport evidence, never a validated multilevel estimate.'; - -create or replace function reject_analysis_run_tepp_accepted_update() -returns trigger -language plpgsql -as $$ -begin - raise exception 'analysis_run_tepp_accepted_is_immutable'; -end -$$; - -drop trigger if exists analysis_run_tepp_accepted_update_reject - on analysis_run_tepp_accepted; -create trigger analysis_run_tepp_accepted_update_reject -before update or delete on analysis_run_tepp_accepted -for each row execute function reject_analysis_run_tepp_accepted_update(); - -create or replace function purge_analysis_run_registry(approval_token text) -returns void -language plpgsql -security definer -set search_path = public -as $$ -declare - run_count bigint; - snapshot_count bigint; -begin - if not exists ( - select 1 from analysis_run_retention_grant - where database_role_name = session_user and revoked_at is null - ) then - raise exception 'analysis_run_retention_not_granted'; - end if; - if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then - raise exception 'analysis_run_retention_not_admin'; - end if; - if approval_token is distinct from 'approved-retention-purge' then - raise exception 'analysis_run_retention_not_approved'; - end if; - - select count(*) into run_count from analysis_run; - select count(*) into snapshot_count from analysis_source_snapshot; - - alter table analysis_run_status_event disable trigger analysis_run_status_event_delete_reject; - alter table analysis_run_scope disable trigger analysis_run_scope_mutation_reject; - alter table analysis_run disable trigger analysis_run_mutation_reject; - if to_regclass('public.analysis_run_outbox') is not null then - alter table analysis_run_outbox disable trigger analysis_run_outbox_mutation_reject; - alter table analysis_run_outbox_delivery disable trigger analysis_run_outbox_delivery_mutation_reject; - end if; - if to_regclass('public.analysis_run_reconstruction') is not null then - alter table analysis_run_reconstruction disable trigger analysis_run_reconstruction_update_reject; - alter table analysis_run_lineage_edge disable trigger analysis_run_lineage_edge_update_reject; - end if; - alter table analysis_run_tepp_accepted disable trigger analysis_run_tepp_accepted_update_reject; - if to_regclass('public.analysis_source_snapshot_member') is not null then - alter table analysis_source_snapshot_member disable trigger analysis_source_snapshot_member_update_reject; - end if; - - begin - if to_regclass('public.analysis_run_outbox_delivery') is not null then - delete from analysis_run_outbox_delivery; - delete from analysis_run_outbox; - end if; - if to_regclass('public.analysis_run_lineage_edge') is not null then - delete from analysis_run_lineage_edge; - end if; - if to_regclass('public.analysis_run_reconstruction') is not null then - delete from analysis_run_reconstruction; - end if; - delete from analysis_run_tepp_accepted; - if to_regclass('public.analysis_run_tepp_result') is not null then - delete from analysis_run_tepp_result; - end if; - delete from analysis_run_status_event; - delete from analysis_run_scope; - delete from analysis_run; - delete from analysis_source_count; - if to_regclass('public.analysis_source_snapshot_member') is not null then - delete from analysis_source_snapshot_member; - end if; - delete from analysis_source_snapshot; - exception - when others then - alter table analysis_run enable trigger analysis_run_mutation_reject; - alter table analysis_run_scope enable trigger analysis_run_scope_mutation_reject; - alter table analysis_run_status_event enable trigger analysis_run_status_event_delete_reject; - if to_regclass('public.analysis_run_outbox') is not null then - alter table analysis_run_outbox enable trigger analysis_run_outbox_mutation_reject; - alter table analysis_run_outbox_delivery enable trigger analysis_run_outbox_delivery_mutation_reject; - end if; - if to_regclass('public.analysis_run_reconstruction') is not null then - alter table analysis_run_reconstruction enable trigger analysis_run_reconstruction_update_reject; - alter table analysis_run_lineage_edge enable trigger analysis_run_lineage_edge_update_reject; - end if; - alter table analysis_run_tepp_accepted enable trigger analysis_run_tepp_accepted_update_reject; - if to_regclass('public.analysis_source_snapshot_member') is not null then - alter table analysis_source_snapshot_member enable trigger analysis_source_snapshot_member_update_reject; - end if; - raise; - end; - - alter table analysis_run enable trigger analysis_run_mutation_reject; - alter table analysis_run_scope enable trigger analysis_run_scope_mutation_reject; - alter table analysis_run_status_event enable trigger analysis_run_status_event_delete_reject; - if to_regclass('public.analysis_run_outbox') is not null then - alter table analysis_run_outbox enable trigger analysis_run_outbox_mutation_reject; - alter table analysis_run_outbox_delivery enable trigger analysis_run_outbox_delivery_mutation_reject; - end if; - if to_regclass('public.analysis_run_reconstruction') is not null then - alter table analysis_run_reconstruction enable trigger analysis_run_reconstruction_update_reject; - alter table analysis_run_lineage_edge enable trigger analysis_run_lineage_edge_update_reject; - end if; - alter table analysis_run_tepp_accepted enable trigger analysis_run_tepp_accepted_update_reject; - if to_regclass('public.analysis_source_snapshot_member') is not null then - alter table analysis_source_snapshot_member enable trigger analysis_source_snapshot_member_update_reject; - end if; - - insert into analysis_run_retention_event ( - purged_run_count, purged_snapshot_count, approval_token_digest, - invoking_session_role, invoking_current_role, client_network_address - ) values ( - run_count, snapshot_count, - encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), - session_user, current_user, inet_client_addr() - ); -end -$$; -''', -) -write( - "migrations/rollback/0047_analysis_run_tepp_accepted.sql", - '''do $$ -begin - if exists (select 1 from analysis_run_tepp_accepted limit 1) then - raise exception 'analysis_run_tepp_accepted_not_empty'; - end if; -end -$$; - -drop trigger if exists analysis_run_tepp_accepted_update_reject - on analysis_run_tepp_accepted; -drop function if exists reject_analysis_run_tepp_accepted_update(); -drop table if exists analysis_run_tepp_accepted; -''', -) -replace_once( - "docker/postgres-init/Dockerfile", - "COPY migrations/0040_post_summary_contract.sql /docker-entrypoint-initdb.d/42-post-summary-contract.sql\n", - '''COPY migrations/0040_post_summary_contract.sql /docker-entrypoint-initdb.d/42-post-summary-contract.sql -COPY migrations/0047_analysis_run_tepp_accepted.sql /docker-entrypoint-initdb.d/43-analysis-run-tepp-accepted.sql -''', -) -replace_once( - "scripts/seed_demo_data.py", - ''' cur.execute((migrations / "0025_role_person_catalog_identity.sql").read_text()) -''', - ''' cur.execute((migrations / "0025_role_person_catalog_identity.sql").read_text()) - cur.execute((migrations / "0027_analysis_run_tepp_result.sql").read_text()) - cur.execute((migrations / "0047_analysis_run_tepp_accepted.sql").read_text()) -''', -) - -replace_once( - "frontend/src/api.ts", - "export interface AnalysisRun {\n", - '''export interface AnalysisRunTeppAccepted { - contract_version: number; - accepted_run_id: string; - run_state: "accepted"; - evidence_sha256: string; - received_at: string; - recorded_at: string; - evidence_kind: "aggregate_transport_evidence"; -} - -export interface AnalysisRun { -''', -) -replace_once( - "frontend/src/api.ts", - ''' reconstruction_result_sha256?: string; - code_revision_sha?: string; -''', - ''' reconstruction_result_sha256?: string; - tepp_accepted?: AnalysisRunTeppAccepted; - code_revision_sha?: string; -''', -) -write( - "frontend/src/components/TeppMeasurementStatus.tsx", - '''import type { AnalysisRunTeppAccepted } from "../api"; - -export function TeppMeasurementStatus({ - accepted, -}: { - accepted?: AnalysisRunTeppAccepted; -}) { - if (!accepted) return null; - return ( -
      -

      TEPP measurement

      -

      - TEPP accepted this measurement run. Calibrated estimates and uncertainty are still pending. -

      -
      -
      TEPP run
      -
      {accepted.accepted_run_id}
      -
      Accepted at
      -
      {accepted.received_at}
      -
      Evidence digest
      -
      {accepted.evidence_sha256}
      -
      -

      - Next action: keep this run open until TEPP publishes a completed result contract. -

      -
      - ); -} -''', -) -write( - "frontend/src/components/TeppMeasurementStatus.test.tsx", - '''import "@testing-library/jest-dom/vitest"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { TeppMeasurementStatus } from "./TeppMeasurementStatus"; - -describe("TeppMeasurementStatus", () => { - it("shows accepted transport evidence without claiming a score", () => { - render( - , - ); - expect(screen.getByRole("status")).toHaveTextContent("Calibrated estimates"); - expect(screen.getByText("tepp-run-42")).toBeInTheDocument(); - expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); - }); - - it("renders nothing before TEPP accepts the run", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); -''', -) -replace_once( - "frontend/src/App.tsx", - 'import { CutoffKnownBody } from "./components/CutoffKnownBody";\n', - '''import { CutoffKnownBody } from "./components/CutoffKnownBody"; -import { TeppMeasurementStatus } from "./components/TeppMeasurementStatus"; -''', -) -replace_once( - "frontend/src/App.tsx", - ''' case "analysis_status_running": - return "Refresh this run. Start already queued the work on the durable outbox."; -''', - ''' case "analysis_status_running": - if (run.run_kind_code === "analysis_run_tepp" && run.tepp_accepted) { - return "TEPP accepted this run. Keep it open until calibrated results and uncertainty arrive."; - } - return "Refresh this run. Start already queued the work on the durable outbox."; -''', -) -replace_once( - "frontend/src/App.tsx", - '''

      {analysisRunCaption(selected)}

      - {selectedNextAction &&

      {selectedNextAction}

      } -''', - '''

      {analysisRunCaption(selected)}

      - - {selectedNextAction &&

      {selectedNextAction}

      } -''', -) - -write( - "tests/test_tepp_result.py", - '''"""Published TEPP accepted evidence is not a completed measurement.""" - -from lineageweave.tepp_result import parse_tepp_accepted_evidence - - -def _accepted(**overrides): - payload = { - "contract_version": 1, - "run_id": "tepp-run-42", - "run_state": "accepted", - "idempotency_key": "buyer-run-42", - } - payload.update(overrides) - return payload - - -def test_published_accepted_envelope_is_transport_evidence() -> None: - evidence = parse_tepp_accepted_evidence( - _accepted(), expected_idempotency_key="buyer-run-42" - ) - assert evidence is not None - assert evidence.accepted_run_id == "tepp-run-42" - assert evidence.run_state == "accepted" - assert len(evidence.evidence_sha256()) == 64 - - -def test_accepted_parser_fails_closed_on_unknown_or_measurement_fields() -> None: - assert parse_tepp_accepted_evidence({"status": "accepted"}) is None - assert parse_tepp_accepted_evidence(_accepted(theta=0.4)) is None - assert parse_tepp_accepted_evidence(_accepted(extra=True)) is None - assert parse_tepp_accepted_evidence(_accepted(run_state="completed")) is None - assert parse_tepp_accepted_evidence(_accepted(contract_version=2)) is None - assert parse_tepp_accepted_evidence( - _accepted(), expected_idempotency_key="different" - ) is None -''', -) -write( - "tests/test_analysis_run_tepp_accepted_schema.py", - '''"""Static contracts for normalized TEPP accepted evidence.""" - -from pathlib import Path - -_ROOT = Path(__file__).resolve().parents[1] -_MIGRATION = _ROOT / "migrations" / "0047_analysis_run_tepp_accepted.sql" -_ROLLBACK = _ROOT / "migrations" / "rollback" / "0047_analysis_run_tepp_accepted.sql" - - -def test_tepp_accepted_schema_is_normalized_immutable_and_purge_aware() -> None: - migration = _MIGRATION.read_text(encoding="utf-8") - assert "create table if not exists analysis_run_tepp_accepted" in migration - assert "jsonb" not in migration.casefold() - assert "before update or delete" in migration.casefold() - assert "delete from analysis_run_tepp_accepted" in migration - assert migration.index("delete from analysis_run_tepp_accepted") < migration.index( - "delete from analysis_run_status_event" - ) - assert "validated multilevel estimate" in migration - - -def test_tepp_accepted_rollback_refuses_nonempty_evidence() -> None: - rollback = _ROLLBACK.read_text(encoding="utf-8") - assert "analysis_run_tepp_accepted_not_empty" in rollback - assert "drop table if exists analysis_run_tepp_accepted" in rollback - assert "analysis_run_tepp_result" not in rollback -''', -) -replace_once( - "tests/test_tepp_client.py", - '''def test_configured_transport_sends_optional_bearer_key(monkeypatch: pytest.MonkeyPatch) -> None: - received = {} - - def fake_post_json(url: str, payload: dict, *, headers: dict, timeout: float) -> dict: - received.update(url=url, payload=payload, headers=headers, timeout=timeout) - return {"status": "accepted"} - - monkeypatch.setattr("backend.app.analysis_run_start.post_json", fake_post_json) - client = configured_tepp_client("https://tepp.example/v1/analysis-runs", "test-key") - - client.submit_analysis_run(_sample_request()) - - assert received["headers"] == {"authorization": "Bearer test-key"} - assert received["payload"] == _sample_request().to_json() -''', - '''def test_configured_transport_sends_exact_consumer_headers_without_credentials( - monkeypatch: pytest.MonkeyPatch, -) -> None: - received = {} - - def fake_post_json_exact( - url: str, - payload: dict, - *, - headers: dict, - timeout: float, - ) -> dict: - received.update(url=url, payload=payload, headers=headers, timeout=timeout) - return { - "contract_version": 1, - "run_id": "tepp-run-42", - "run_state": "accepted", - "idempotency_key": payload["idempotency_key"], - } - - monkeypatch.setattr( - "backend.app.analysis_run_start.post_json_exact", fake_post_json_exact - ) - client = configured_tepp_client( - "https://tepp.example/v1/analysis-runs", - "legacy-key-that-must-not-be-forwarded", - ) - - result = client.submit_analysis_run(_sample_request()) - - assert result["run_state"] == "accepted" - assert received["headers"] == { - "tepp-consumer": "lineageweave", - "tepp-contract-version": "1", - "idempotency-key": "demo-run-1", - } - assert received["payload"] == _sample_request().to_json() - assert set(received["payload"]) == { - "contract_version", - "idempotency_key", - "tenant_workspace_id", - "snapshot_id", - "knowledge_cutoff", - "model_contract_version", - "output_profile", - } -''', -) -replace_once( - "tests/test_analysis_run_start.py", - '''def test_tepp_submit_outcome_does_not_persist_an_empty_envelope() -> None: - """An accepted envelope is not a persistable measurement.""" - - class _Accepting(TeppClient): - def __init__(self) -> None: - super().__init__(transport=lambda _payload: {"status": "accepted"}) - - status, failure = tepp_submit_outcome(_Accepting(), _tepp_request()) - assert status == "analysis_status_failed" - assert failure == "tepp_result_not_persisted" -''', - '''def test_tepp_submit_outcome_keeps_a_published_acknowledgement_running() -> None: - """A strict acknowledgement is evidence, not a score or failure.""" - - class _Accepting(TeppClient): - def __init__(self) -> None: - super().__init__( - transport=lambda payload: { - "contract_version": 1, - "run_id": "tepp-run-42", - "run_state": "accepted", - "idempotency_key": payload["idempotency_key"], - } - ) - - status, failure = tepp_submit_outcome(_Accepting(), _tepp_request()) - assert status == "analysis_status_running" - assert failure == "" - - -def test_tepp_submit_outcome_rejects_a_bare_accepted_word() -> None: - """An unversioned status string is not TEPP's published evidence.""" - client = TeppClient(transport=lambda _payload: {"status": "accepted"}) - status, failure = tepp_submit_outcome(client, _tepp_request()) - assert status == "analysis_status_failed" - assert failure == "tepp_result_not_persisted" -''', -) - -write( - "docs/adr/0090-tepp-accepted-buyer-evidence.md", - '''# ADR 0090: TEPP accepted evidence stays Running - -## Status - -Accepted - -## Context - -The Buyer stack could create a TEPP run, but its HTTP transport added generic -LLM metadata and optional bearer credentials to a closed seven-field contract. -TEPP's live listener admitted only Naruon. When TEPP returned its published -asynchronous acknowledgement, LineageWeave treated it as a missing completed -result and moved the run to Failed. - -## Decision - -- TEPP admits the published `lineageweave` consumer without credentials. -- LineageWeave sends the exact `AnalysisRunRequest` body and consumer, - contract-version, and idempotency headers. -- `AnalysisRunAccepted` is immutable aggregate transport evidence. -- A stored acknowledgement leaves the analysis run in Running. -- The buyer sees the opaque TEPP run ID, receipt time, digest, and the next - action, while calibrated results and uncertainty remain explicitly pending. -- Only a future versioned completed-result contract may move a TEPP run to - Succeeded. - -## Consequences - -The integration is modular and fail-closed without shared database access. -No theta, topic score, item parameter, membership weight, or uncertainty is -invented. Consumer identity is part of TEPP's idempotency namespace so Naruon -and LineageWeave cannot replay each other's accepted runs. -''', -) -write( - "CHANGELOG.d/2.12.6-tepp-buyer-integration.md", - '''### Fixed - -- Connected LineageWeave to TEPP's published asynchronous analysis-run boundary - with an exact credential-free request, consumer-scoped idempotency, immutable - accepted evidence, and buyer-visible pending-result guidance. -- A valid TEPP acknowledgement now keeps the run Running instead of falsely - marking it Failed or fabricating a completed psychometric result. -''', -) diff --git a/.github/workflows/repair-tepp-buyer-integration.yml b/.github/workflows/repair-tepp-buyer-integration.yml deleted file mode 100644 index d5d7ec378..000000000 --- a/.github/workflows/repair-tepp-buyer-integration.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: One-shot TEPP buyer integration repair - -on: - push: - branches: - - feat/analysis-run-name-evidence-lineage - paths: - - .github/workflows/repair-tepp-buyer-integration.yml - - .github/repair/apply_tepp_buyer_integration.py - -permissions: - contents: write - -concurrency: - group: repair-tepp-buyer-integration - cancel-in-progress: false - -jobs: - repair: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - steps: - - name: Checkout exact branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/analysis-run-name-evidence-lineage - fetch-depth: 0 - persist-credentials: true - - - name: Fast-forward to the live branch tip - run: | - git fetch origin feat/analysis-run-name-evidence-lineage - git merge --ff-only origin/feat/analysis-run-name-evidence-lineage - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Apply the bounded patch - run: | - sed -i 's/0090-tepp-accepted-buyer-evidence/0091-tepp-accepted-buyer-evidence/g; s/ADR 0090/ADR 0091/g' .github/repair/apply_tepp_buyer_integration.py - python -m py_compile .github/repair/apply_tepp_buyer_integration.py - python .github/repair/apply_tepp_buyer_integration.py - python -m compileall -q lineageweave backend tests - - - name: Install locked Python dependencies - run: uv sync --frozen --extra dev --extra backend - - - name: Verify the TEPP integration slice - run: | - uv run --frozen python -m pytest -q \ - tests/test_tepp_client.py \ - tests/test_tepp_result.py \ - tests/test_analysis_run_start.py \ - tests/test_analysis_run_tepp_accepted_schema.py \ - tests/test_http_client.py - - - name: Install and verify the buyer frontend - working-directory: frontend - run: | - corepack enable - pnpm install --frozen-lockfile - pnpm run lint - pnpm run test -- TeppMeasurementStatus App - pnpm run build - pnpm run build-storybook - - - name: Commit the verified repair and remove one-shot machinery - run: | - git rm .github/workflows/repair-tepp-buyer-integration.yml - git rm .github/repair/apply_tepp_buyer_integration.py - git config user.name "CWL TEPP Integration Repair" - git config user.email "actions@users.noreply.github.com" - git add -A - git commit -m "fix(buyer): connect TEPP accepted evidence end to end" - git pull --rebase origin feat/analysis-run-name-evidence-lineage - git push origin HEAD:feat/analysis-run-name-evidence-lineage diff --git a/AGENTS.md b/AGENTS.md index 1c0c48798..25c482840 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,16 +169,14 @@ never a fabricated theta or a local psychometric substitute. Buyer Board **Weekly VOC** is an ISO-8601 week list filter (ADR 0092). Opening that filtered post focuses Event Lineage (ADR 0093). Do not invent a week, a theta, or a cutoff body. -Opening a Calendar commitment uses the same focus path (ADR 0072). Do not +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 0073). +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 0074). Do not -invent a week, a theta, a cutoff body, a CalDAV event, a customer, or a cited -post. +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 0075). Do not invent a week, a theta, a cutoff body, -a CalDAV event, a customer, or a cited post. +originating flags (ADR 0097). Do not invent a cited post. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea005be9c..5757a935c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -282,11 +282,11 @@ Keycloak issued; `src/App.tsx` renders a git-branch SVG of `GET /api/lineage` (click a node to open that post; `post_admin` can 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 0072), +Calendar commitments use the same Event Lineage focus path (ADR 0094), Customer master related posts use the same Event Lineage focus path -(ADR 0073). Ask Agent cited posts use the same Event Lineage focus path -(ADR 0074). A linked Event Lineage node opened from a focused popup -keeps those flags (ADR 0075), and a full detail popup: Korean +(ADR 0095). Ask Agent cited posts use the same Event Lineage focus path +(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.md b/CHANGELOG.md index 1168c289d..1c0ca8922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to this project are documented here. Format follows 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 0075 / ADR 0074 / ADR 0016). + invented (ADR 0097 / ADR 0096 / ADR 0016). ## [2.16.0] - 2026-08-19 @@ -22,7 +22,7 @@ All notable changes to this project are documented here. Format follows 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 0074 / ADR 0039 / ADR 0016). + (ADR 0096 / ADR 0039 / ADR 0016). ## [2.15.0] - 2026-08-19 @@ -32,7 +32,7 @@ All notable changes to this project are documented here. Format follows Keyman and evaluation as the next read. Customer master names authorized customer entities as current before that open. Home-list opens do not add that focus or copy. No TEPP theta is invented. No customer is invented - (ADR 0073 / ADR 0037 / ADR 0016). + (ADR 0095 / ADR 0037 / ADR 0016). ## [2.14.0] - 2026-08-19 @@ -42,7 +42,7 @@ All notable changes to this project are documented here. Format follows and evaluation as the next read. Calendar names authorized commitments as current before that open. Home-list opens do not add that focus or copy. No TEPP theta is invented. No cutoff body is invented - (ADR 0072 / ADR 0016). + (ADR 0094 / ADR 0016). ## [2.13.0] - 2026-08-19 diff --git a/CLAUDE.md b/CLAUDE.md index 1ec98cabf..b989aa8b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,28 +83,25 @@ Event Lineage takes focus and names Keyman and evaluation next Open Calendar. Authorized commitments are current. Open a commitment: Event Lineage takes focus and names Keyman and evaluation next -(ADR 0072). A home-list open does not. Do not invent a theta or a +(ADR 0094). A home-list open does not. Do not invent a theta or a CalDAV event. ## Customer master open (v2.15.0) Open Customer master. Authorized customer entities are current. Open a related post: Event Lineage takes focus and names Keyman and evaluation -next (ADR 0073). A home-list open does not. Do not invent a theta or a +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 0074). A home-list open does not. Do not invent a theta or a +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 0075). A home-list +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/backend/app/main.py b/backend/app/main.py index 20510bd3e..e0424c4b1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1956,7 +1956,9 @@ async def extract_post_keymen( # tags dilute the model's attention and a base64 payload sent as # literal text either blows the token budget or is silently # ignored (see lineageweave/post_content_normalization.py). - post_body = normalize_post_body(raw_body, vision_client=_vision_client()).text + post_body = ( + await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) + ).text context_hints = await _load_post_semantic_hints(conn, post_id) mentions = await ingest_post_keymen( conn, @@ -2114,8 +2116,12 @@ async def evaluate_post( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - normalized_body = normalize_post_body( - "" if body_row is None else body_row["post_body"], vision_client=_vision_client() + normalized_body = ( + await asyncio.to_thread( + normalize_post_body, + "" if body_row is None else body_row["post_body"], + _vision_client(), + ) ).text async with pool.acquire() as conn: rows = await ingest_post_evaluation( @@ -2315,7 +2321,7 @@ async def read_post_summary( "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) vision_client = _vision_client() - normalized = normalize_post_body(raw_body, vision_client=vision_client) + normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client) settings = load_settings() embedding_client = _embedding_client() structure_client = _post_structure_client() @@ -2735,7 +2741,9 @@ async def derive_post_commitment( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - normalized_body = normalize_post_body(body_row["post_body"], vision_client=_vision_client()).text + normalized_body = ( + await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) + ).text # TimeML/TempEval document creation time, not wall-clock now: "by next # Friday" in a January post must resolve to that January, not to the # Friday after the operator clicked Derive. diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 36e8a678a..b15639c24 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1237,7 +1237,7 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys "person_side_code": "our_side", "last_known_job_title": None, "mention_count": 1, - "provenance": "post_summary_role.cataloged_person_id/source_post.author_account_id", + "provenance": "post_person_mention.person_id|post_summary_role.cataloged_person_id/source_post.author_account_id", } ] assert author_hint[0]["related_posts"] == [ diff --git a/backend/tests/test_auth_jwks.py b/backend/tests/test_auth_jwks.py index 9547851c8..709d2c16e 100644 --- a/backend/tests/test_auth_jwks.py +++ b/backend/tests/test_auth_jwks.py @@ -18,7 +18,7 @@ def _segment(value: dict) -> str: def _unsigned_token(header: dict) -> str: - return f"{_segment(header)}.{_segment({'sub': 'subject'})}.signature" + return f"{_segment(header)}.{_segment({'sub': 'subject'})}.{_segment({'test': 'signature'})}" def test_signing_key_requires_nonempty_exact_kid(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 1622e98d0..b5f6e9a12 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -48,6 +48,7 @@ def test_keyverse_issuer_overrides_local_keycloak_and_uses_oidc_discovery(monkey """Production Keyverse configuration is standard OIDC, not a local mock.""" monkeypatch.setenv("KEYVERSE_ISSUER", "https://keyverse.example/tenant/acme") monkeypatch.setenv("KEYVERSE_CLIENT_ID", "lineageweave-production") + monkeypatch.setenv("KEYVERSE_AUDIENCE", "lineageweave-api") monkeypatch.delenv("KEYVERSE_DISCOVERY_URI", raising=False) monkeypatch.delenv("KEYVERSE_JWKS_URI", raising=False) diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile index 7fda9b223..9ac9aa86f 100644 --- a/docker/contextual-orchestrator/Dockerfile +++ b/docker/contextual-orchestrator/Dockerfile @@ -3,9 +3,10 @@ FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a WORKDIR /app # Reuse the upstream implementation without copying it into LineageWeave. -# Pin the runtime to PR #761's pushed commit until that protected PR merges; -# this keeps reasoning_effort=auto ownership in contextual-orchestrator itself. -ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/0298693.tar.gz /tmp/contextual-orchestrator.tar.gz +# Pin the runtime to PR #765's pushed commit until that protected PR merges; +# this keeps model selection, structured synthesis, and reasoning policy in +# contextual-orchestrator itself. +ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/6db772e.tar.gz /tmp/contextual-orchestrator.tar.gz RUN mkdir /tmp/contextual-orchestrator \ && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \ && cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \ diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py index 4c23a7784..9fa27cb6b 100644 --- a/docker/contextual-orchestrator/start.py +++ b/docker/contextual-orchestrator/start.py @@ -36,7 +36,6 @@ def main() -> None: raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway") if not provider_url.rstrip("/").endswith("/v1"): provider_url = provider_url.rstrip("/") + "/v1" - embedding_provider_url = provider_url raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip() try: max_output_tokens = int(raw_limit) @@ -51,8 +50,6 @@ def main() -> None: raise SystemExit("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES must be an integer") from exc if not 64 * 1024 <= max_body_bytes <= 64 * 1024 * 1024: raise SystemExit("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES must be between 65536 and 67108864") - embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip() - agents_path = Path("/tmp/lineageweave-agents.json") agents = json.loads(Path("/app/agents.json").read_text(encoding="utf-8")) for agent in agents["agents"]: @@ -85,10 +82,6 @@ def main() -> None: str(max_output_tokens), "--max-body-bytes", str(max_body_bytes), - "--embedding-provider-url", - embedding_provider_url, - "--embedding-model", - embedding_model, ] del auth_token from contextual_orchestrator.__main__ import main as serve diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md index 7b759d757..f84cc8230 100644 --- a/docs/adr/0083-orchestrator-runtime-commit-pin.md +++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md @@ -8,14 +8,14 @@ LineageWeave delegates every LLM and VISION request to contextual-orchestrator. The orchestrator's `auto` reasoning mode is an internal routing decision and must not be forwarded as an upstream provider `reasoning_effort` value. The -LineageWeave orchestrator image was pinned to an older archive commit that did -forward that value, causing real provider HTTP 400 responses during semantic -backfill even though the local contextual-orchestrator PR had the correction. +runtime also must discover provider models from the configured gateway rather +than requiring `LLM_GATEWAY_MODEL`, and structured requests must remain +multi-agent. ## Decision `docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to -commit `0298693`, the pushed head of contextual-orchestrator PR #761. The pin +commit `6db772e`, the pushed head of contextual-orchestrator PR #765. The pin remains explicit and immutable until the protected PR merges; it is not a moving `main` reference and it is not a LineageWeave monkey patch. @@ -24,15 +24,19 @@ The runtime contract is: - LineageWeave may send `reasoning_effort="auto"` to the orchestrator. - contextual-orchestrator resolves `auto` using its capability/routing policy. - Only an explicit supported effort is sent to an upstream provider. -- Structured output negotiates `json_schema`, then `json_object`, then - prompt-only synthesis inside contextual-orchestrator when a provider returns - a capability `400` or `422`; the requested JSON contract is validated locally. +- Structured output uses prompt-constrained final synthesis inside + contextual-orchestrator and validates the requested `json_object` or + `json_schema` contract locally; it is not a provider passthrough. - This negotiation preserves multi-agent worker and synthesis calls and never collapses a structured request to a single-agent passthrough. - Multimodal synthesis excludes embedded image/base64 payloads from its textual reconciliation prompt; independent VISION worker evidence is retained instead. - A provider 4xx is reported as a failed orchestration attempt, never as a successful empty semantic result. +- An empty seed model is expanded from the configured gateway `/v1/models` + endpoint; embedding-only rows are not added to the chat agent pool. +- `json_object`, `json_schema`, and Responses JSON formats run conduct plus + synthesis. Tool requests never silently fall back to one agent. ## Consequences @@ -40,4 +44,4 @@ The runtime contract is: implementation. - Rebuilding the image is required after the upstream pin changes. - Protected-branch review and merge remain external gates; this pin does not - bypass PR #761. + bypass PR #765. diff --git a/docs/adr/0072-calendar-open-focuses-event-lineage.md b/docs/adr/0094-calendar-open-focuses-event-lineage.md similarity index 89% rename from docs/adr/0072-calendar-open-focuses-event-lineage.md rename to docs/adr/0094-calendar-open-focuses-event-lineage.md index ef20d1484..0b7b1a8b9 100644 --- a/docs/adr/0072-calendar-open-focuses-event-lineage.md +++ b/docs/adr/0094-calendar-open-focuses-event-lineage.md @@ -1,11 +1,11 @@ -# ADR 0072: Opening a Calendar commitment focuses Event Lineage +# ADR 0094: Opening a Calendar commitment focuses Event Lineage - Status: Accepted - Date: 2026-08-19 ## Context -Board Weekly VOC opens already focus Event Lineage (ADR 0071). Calendar is +Board Weekly VOC opens already focus Event Lineage (ADR 0093). Calendar is the other buyer destination that opens a source post from an authorized commitment. That open was a home-list open: the popup body appeared and Event Lineage did not take focus. diff --git a/docs/adr/0073-customer-master-open-focuses-event-lineage.md b/docs/adr/0095-customer-master-open-focuses-event-lineage.md similarity index 90% rename from docs/adr/0073-customer-master-open-focuses-event-lineage.md rename to docs/adr/0095-customer-master-open-focuses-event-lineage.md index c941e4a24..ab9e55f9f 100644 --- a/docs/adr/0073-customer-master-open-focuses-event-lineage.md +++ b/docs/adr/0095-customer-master-open-focuses-event-lineage.md @@ -1,4 +1,4 @@ -# ADR 0073: Opening a Customer master related post focuses Event Lineage +# ADR 0095: Opening a Customer master related post focuses Event Lineage - Status: Accepted - Date: 2026-08-19 @@ -6,7 +6,7 @@ ## Context Board Weekly VOC and Calendar commitment opens already focus Event Lineage -(ADR 0071 / ADR 0072). Customer master is the remaining buyer GNB destination +(ADR 0093 / ADR 0094). Customer master is the remaining buyer GNB destination that opens an authorized related post. That open was a home-list open: the popup body appeared and Event Lineage did not take focus. diff --git a/docs/adr/0074-ask-agent-open-focuses-event-lineage.md b/docs/adr/0096-ask-agent-open-focuses-event-lineage.md similarity index 90% rename from docs/adr/0074-ask-agent-open-focuses-event-lineage.md rename to docs/adr/0096-ask-agent-open-focuses-event-lineage.md index 16da4d34a..5a323fc25 100644 --- a/docs/adr/0074-ask-agent-open-focuses-event-lineage.md +++ b/docs/adr/0096-ask-agent-open-focuses-event-lineage.md @@ -1,4 +1,4 @@ -# ADR 0074: Opening an Ask Agent cited post focuses Event Lineage +# ADR 0096: Opening an Ask Agent cited post focuses Event Lineage - Status: Accepted - Date: 2026-08-19 @@ -6,7 +6,7 @@ ## Context Board Weekly VOC, Calendar, and Customer master opens already focus Event -Lineage (ADR 0071 / ADR 0072 / ADR 0073). Ask Agent is the remaining buyer +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. diff --git a/docs/adr/0075-event-lineage-node-keeps-gnb-focus.md b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md similarity index 90% rename from docs/adr/0075-event-lineage-node-keeps-gnb-focus.md rename to docs/adr/0097-event-lineage-node-keeps-gnb-focus.md index 851b92384..a31084961 100644 --- a/docs/adr/0075-event-lineage-node-keeps-gnb-focus.md +++ b/docs/adr/0097-event-lineage-node-keeps-gnb-focus.md @@ -1,4 +1,4 @@ -# ADR 0075: A linked Event Lineage node keeps GNB focus +# ADR 0097: A linked Event Lineage node keeps GNB focus - Status: Accepted - Date: 2026-08-19 @@ -7,7 +7,7 @@ Opening a Board Weekly VOC post, Calendar commitment, Customer master related post, Ask Agent cited post, or report member already focuses -Event Lineage (ADR 0071 / ADR 0072 / ADR 0073 / ADR 0074). Clicking a +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. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 282f3e6fe..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 () => { @@ -1887,6 +1897,30 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("does not scroll Calendar users away from Event Lineage when related evidence lands", async () => { + const scrolledIds: string[] = []; + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = function () { + scrolledIds.push(this.id); + }; + try { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Calendar" })); + const calendar = await screen.findByRole("region", { name: "Calendar" }); + await userEvent.click( + within(calendar).getByRole("button", { name: "Open commitment for: Public post" }), + ); + + await screen.findByRole("status", { name: "Ask next action" }); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(scrolledIds).not.toContain("post-ask"); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it("opening a Calendar commitment focuses Event Lineage; a home list open does not", async () => { stubBackend(); render(); @@ -1973,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 fc5d77b79..c14cdfd3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -830,7 +830,7 @@ function KeymanPanel({ focusTeam, landFirstKeyman, landFirstRelated, - focusAskAfterRelated, + landOnAsk, afterList, }: { postId: string; @@ -845,7 +845,7 @@ function KeymanPanel({ focusTeam?: { teamId: string; teamName: string } | null; landFirstKeyman?: boolean; landFirstRelated?: boolean; - focusAskAfterRelated?: boolean; + landOnAsk?: boolean; afterList?: ReactNode; }) { const [related, setRelated] = useState(null); @@ -953,13 +953,13 @@ function KeymanPanel({ }, [accessToken, landFirstRelated, related]); useEffect(() => { - if (!landFirstRelated || !focusAskAfterRelated || !landedRelatedName || landedRelated === null) { + if (!landFirstRelated || !landOnAsk || !landedRelatedName || landedRelated === null) { return; } const heading = document.getElementById("post-ask"); heading?.focus(); heading?.scrollIntoView?.({ block: "nearest" }); - }, [landFirstRelated, focusAskAfterRelated, landedRelatedName, landedRelated]); + }, [landFirstRelated, landedRelatedName, landedRelated, landOnAsk]); useEffect(() => { if (!focusPerson) return; @@ -1653,7 +1653,7 @@ function PostDetailPopup({ liveBodyWarning, knowledgeCutoff, focusEventLineage, - focusAskAfterRelated, + focusAskOnLand, onClose, onSelectPost, onSearch, @@ -1665,7 +1665,7 @@ function PostDetailPopup({ liveBodyWarning?: string | null; knowledgeCutoff?: string | null; focusEventLineage?: boolean; - focusAskAfterRelated?: boolean; + focusAskOnLand?: boolean; onClose: () => void; onSelectPost?: (postId: string) => void; onSearch?: (query: string) => void; @@ -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 = (() => { @@ -2237,7 +2310,7 @@ function PostDetailPopup({ focusTeam={focusTeam} landFirstKeyman landFirstRelated - focusAskAfterRelated={focusAskAfterRelated} + landOnAsk={focusAskOnLand} afterList={ <> { const cutoffOptions = openedAnalysisRunContext diff --git a/frontend/src/isoWeek.test.ts b/frontend/src/isoWeek.test.ts index e3f3ca69e..6a38f21de 100644 --- a/frontend/src/isoWeek.test.ts +++ b/frontend/src/isoWeek.test.ts @@ -18,6 +18,17 @@ describe("isoWeekFromCreatedAt", () => { expect(isoWeekFromCreatedAt(undefined)).toBeNull(); expect(isoWeekFromCreatedAt("not-a-date")).toBeNull(); }); + + it("rejects timestamps whose Gregorian calendar date is impossible", () => { + expect(isoWeekFromCreatedAt("2026-02-29T00:00:00Z")).toBeNull(); + expect(isoWeekFromCreatedAt("2026-02-30T00:00:00Z")).toBeNull(); + expect(isoWeekFromCreatedAt("2026-00-01T00:00:00Z")).toBeNull(); + expect(isoWeekFromCreatedAt("2024-02-29T00:00:00Z")).toBe("2024-W09"); + }); + + it("uses the UTC date when an offset crosses an ISO-week boundary", () => { + expect(isoWeekFromCreatedAt("2026-01-05T00:30:00+09:00")).toBe("2026-W01"); + }); }); describe("latestIsoWeek", () => { diff --git a/frontend/src/isoWeek.ts b/frontend/src/isoWeek.ts index 56fc477bc..5f0b1d192 100644 --- a/frontend/src/isoWeek.ts +++ b/frontend/src/isoWeek.ts @@ -5,6 +5,23 @@ export function isoWeekFromCreatedAt(createdAt: string | null | undefined): stri if (!createdAt) { return null; } + const datePrefix = /^(\d{4})-(\d{2})-(\d{2})T/.exec(createdAt); + if (!datePrefix) { + return null; + } + const year = Number(datePrefix[1]); + const month = Number(datePrefix[2]); + const day = Number(datePrefix[3]); + const calendarDate = new Date(0); + calendarDate.setUTCHours(0, 0, 0, 0); + calendarDate.setUTCFullYear(year, month - 1, day); + if ( + calendarDate.getUTCFullYear() !== year || + calendarDate.getUTCMonth() !== month - 1 || + calendarDate.getUTCDate() !== day + ) { + return null; + } const parsed = new Date(createdAt); if (Number.isNaN(parsed.getTime())) { return null; diff --git a/lineageweave/commitment_extraction.py b/lineageweave/commitment_extraction.py index 1465f0946..db5f7761a 100644 --- a/lineageweave/commitment_extraction.py +++ b/lineageweave/commitment_extraction.py @@ -143,7 +143,7 @@ class ContextualOrchestratorCommitmentExtractionClient: available = True def __init__( - self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 60.0 + self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/lineageweave/entity_relationship_classification.py b/lineageweave/entity_relationship_classification.py index 08cdf751c..50093885d 100644 --- a/lineageweave/entity_relationship_classification.py +++ b/lineageweave/entity_relationship_classification.py @@ -164,7 +164,7 @@ class ContextualOrchestratorEntityRelationshipClient: available = True def __init__( - self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 60.0 + self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index 5bb798d23..3aa2c9038 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -287,7 +287,7 @@ def __init__( api_key: str, model: str | None = None, *, - timeout: float = 60.0, + timeout: float = 180.0, allow_insecure_http: bool = False, ) -> None: parsed = urlparse(base_url) diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py index a677df5db..717a01783 100644 --- a/lineageweave/keyman_extraction.py +++ b/lineageweave/keyman_extraction.py @@ -179,7 +179,7 @@ class ContextualOrchestratorKeymanExtractionClient: available = True def __init__( - self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 60.0 + self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 8156ab0cb..cb5c9ce0c 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -289,11 +289,10 @@ def parse_chat_response(content: str, sources: list[ChatSourceDocument]) -> Chat class ContextualOrchestratorPostChatClient: - """Calls the orchestrator's evidence-preserving ``mode="route"`` boundary. + """Calls the orchestrator's evidence-preserving ``mode="auto"`` boundary. - Interactive chat must not run a multi-step workflow for every question; - the route still crosses contextual-orchestrator and the prompt enforces - evidence-only answers and citations. + contextual-orchestrator resolves ``auto`` using its own capability/routing + policy (ADR 0083); the prompt enforces evidence-only answers and citations. """ available = True diff --git a/lineageweave/post_content_normalization.py b/lineageweave/post_content_normalization.py index 4b3561b35..3fc3e107f 100644 --- a/lineageweave/post_content_normalization.py +++ b/lineageweave/post_content_normalization.py @@ -21,6 +21,9 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor +from contextvars import Context, copy_context +from itertools import repeat import re from dataclasses import dataclass, field @@ -130,6 +133,69 @@ def _merge_region_descriptions(descriptions: list[ImageDescription]) -> ImageDes return ImageDescription(extracted_text=extracted_text, caption=captions, tags=tags) +def _describe_image_chunk( + chunk: Chunk, vision_client: ImageContentClient +) -> tuple[ImageContentResult, ImageDescription | None, str]: + """Analyze one image chunk and keep its evidence and failure state.""" + result = ImageContentResult( + chunk_index=chunk.index, + mime_type=chunk.label, + status_code="unavailable", + ) + if not vision_client.available or chunk.image_data is None: + return result, None, "[image: content unavailable]" + + region_results: list[ImageRegionResult] = [] + try: + locator = getattr(vision_client, "locate_regions", None) + try: + regions = locator(chunk.image_data, chunk.label) if callable(locator) else () + except Exception: # noqa: BLE001 - locator failure falls back to whole-image evidence. + regions = () + if not regions_cover_image(regions): + # A provider may return only a salient crop even when the contract asks for + # full-image coverage. Preserve the missing evidence with one bounded region. + regions = (ImageRegion(0.0, 0.0, 1.0, 1.0),) + for region_index, region in enumerate(regions): + try: + cropped, cropped_mime = crop_image_region(chunk.image_data, chunk.label, region) + region_description = vision_client.describe(cropped, cropped_mime) + except Exception: # noqa: BLE001 - one bad region must not drop other evidence. + region_results.append(ImageRegionResult(region_index, region, "failed")) + else: + region_results.append( + ImageRegionResult(region_index, region, "described", region_description) + ) + successful_regions = [ + item.description for item in region_results if item.description is not None + ] + description = ( + _merge_region_descriptions(successful_regions) + if successful_regions + else vision_client.describe(chunk.image_data, chunk.label) + ) + except Exception: # noqa: BLE001 - a provider failure must not drop the whole post. + return ImageContentResult(chunk.index, chunk.label, "failed"), None, "[image: content unavailable]" + + result = ImageContentResult( + chunk_index=chunk.index, + mime_type=chunk.label, + status_code="described", + description=description, + regions=tuple(region_results), + ) + return result, description, _image_placeholder(description) + + +def _describe_image_chunk_in_context( + context: Context, + chunk: Chunk, + vision_client: ImageContentClient, +) -> tuple[ImageContentResult, ImageDescription | None, str]: + """Run one parallel vision task with the caller's request context.""" + return context.run(_describe_image_chunk, chunk, vision_client) + + def normalize_post_body( body: str, vision_client: ImageContentClient | None = None ) -> NormalizedPostContent: @@ -155,6 +221,23 @@ def normalize_post_body( formatting_hints: list[FormattingHint] = [] image_descriptions: list[ImageDescription] = [] image_results: list[ImageContentResult] = [] + image_outcomes: dict[int, tuple[ImageContentResult, ImageDescription | None, str]] = {} + image_chunks = [chunk for chunk in chunks if chunk.unit_type == "image"] + if image_chunks and vision_client.available: + # ponytail: cap independent provider calls at eight; raise only with measured throughput need. + with ThreadPoolExecutor(max_workers=min(8, len(image_chunks))) as executor: + image_outcomes.update( + zip( + (chunk.index for chunk in image_chunks), + executor.map( + _describe_image_chunk_in_context, + (copy_context() for _ in image_chunks), + image_chunks, + repeat(vision_client), + ), + strict=True, + ) + ) for chunk in chunks: if chunk.unit_type == "dom": @@ -164,63 +247,17 @@ def normalize_post_body( FormattingHint(chunk_index=chunk.index, tag=chunk.label, style=chunk.style) ) elif chunk.unit_type == "image": - result = ImageContentResult( - chunk_index=chunk.index, - mime_type=chunk.label, - status_code="unavailable", + result, description, placeholder = image_outcomes.get( + chunk.index, + ( + ImageContentResult(chunk.index, chunk.label, "unavailable"), + None, + "[image: content unavailable]", + ), ) - if vision_client.available and chunk.image_data is not None: - region_results: list[ImageRegionResult] = [] - try: - locator = getattr(vision_client, "locate_regions", None) - try: - regions = locator(chunk.image_data, chunk.label) if callable(locator) else () - except Exception: # noqa: BLE001 - locator failure falls back to whole-image evidence. - regions = () - if not regions_cover_image(regions): - # A provider may return only a salient crop even when - # the contract asks for full-image coverage. Preserve - # the missing evidence by forcing one bounded, - # full-image description instead of accepting a - # partial region as complete analysis. - regions = (ImageRegion(0.0, 0.0, 1.0, 1.0),) - for region_index, region in enumerate(regions): - try: - cropped, cropped_mime = crop_image_region(chunk.image_data, chunk.label, region) - region_description = vision_client.describe(cropped, cropped_mime) - except Exception: # noqa: BLE001 - one bad region must not drop other evidence. - region_results.append(ImageRegionResult(region_index, region, "failed")) - else: - region_results.append( - ImageRegionResult(region_index, region, "described", region_description) - ) - successful_regions = [ - result.description for result in region_results if result.description is not None - ] - description = ( - _merge_region_descriptions(successful_regions) - if successful_regions - else vision_client.describe(chunk.image_data, chunk.label) - ) - except Exception: # noqa: BLE001 - a provider failure must not drop the whole post. - text_parts.append("[image: content unavailable]") - result = ImageContentResult( - chunk_index=chunk.index, - mime_type=chunk.label, - status_code="failed", - ) - else: - image_descriptions.append(description) - result = ImageContentResult( - chunk_index=chunk.index, - mime_type=chunk.label, - status_code="described", - description=description, - regions=tuple(region_results), - ) - text_parts.append(_image_placeholder(description)) - else: - text_parts.append("[image: content unavailable]") + if description is not None: + image_descriptions.append(description) + text_parts.append(placeholder) image_results.append(result) return NormalizedPostContent( diff --git a/lineageweave/post_evaluation.py b/lineageweave/post_evaluation.py index 572c43d38..e884179ea 100644 --- a/lineageweave/post_evaluation.py +++ b/lineageweave/post_evaluation.py @@ -81,7 +81,7 @@ class _OrchestratorCompleteAdapter: ``complete(messages, mode=...)`` contract. """ - def __init__(self, base_url: str, api_key: str, *, timeout: float = 60.0) -> None: + def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key self._timeout = timeout @@ -111,7 +111,7 @@ class ContextualOrchestratorPostEvaluationClient: available = True - def __init__(self, base_url: str, api_key: str, *, timeout: float = 60.0) -> None: + def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None: self._judge = ContextualOrchestratorJudge( _OrchestratorCompleteAdapter(base_url, api_key, timeout=timeout), mode="auto", diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index b48908559..cd3cb7a60 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -740,12 +740,12 @@ def _parse_summary_details( class ContextualOrchestratorPostSummaryClient: - """Derive summary and semantic evidence through two ``mode="route"`` calls.""" + """Derive summary and semantic evidence through two ``mode="auto"`` calls.""" available = True def __init__( - self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 60.0 + self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/tests/test_orchestrator_bootstrap.py b/tests/test_orchestrator_bootstrap.py deleted file mode 100644 index eecd2c15d..000000000 --- a/tests/test_orchestrator_bootstrap.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path -from unittest.mock import patch - - -_ROOT = Path(__file__).resolve().parents[1] -_MODULE_PATH = _ROOT / "docker" / "contextual-orchestrator" / "start.py" -_SPEC = importlib.util.spec_from_file_location("lineageweave_orchestrator_start", _MODULE_PATH) -assert _SPEC and _SPEC.loader -sys.path.insert(0, str(_MODULE_PATH.parent)) -_MODULE = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(_MODULE) - - -def test_discover_chat_model_skips_non_chat_models() -> None: - class Response: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return None - - def read(self): - return json.dumps( - { - "data": [ - {"id": "text-embedding-3-large"}, - {"id": "gpt-4.1-mini"}, - ] - } - ).encode() - - response = Response() - with patch.object(_MODULE.urllib.request, "urlopen", return_value=response): - assert _MODULE._discover_chat_model("https://gateway.example/v1", "secret") == "gpt-4.1-mini" diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py index ffc9d5e78..43ae0b5a0 100644 --- a/tests/test_post_chat_ingestion.py +++ b/tests/test_post_chat_ingestion.py @@ -89,7 +89,7 @@ def test_parse_chat_response_strips_fence_and_drops_invalid_citations() -> None: assert parse_chat_response('{"answer_text":""}', sources) is None -def test_contextual_chat_client_uses_route_mode_and_evidence_prompt(monkeypatch: pytest.MonkeyPatch) -> None: +def test_contextual_chat_client_uses_auto_mode_and_evidence_prompt(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} def fake_post_json(url: str, payload: dict, *, headers: dict[str, str], timeout: float) -> dict: @@ -111,7 +111,7 @@ def fake_post_json(url: str, payload: dict, *, headers: dict[str, str], timeout: assert answer.cited_post_ids == ("post-a",) assert captured["url"] == "https://orchestrator/v1/chat/completions" payload = captured["payload"] - assert payload["mode"] == "route" + assert payload["mode"] == "auto" assert payload["reasoning_effort"] == "low" assert "fact" in payload["messages"][0]["content"] diff --git a/tests/test_post_content_normalization.py b/tests/test_post_content_normalization.py index 5eac45276..2342e6ad4 100644 --- a/tests/test_post_content_normalization.py +++ b/tests/test_post_content_normalization.py @@ -9,8 +9,10 @@ from __future__ import annotations import base64 +from threading import Lock from lineageweave.image_content import ImageDescription, ImageRegion +from lineageweave.llm_context import current_llm_metadata, use_llm_metadata from lineageweave.post_content_normalization import normalize_post_body _PNG_1X1 = base64.b64decode( @@ -35,6 +37,18 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: raise RuntimeError("provider is down") +class _MetadataCapturingVisionClient(_FakeVisionClient): + def __init__(self, description: ImageDescription) -> None: + super().__init__(description) + self._lock = Lock() + self.seen_metadata: list[dict[str, str] | None] = [] + + def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: + with self._lock: + self.seen_metadata.append(current_llm_metadata()) + return super().describe(image_bytes, mime_type) + + class _RegionVisionClient(_FakeVisionClient): def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: return (ImageRegion(0.0, 0.0, 1.0, 1.0),) @@ -117,6 +131,27 @@ def test_image_regions_are_cropped_and_described_as_independent_evidence() -> No assert "panel text" in result.text +def test_parallel_image_analysis_preserves_post_scoped_llm_metadata() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + html = ( + f'' + f'' + ) + description = ImageDescription(extracted_text="Q3 2026", caption="a chart", tags=("chart",)) + client = _MetadataCapturingVisionClient(description) + metadata = { + "lineageweave_post_id": "post-1", + "lineageweave_pu": "PU-01", + } + + with use_llm_metadata(metadata): + result = normalize_post_body(html, vision_client=client) + + assert len(result.image_descriptions) == 2 + assert len(client.seen_metadata) == 2 + assert all(seen == metadata for seen in client.seen_metadata) + + def test_partial_region_response_falls_back_to_full_image_evidence() -> None: b64 = base64.b64encode(_PNG_1X1).decode("ascii") html = f'' From 194c6315fb6948d3bc0661c2ea08b89e35cc5abf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:33:57 -0700 Subject: [PATCH 59/63] test(red): define the accessible project-history timeline --- .../ProjectHistoryTimeline.test.tsx | 273 ++++++++++++++++++ frontend/src/projectHistory.test.ts | 64 ++++ 2 files changed, 337 insertions(+) create mode 100644 frontend/src/components/ProjectHistoryTimeline.test.tsx create mode 100644 frontend/src/projectHistory.test.ts diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx new file mode 100644 index 000000000..63f041a7b --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -0,0 +1,273 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; + + +const projection: ProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Northridge renewal", + focus_event_id: "voc", + time_basis_code: "document_time", + event_count: 5, + distinct_observed_actor_count: 3, + truncated: false, + events: [ + { + event_id: "award", + source_post_id: "post-award", + event_title: "Contract awarded", + event_type_code: "contract_awarded", + event_type_basis_code: "display_classification", + occurred_at: "2022-03-11T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [ + { + match_kind_code: "source_project_code", + matched_value: "P-100", + truth_status_code: "observed", + confidence: null, + ontology_iri: null, + provenance: "source_post.source_project_code", + }, + ], + observed_responsibilities: [ + { + actor_key: "person:ada", + actor_name: "Ada West", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Own the award", + truth_status_code: "observed", + provenance: "post_summary_role", + }, + ], + responsibility_transition_code: null, + related_prior_paths: [], + }, + { + event_id: "spec", + source_post_id: "post-spec", + event_title: "Specification revision requested", + event_type_code: "specification_changed", + event_type_basis_code: "display_classification", + occurred_at: "2023-06-15T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [ + { + actor_key: "person:ada", + actor_name: "Ada West", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Own the specification", + truth_status_code: "observed", + provenance: "post_summary_role", + }, + ], + responsibility_transition_code: "continuous", + related_prior_paths: [ + { + source_event_id: "award", + target_event_id: "spec", + event_ids: ["award", "spec"], + edges: [ + { + parent_event_id: "award", + child_event_id: "spec", + fused_score: 0.91, + }, + ], + minimum_fused_score: 0.91, + truth_status_code: "inferred", + source_relation_code: "post_lineage_edge", + provenance: "post_lineage_edge.fused_score", + }, + ], + }, + { + event_id: "delivery", + source_post_id: "post-delivery", + event_title: "Delivery confirmed", + event_type_code: "delivered", + event_type_basis_code: "display_classification", + occurred_at: "2024-02-20T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [ + { + actor_key: "person:priya", + actor_name: "Priya Nair", + actor_type_code: "prov_person", + affiliated_organization_name: "Northridge Grid", + responsibility: "Own delivery acceptance", + truth_status_code: "observed", + provenance: "post_summary_role", + }, + ], + responsibility_transition_code: "handoff", + related_prior_paths: [ + { + source_event_id: "award", + target_event_id: "delivery", + event_ids: ["award", "spec", "delivery"], + edges: [ + { + parent_event_id: "award", + child_event_id: "spec", + fused_score: 0.91, + }, + { + parent_event_id: "spec", + child_event_id: "delivery", + fused_score: 0.82, + }, + ], + minimum_fused_score: 0.82, + truth_status_code: "inferred", + source_relation_code: "post_lineage_edge", + provenance: "post_lineage_edge.fused_score", + }, + ], + }, + { + event_id: "voc", + source_post_id: "post-voc", + event_title: "VOC received", + event_type_code: "voc_received", + event_type_basis_code: "display_classification", + occurred_at: "2026-07-30T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [], + responsibility_transition_code: "assignment_gap", + related_prior_paths: [ + { + source_event_id: "award", + target_event_id: "voc", + event_ids: ["award", "spec", "delivery", "voc"], + edges: [ + { + parent_event_id: "award", + child_event_id: "spec", + fused_score: 0.91, + }, + { + parent_event_id: "spec", + child_event_id: "delivery", + fused_score: 0.82, + }, + { + parent_event_id: "delivery", + child_event_id: "voc", + fused_score: 0.73, + }, + ], + minimum_fused_score: 0.73, + truth_status_code: "inferred", + source_relation_code: "post_lineage_edge", + provenance: "post_lineage_edge.fused_score", + }, + ], + }, + { + event_id: "rebid", + source_post_id: "post-rebid", + event_title: "Rebid started", + event_type_code: "rebid_started", + event_type_basis_code: "display_classification", + occurred_at: "2026-08-10T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [ + { + actor_key: "team:bid", + actor_name: "Bid team", + actor_type_code: "prov_team", + affiliated_organization_name: "Demo Corp", + responsibility: "Prepare the rebid", + truth_status_code: "observed", + provenance: "post_summary_role", + }, + ], + responsibility_transition_code: "assignment_gap", + related_prior_paths: [], + }, + ], +}; + + +describe("ProjectHistoryTimeline", () => { + it("renders the focus event, exact evidence, and non-causal prior path", () => { + const onOpenPost = vi.fn(); + render(); + + expect(screen.getByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + expect(screen.getByText("5 events · 3 observed actors")).toBeInTheDocument(); + const vocTab = screen.getByRole("tab", { name: /VOC received/ }); + expect(vocTab).toHaveAttribute("aria-selected", "true"); + expect(vocTab).toHaveAttribute("aria-current", "step"); + expect(screen.getByText("Assignment evidence gap")).toBeInTheDocument(); + expect( + screen.getByText( + "Contract awarded → Specification revision requested → Delivery confirmed → VOC received", + ), + ).toBeInTheDocument(); + expect(screen.getByText(/inferred related history, not causality/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open source record: VOC received" })); + expect(onOpenPost).toHaveBeenCalledWith("post-voc"); + }); + + it("supports roving keyboard selection with visible text for handoffs and gaps", () => { + render(); + const vocTab = screen.getByRole("tab", { name: /VOC received/ }); + + fireEvent.keyDown(vocTab, { key: "ArrowLeft" }); + const deliveryTab = screen.getByRole("tab", { name: /Delivery confirmed/ }); + expect(deliveryTab).toHaveAttribute("aria-selected", "true"); + expect(deliveryTab).toHaveFocus(); + expect(screen.getByText("Responsibility handoff")).toBeInTheDocument(); + expect(screen.getByText("Priya Nair")).toBeInTheDocument(); + + fireEvent.keyDown(deliveryTab, { key: "Home" }); + expect(screen.getByRole("tab", { name: /Contract awarded/ })).toHaveAttribute( + "aria-selected", + "true", + ); + + fireEvent.keyDown(screen.getByRole("tab", { name: /Contract awarded/ }), { key: "End" }); + expect(screen.getByRole("tab", { name: /Rebid started/ })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("provides a complete exact-value table for touch, print, and assistive technology", () => { + render(); + fireEvent.click(screen.getByText("Exact values")); + + const table = screen.getByRole("table", { name: "Project history exact values" }); + expect(within(table).getAllByRole("row")).toHaveLength(6); + expect(within(table).getByText("0.730")).toBeInTheDocument(); + expect(within(table).getByText("Assignment evidence gap")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/projectHistory.test.ts b/frontend/src/projectHistory.test.ts new file mode 100644 index 000000000..e1e56617c --- /dev/null +++ b/frontend/src/projectHistory.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + groupProjectEvidence, + PROJECT_HISTORY_MESSAGE_KEYS, + projectHistoryText, +} from "./projectHistory"; + + +describe("project-history evidence grouping", () => { + it("converges explicit and semantic project identity without duplicate cards", () => { + const groups = groupProjectEvidence([ + { + project_key: "P-100", + project_name: "Northridge renewal", + evidence: "source_post.source_project_code", + confidence: null, + ontology_iri: "https://w3id.org/lineageweave#Project", + extraction_method: "source_field_hint", + resolution_status: "hint_only", + provenance: "source_post.source_project_code", + }, + { + project_key: "P-100", + project_name: "Northridge renewal", + evidence: "The project was named in the body.", + confidence: 0.91, + ontology_iri: "https://w3id.org/lineageweave#Project", + extraction_method: "contextual_orchestrator_semantic", + resolution_status: "semantic_candidate", + provenance: "post_project_mention.evidence_text", + }, + ]); + + expect(groups).toHaveLength(1); + expect(groups[0].projectKey).toBe("P-100"); + expect(groups[0].projectName).toBe("Northridge renewal"); + expect(groups[0].evidence).toHaveLength(2); + expect(groups[0].evidence[0].extraction_method).toBe("source_field_hint"); + }); +}); + + +describe("project-history locale contract", () => { + it.each(["ko", "zh", "ja", "vi"] as const)( + "contains every Buyer message in %s", + (locale) => { + for (const key of PROJECT_HISTORY_MESSAGE_KEYS) { + expect(projectHistoryText(locale, key), `${locale}:${key}`).not.toBe( + projectHistoryText("en", key), + ); + } + }, + ); + + it("formats event and actor counts", () => { + expect(projectHistoryText("en", "summaryCounts", { events: 5, actors: 3 })).toBe( + "5 events · 3 observed actors", + ); + expect(projectHistoryText("ko", "summaryCounts", { events: 5, actors: 3 })).toBe( + "이벤트 5건 · 관찰된 담당자 3명", + ); + }); +}); From 5b4070cfd18fc2d90cc3c799701c7adadd5c5086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:42:50 -0700 Subject: [PATCH 60/63] feat: render accessible evidence-bound project histories --- .../src/components/ProjectHistoryTimeline.css | 241 +++++++++++ .../ProjectHistoryTimeline.stories.tsx | 97 +++++ .../src/components/ProjectHistoryTimeline.tsx | 292 +++++++++++++ frontend/src/projectHistory.ts | 391 ++++++++++++++++++ 4 files changed, 1021 insertions(+) create mode 100644 frontend/src/components/ProjectHistoryTimeline.css create mode 100644 frontend/src/components/ProjectHistoryTimeline.stories.tsx create mode 100644 frontend/src/components/ProjectHistoryTimeline.tsx create mode 100644 frontend/src/projectHistory.ts diff --git a/frontend/src/components/ProjectHistoryTimeline.css b/frontend/src/components/ProjectHistoryTimeline.css new file mode 100644 index 000000000..0ff7a031e --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.css @@ -0,0 +1,241 @@ +.project-history { + display: grid; + gap: 1rem; + min-width: 0; +} + +.project-history-header, +.project-history-detail-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.project-history-header h3, +.project-history-detail-heading h4 { + margin: 0; +} + +.project-history-counts, +.project-history-time-basis, +.project-history-warning, +.project-history-boundary { + margin: 0; +} + +.project-history-warning, +.project-history-boundary { + border-inline-start: 0.25rem solid currentColor; + padding-inline-start: 0.75rem; +} + +.project-history-tabs { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(10rem, 1fr); + overflow-x: auto; + padding: 1.5rem 0 0.5rem; + position: relative; +} + +.project-history-tabs::before { + content: ""; + position: absolute; + inset-inline: 1rem; + top: 2rem; + border-top: 2px solid var(--border-color, #9aa4b2); +} + +.project-history-tab { + appearance: none; + background: transparent; + border: 0; + color: inherit; + display: grid; + gap: 0.35rem; + justify-items: center; + min-height: 7rem; + padding: 0; + position: relative; + text-align: center; +} + +.project-history-tab:focus-visible { + outline: 3px solid currentColor; + outline-offset: 0.25rem; +} + +.project-history-marker { + background: currentColor; + border: 0.25rem solid var(--surface-color, #fff); + border-radius: 50%; + box-shadow: 0 0 0 2px currentColor; + height: 1rem; + width: 1rem; + z-index: 1; +} + +.project-history-tab-current .project-history-marker { + height: 1.25rem; + width: 1.25rem; +} + +.project-history-tab[aria-selected="true"] strong { + text-decoration: underline; + text-underline-offset: 0.25rem; +} + +.project-history-detail { + border: 1px solid var(--border-color, #c8d0da); + border-radius: 0.75rem; + display: grid; + gap: 1rem; + padding: 1rem; +} + +.project-history-detail section { + display: grid; + gap: 0.5rem; +} + +.project-history-detail h5 { + margin: 0; +} + +.project-history-facts { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + margin: 0; +} + +.project-history-facts div { + display: grid; + gap: 0.25rem; +} + +.project-history-facts dt { + font-weight: 700; +} + +.project-history-facts dd { + margin: 0; +} + +.project-history-transition { + font-weight: 700; +} + +.project-history-responsibilities, +.project-history-paths { + display: grid; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.project-history-responsibilities li, +.project-history-paths li { + border: 1px solid var(--border-color, #d7dde5); + border-radius: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem; +} + +.project-history-responsibilities li span:not(.project-history-truth) { + flex-basis: 100%; +} + +.project-history-paths p { + flex: 1 1 20rem; + margin: 0; +} + +.project-history-truth { + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.8rem; + padding: 0.1rem 0.5rem; +} + +.project-history-exact-values summary { + cursor: pointer; + font-weight: 700; +} + +.project-history-table-scroll { + overflow-x: auto; + padding-top: 0.75rem; +} + +.project-history-table-scroll table { + border-collapse: collapse; + min-width: 54rem; + width: 100%; +} + +.project-history-table-scroll th, +.project-history-table-scroll td { + border: 1px solid var(--border-color, #c8d0da); + padding: 0.5rem; + text-align: start; + vertical-align: top; +} + +@media (max-width: 48rem) { + .project-history-tabs { + grid-auto-flow: row; + grid-auto-rows: auto; + overflow: visible; + padding: 0; + } + + .project-history-tabs::before { + border-inline-start: 2px solid var(--border-color, #9aa4b2); + border-top: 0; + inset-block: 1rem; + inset-inline-start: 0.75rem; + } + + .project-history-tab { + grid-template-columns: 1.5rem minmax(5rem, auto) 1fr; + justify-items: start; + min-height: auto; + padding: 0.5rem 0.5rem 0.5rem 0; + text-align: start; + } + + .project-history-tab > span:last-child { + grid-column: 3; + } + + .project-history-header, + .project-history-detail-heading { + align-items: stretch; + flex-direction: column; + } +} + +@media print { + .project-history-tabs, + .project-history-detail-heading button { + display: none; + } + + .project-history-exact-values, + .project-history-exact-values > * { + display: block !important; + } + + .project-history-table-scroll { + overflow: visible; + } + + .project-history-table-scroll table { + min-width: 0; + } +} diff --git a/frontend/src/components/ProjectHistoryTimeline.stories.tsx b/frontend/src/components/ProjectHistoryTimeline.stories.tsx new file mode 100644 index 000000000..ca6b1cb38 --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import type { ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; + +const event = ( + eventId: string, + title: string, + type: string, + occurredAt: string, + transition: "continuous" | "handoff" | "assignment_gap" | null, + actorName?: string, +) => ({ + event_id: eventId, + source_post_id: `post-${eventId}`, + event_title: title, + event_type_code: type, + event_type_basis_code: "display_classification" as const, + occurred_at: occurredAt, + time_basis_code: "document_time" as const, + voc_type_code: eventId === "voc" ? "voc" : "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: actorName + ? [ + { + actor_key: `actor:${actorName}`, + actor_name: actorName, + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: `Own ${title.toLowerCase()}`, + truth_status_code: "observed" as const, + provenance: "post_summary_role" as const, + }, + ] + : [], + responsibility_transition_code: transition, + related_prior_paths: [], +}); + +const projection: ProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Northridge renewal", + focus_event_id: "voc", + time_basis_code: "document_time", + event_count: 5, + distinct_observed_actor_count: 3, + truncated: false, + events: [ + event("award", "Contract awarded", "contract_awarded", "2022-03-11T09:00:00Z", null, "Ada West"), + event( + "spec", + "Specification revision requested", + "specification_changed", + "2023-06-15T09:00:00Z", + "continuous", + "Ada West", + ), + event("delivery", "Delivery confirmed", "delivered", "2024-02-20T09:00:00Z", "handoff", "Priya Nair"), + event("voc", "VOC received", "voc_received", "2026-07-30T09:00:00Z", "assignment_gap"), + event("rebid", "Rebid started", "rebid_started", "2026-08-10T09:00:00Z", "assignment_gap", "Bid team"), + ], +}; + +projection.events[3].related_prior_paths = [ + { + source_event_id: "award", + target_event_id: "voc", + event_ids: ["award", "spec", "delivery", "voc"], + edges: [ + { parent_event_id: "award", child_event_id: "spec", fused_score: 0.91 }, + { parent_event_id: "spec", child_event_id: "delivery", fused_score: 0.82 }, + { parent_event_id: "delivery", child_event_id: "voc", fused_score: 0.73 }, + ], + minimum_fused_score: 0.73, + truth_status_code: "inferred", + source_relation_code: "post_lineage_edge", + provenance: "post_lineage_edge.fused_score", + }, +]; + +const meta = { + title: "Buyer/Project History Timeline", + component: ProjectHistoryTimeline, + args: { + projection, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AwardToRebid: Story = {}; diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx new file mode 100644 index 000000000..f222e0ab2 --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -0,0 +1,292 @@ +import { useRef, useState, type KeyboardEvent } from "react"; + +import { useLocale } from "../i18n"; +import { + type ProjectHistoryEvent, + type ProjectHistoryProjection, + projectHistoryEventTypeLabel, + projectHistoryText, + projectHistoryTransitionLabel, +} from "../projectHistory"; +import "./ProjectHistoryTimeline.css"; + +function formatDate(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? value : parsed.toISOString().slice(0, 10); +} + +function minimumPathScore(event: ProjectHistoryEvent): number | null { + if (event.related_prior_paths.length === 0) return null; + return Math.min(...event.related_prior_paths.map((path) => path.minimum_fused_score)); +} + +export function ProjectHistoryTimeline({ + projection, + onOpenPost, +}: { + projection: ProjectHistoryProjection; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const initialEvent = + projection.events.find((event) => event.event_id === projection.focus_event_id) ?? + projection.events[0]; + const [selectedEventId, setSelectedEventId] = useState(initialEvent?.event_id ?? ""); + const tabRefs = useRef>([]); + const eventById = new Map(projection.events.map((event) => [event.event_id, event])); + const selectedEvent = eventById.get(selectedEventId) ?? initialEvent; + + function selectAt(index: number) { + const bounded = Math.max(0, Math.min(index, projection.events.length - 1)); + const event = projection.events[bounded]; + if (!event) return; + setSelectedEventId(event.event_id); + tabRefs.current[bounded]?.focus(); + } + + function handleTabKey(event: KeyboardEvent, index: number) { + let target: number | null = null; + switch (event.key) { + case "ArrowLeft": + case "ArrowUp": + target = index === 0 ? projection.events.length - 1 : index - 1; + break; + case "ArrowRight": + case "ArrowDown": + target = index === projection.events.length - 1 ? 0 : index + 1; + break; + case "Home": + target = 0; + break; + case "End": + target = projection.events.length - 1; + break; + default: + return; + } + event.preventDefault(); + selectAt(target); + } + + const selectedPanelId = `project-history-panel-${projection.normalized_project_key.replace(/[^a-z0-9_-]+/g, "-")}`; + + return ( +
      +
      +
      +

      {projection.project_name}

      +

      {projectHistoryText(locale, "heading")}

      +
      +

      + {projectHistoryText(locale, "summaryCounts", { + events: projection.event_count, + actors: projection.distinct_observed_actor_count, + })} +

      +
      + +

      {projectHistoryText(locale, "documentTime")}

      + {projection.truncated ? ( +

      + {projectHistoryText(locale, "truncated")} +

      + ) : null} + +
      + {projection.events.map((event, index) => { + const selected = event.event_id === selectedEvent?.event_id; + const current = event.event_id === projection.focus_event_id; + return ( + + ); + })} +
      + + {selectedEvent ? ( +
      +
      +
      +

      {projectHistoryText(locale, "eventDetail")}

      +

      {selectedEvent.event_title}

      +
      + +
      + +
      +
      +
      {projectHistoryText(locale, "eventDate")}
      +
      {formatDate(selectedEvent.occurred_at)}
      +
      +
      +
      {projectHistoryText(locale, "eventType")}
      +
      {projectHistoryEventTypeLabel(locale, selectedEvent.event_type_code)}
      +
      + {selectedEvent.responsibility_transition_code ? ( +
      +
      {projectHistoryText(locale, "columnTransition")}
      +
      + {projectHistoryTransitionLabel( + locale, + selectedEvent.responsibility_transition_code, + )} +
      +
      + ) : null} +
      + +
      +
      + {projectHistoryText(locale, "responsibilityEvidence")} +
      + {selectedEvent.observed_responsibilities.length > 0 ? ( +
        + {selectedEvent.observed_responsibilities.map((responsibility) => ( +
      • + {responsibility.actor_name} + {responsibility.affiliated_organization_name + ? ` · ${responsibility.affiliated_organization_name}` + : ""} + {responsibility.responsibility} + + {projectHistoryText(locale, "observed")} + +
      • + ))} +
      + ) : ( +

      {projectHistoryText(locale, "noResponsibilityEvidence")}

      + )} +
      + +
      +
      {projectHistoryText(locale, "priorHistory")}
      + {selectedEvent.related_prior_paths.length > 0 ? ( +
        + {selectedEvent.related_prior_paths.map((path) => ( +
      • +

        + {path.event_ids + .map((eventId) => eventById.get(eventId)?.event_title ?? eventId) + .join(" → ")} +

        + {path.minimum_fused_score.toFixed(3)} + + {projectHistoryText(locale, "inferred")} + +
      • + ))} +
      + ) : ( +

      {projectHistoryText(locale, "noPriorHistory")}

      + )} +

      + {projectHistoryText(locale, "inferredBoundary")} +

      +
      + + {selectedEvent.project_matches.length > 0 ? ( +
      +
      + {projectHistoryText(locale, "projectEvidence")} +
      +
        + {selectedEvent.project_matches.map((match) => ( +
      • + {match.matched_value} · {match.provenance} ·{" "} + {projectHistoryText(locale, match.truth_status_code)} +
      • + ))} +
      +
      + ) : null} +
      + ) : null} + +
      + {projectHistoryText(locale, "exactValues")} +
      + + + + + + + + + + + + + {projection.events.map((event) => { + const pathScore = minimumPathScore(event); + return ( + + + + + + + + + ); + })} + +
      {projectHistoryText(locale, "columnDate")}{projectHistoryText(locale, "columnEvent")}{projectHistoryText(locale, "columnType")}{projectHistoryText(locale, "columnTransition")}{projectHistoryText(locale, "columnActors")}{projectHistoryText(locale, "columnPathScore")}
      {formatDate(event.occurred_at)}{event.event_title}{projectHistoryEventTypeLabel(locale, event.event_type_code)} + {projectHistoryTransitionLabel(locale, event.responsibility_transition_code)} + + {event.observed_responsibilities.length > 0 + ? event.observed_responsibilities.map((row) => row.actor_name).join(", ") + : projectHistoryText(locale, "notApplicable")} + + {pathScore === null + ? projectHistoryText(locale, "notApplicable") + : pathScore.toFixed(3)} +
      +
      +
      +
      + ); +} diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts new file mode 100644 index 000000000..03cb823f6 --- /dev/null +++ b/frontend/src/projectHistory.ts @@ -0,0 +1,391 @@ +import type { ProjectEvidence } from "./api"; +import type { Locale } from "./i18n"; + +export type ProjectHistoryTruthStatus = "observed" | "inferred"; +export type ResponsibilityTransitionCode = "continuous" | "handoff" | "assignment_gap"; + +export interface ProjectHistoryMatch { + match_kind_code: string; + matched_value: string; + truth_status_code: ProjectHistoryTruthStatus; + confidence: number | null; + ontology_iri: string | null; + provenance: string; +} + +export interface ProjectHistoryResponsibility { + actor_key: string; + actor_name: string; + actor_type_code: string; + affiliated_organization_name: string | null; + responsibility: string; + truth_status_code: "observed"; + provenance: "post_summary_role"; +} + +export interface ProjectHistoryPathEdge { + parent_event_id: string; + child_event_id: string; + fused_score: number; +} + +export interface ProjectHistoryPriorPath { + source_event_id: string; + target_event_id: string; + event_ids: string[]; + edges: ProjectHistoryPathEdge[]; + minimum_fused_score: number; + truth_status_code: "inferred"; + source_relation_code: "post_lineage_edge"; + provenance: "post_lineage_edge.fused_score"; +} + +export interface ProjectHistoryEvent { + event_id: string; + source_post_id: string; + event_title: string; + event_type_code: string; + event_type_basis_code: "display_classification"; + occurred_at: string; + time_basis_code: "document_time"; + voc_type_code: string | null; + source_stage_code: string | null; + source_detail_state_code: string | null; + project_matches: ProjectHistoryMatch[]; + observed_responsibilities: ProjectHistoryResponsibility[]; + responsibility_transition_code: ResponsibilityTransitionCode | null; + related_prior_paths: ProjectHistoryPriorPath[]; +} + +export interface ProjectHistoryProjection { + contract_version: 1; + project_key: string; + normalized_project_key: string; + project_name: string; + focus_event_id: string; + time_basis_code: "document_time"; + event_count: number; + distinct_observed_actor_count: number; + truncated: boolean; + events: ProjectHistoryEvent[]; +} + +export interface ProjectEvidenceGroup { + normalizedProjectKey: string; + projectKey: string; + projectName: string; + evidence: ProjectEvidence[]; +} + +function normalizeProjectIdentity(value: string): string { + return value.normalize("NFKC").trim().toLocaleLowerCase("en-US"); +} + +function evidenceOrder(evidence: ProjectEvidence): number { + if (evidence.extraction_method === "source_field_hint") return 0; + if (evidence.resolution_status === "hint_only") return 1; + return 2; +} + +export function groupProjectEvidence(evidence: ProjectEvidence[]): ProjectEvidenceGroup[] { + const groups = new Map(); + for (const item of evidence) { + const normalizedProjectKey = normalizeProjectIdentity(item.project_key || item.project_name); + if (!normalizedProjectKey) continue; + const existing = groups.get(normalizedProjectKey); + if (!existing) { + groups.set(normalizedProjectKey, { + normalizedProjectKey, + projectKey: item.project_key, + projectName: item.project_name, + evidence: [item], + }); + continue; + } + existing.evidence.push(item); + if (evidenceOrder(item) < evidenceOrder(existing.evidence[0])) { + existing.projectKey = item.project_key; + existing.projectName = item.project_name; + } + } + return Array.from(groups.values()) + .map((group) => ({ + ...group, + evidence: [...group.evidence].sort( + (left, right) => + evidenceOrder(left) - evidenceOrder(right) || + left.project_name.localeCompare(right.project_name) || + left.provenance.localeCompare(right.provenance), + ), + })) + .sort((left, right) => left.projectName.localeCompare(right.projectName)); +} + +const MESSAGE_KEYS = [ + "heading", + "summaryCounts", + "documentTime", + "truncated", + "eventDetail", + "eventType", + "eventDate", + "responsibilityEvidence", + "noResponsibilityEvidence", + "continuous", + "handoff", + "assignmentGap", + "priorHistory", + "noPriorHistory", + "inferredBoundary", + "projectEvidence", + "observed", + "inferred", + "openSourceRecord", + "exactValues", + "exactTableLabel", + "columnDate", + "columnEvent", + "columnType", + "columnTransition", + "columnActors", + "columnPathScore", + "notApplicable", + "contractAwarded", + "specificationChanged", + "delivered", + "handoffRecorded", + "vocReceived", + "rebidStarted", + "sourceRecorded", +] as const; + +export const PROJECT_HISTORY_MESSAGE_KEYS = MESSAGE_KEYS; +export type ProjectHistoryMessageKey = (typeof MESSAGE_KEYS)[number]; + +type MessageParams = Record; + +const EN: Record = { + heading: "Project event timeline", + summaryCounts: "{events} events · {actors} observed actors", + documentTime: "Dates use document time; they are not asserted event-occurrence times.", + truncated: "This bounded timeline is truncated. The selected event remains included.", + eventDetail: "Event detail", + eventType: "Display event type", + eventDate: "Document date", + responsibilityEvidence: "Observed responsibility evidence", + noResponsibilityEvidence: "No responsibility evidence is recorded for this event.", + continuous: "Responsibility continued", + handoff: "Responsibility handoff", + assignmentGap: "Assignment evidence gap", + priorHistory: "Related prior history", + noPriorHistory: "No visible prior lineage path is recorded for this event.", + inferredBoundary: "This is inferred related history, not causality or an authoritative assignment record.", + projectEvidence: "Project identity evidence", + observed: "Observed", + inferred: "Inferred", + openSourceRecord: "Open source record: {title}", + exactValues: "Exact values", + exactTableLabel: "Project history exact values", + columnDate: "Date", + columnEvent: "Event", + columnType: "Type", + columnTransition: "Responsibility transition", + columnActors: "Observed actors", + columnPathScore: "Minimum lineage score", + notApplicable: "Not applicable", + contractAwarded: "Contract awarded", + specificationChanged: "Specification changed", + delivered: "Delivered", + handoffRecorded: "Handoff recorded", + vocReceived: "VOC received", + rebidStarted: "Rebid started", + sourceRecorded: "Source record", +}; + +const MESSAGES: Record> = { + en: EN, + ko: { + heading: "프로젝트 이벤트 타임라인", + summaryCounts: "이벤트 {events}건 · 관찰된 담당자 {actors}명", + documentTime: "날짜는 문서 시각이며 실제 사건 발생 시각으로 단정하지 않습니다.", + truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.", + eventDetail: "이벤트 상세", + eventType: "표시용 이벤트 유형", + eventDate: "문서 날짜", + responsibilityEvidence: "관찰된 담당 근거", + noResponsibilityEvidence: "이 이벤트에는 기록된 담당 근거가 없습니다.", + continuous: "담당 유지", + handoff: "담당 변경", + assignmentGap: "담당 근거 공백", + priorHistory: "관련 과거 이력", + noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.", + inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.", + projectEvidence: "프로젝트 식별 근거", + observed: "관찰됨", + inferred: "추론됨", + openSourceRecord: "원천 기록 열기: {title}", + exactValues: "정확한 값", + exactTableLabel: "프로젝트 이력 정확한 값", + columnDate: "날짜", + columnEvent: "이벤트", + columnType: "유형", + columnTransition: "담당 변화", + columnActors: "관찰된 담당자", + columnPathScore: "최소 계보 점수", + notApplicable: "해당 없음", + contractAwarded: "수주 확정", + specificationChanged: "사양 변경", + delivered: "납품", + handoffRecorded: "인수인계 기록", + vocReceived: "VOC 접수", + rebidStarted: "재입찰 시작", + sourceRecorded: "원천 기록", + }, + zh: { + heading: "项目事件时间线", + summaryCounts: "{events} 个事件 · {actors} 名已观察责任人", + documentTime: "日期采用文档时间,不声称为事件实际发生时间。", + truncated: "此有界时间线已截断,但所选事件仍保留。", + eventDetail: "事件详情", + eventType: "显示事件类型", + eventDate: "文档日期", + responsibilityEvidence: "已观察的责任证据", + noResponsibilityEvidence: "此事件没有记录责任证据。", + continuous: "责任持续", + handoff: "责任交接", + assignmentGap: "责任证据缺口", + priorHistory: "相关既往历史", + noPriorHistory: "此事件没有可见的既往谱系路径。", + inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。", + projectEvidence: "项目身份依据", + observed: "已观察", + inferred: "已推断", + openSourceRecord: "打开源记录:{title}", + exactValues: "精确值", + exactTableLabel: "项目历史精确值", + columnDate: "日期", + columnEvent: "事件", + columnType: "类型", + columnTransition: "责任变化", + columnActors: "已观察责任人", + columnPathScore: "最低谱系分数", + notApplicable: "不适用", + contractAwarded: "合同授予", + specificationChanged: "规格变更", + delivered: "已交付", + handoffRecorded: "已记录交接", + vocReceived: "收到客户之声", + rebidStarted: "重新投标开始", + sourceRecorded: "源记录", + }, + ja: { + heading: "プロジェクトイベントのタイムライン", + summaryCounts: "イベント {events}件 · 観察された担当者 {actors}名", + documentTime: "日付は文書時刻であり、実際のイベント発生時刻とは断定しません。", + truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。", + eventDetail: "イベント詳細", + eventType: "表示用イベント種別", + eventDate: "文書日付", + responsibilityEvidence: "観察された担当根拠", + noResponsibilityEvidence: "このイベントには担当根拠が記録されていません。", + continuous: "担当継続", + handoff: "担当引継ぎ", + assignmentGap: "担当根拠の空白", + priorHistory: "関連する過去履歴", + noPriorHistory: "このイベントに至る可視の過去系譜はありません。", + inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。", + projectEvidence: "プロジェクト識別根拠", + observed: "観察済み", + inferred: "推論済み", + openSourceRecord: "原資料を開く: {title}", + exactValues: "正確な値", + exactTableLabel: "プロジェクト履歴の正確な値", + columnDate: "日付", + columnEvent: "イベント", + columnType: "種別", + columnTransition: "担当変化", + columnActors: "観察担当者", + columnPathScore: "最小系譜スコア", + notApplicable: "該当なし", + contractAwarded: "受注確定", + specificationChanged: "仕様変更", + delivered: "納品", + handoffRecorded: "引継ぎ記録", + vocReceived: "VOC受付", + rebidStarted: "再入札開始", + sourceRecorded: "原資料", + }, + vi: { + heading: "Dòng thời gian sự kiện dự án", + summaryCounts: "{events} sự kiện · {actors} người phụ trách được quan sát", + documentTime: "Ngày dùng thời gian tài liệu, không khẳng định là thời điểm sự kiện thực tế.", + truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.", + eventDetail: "Chi tiết sự kiện", + eventType: "Loại sự kiện hiển thị", + eventDate: "Ngày tài liệu", + responsibilityEvidence: "Bằng chứng trách nhiệm quan sát được", + noResponsibilityEvidence: "Không có bằng chứng trách nhiệm được ghi cho sự kiện này.", + continuous: "Trách nhiệm được duy trì", + handoff: "Bàn giao trách nhiệm", + assignmentGap: "Khoảng trống bằng chứng phân công", + priorHistory: "Lịch sử trước đó có liên quan", + noPriorHistory: "Không có đường dẫn lịch sử trước đó khả kiến cho sự kiện này.", + inferredBoundary: "Đây là lịch sử liên quan được suy luận, không phải quan hệ nhân quả hay hồ sơ phân công có thẩm quyền.", + projectEvidence: "Bằng chứng nhận dạng dự án", + observed: "Đã quan sát", + inferred: "Đã suy luận", + openSourceRecord: "Mở bản ghi nguồn: {title}", + exactValues: "Giá trị chính xác", + exactTableLabel: "Giá trị chính xác của lịch sử dự án", + columnDate: "Ngày", + columnEvent: "Sự kiện", + columnType: "Loại", + columnTransition: "Thay đổi trách nhiệm", + columnActors: "Người phụ trách được quan sát", + columnPathScore: "Điểm dòng dõi tối thiểu", + notApplicable: "Không áp dụng", + contractAwarded: "Đã trao hợp đồng", + specificationChanged: "Đã thay đổi đặc tả", + delivered: "Đã bàn giao sản phẩm", + handoffRecorded: "Đã ghi nhận bàn giao", + vocReceived: "Đã nhận ý kiến khách hàng", + rebidStarted: "Đã bắt đầu đấu thầu lại", + sourceRecorded: "Bản ghi nguồn", + }, +}; + +export function projectHistoryText( + locale: Locale, + key: ProjectHistoryMessageKey, + params: MessageParams = {}, +): string { + let value = MESSAGES[locale][key]; + for (const [name, replacement] of Object.entries(params)) { + value = value.replaceAll(`{${name}}`, String(replacement)); + } + return value; +} + +export function projectHistoryEventTypeLabel(locale: Locale, code: string): string { + const keyByCode: Record = { + contract_awarded: "contractAwarded", + specification_changed: "specificationChanged", + delivered: "delivered", + handoff_recorded: "handoffRecorded", + voc_received: "vocReceived", + rebid_started: "rebidStarted", + source_recorded: "sourceRecorded", + }; + const key = keyByCode[code]; + return key ? projectHistoryText(locale, key) : code; +} + +export function projectHistoryTransitionLabel( + locale: Locale, + code: ResponsibilityTransitionCode | null, +): string { + if (code === "continuous") return projectHistoryText(locale, "continuous"); + if (code === "handoff") return projectHistoryText(locale, "handoff"); + if (code === "assignment_gap") return projectHistoryText(locale, "assignmentGap"); + return projectHistoryText(locale, "notApplicable"); +} From 60c741e8f650481f9d5a7d2466255ad409336336 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:46:37 -0700 Subject: [PATCH 61/63] test(red): require an explicit project-history disclosure --- .../ProjectHistoryDisclosure.test.tsx | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 frontend/src/components/ProjectHistoryDisclosure.test.tsx diff --git a/frontend/src/components/ProjectHistoryDisclosure.test.tsx b/frontend/src/components/ProjectHistoryDisclosure.test.tsx new file mode 100644 index 000000000..5a5228fce --- /dev/null +++ b/frontend/src/components/ProjectHistoryDisclosure.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ProjectHistoryDisclosure } from "./ProjectHistoryDisclosure"; + +const projection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Northridge renewal", + focus_event_id: "voc", + time_basis_code: "document_time", + event_count: 1, + distinct_observed_actor_count: 0, + truncated: false, + events: [ + { + event_id: "voc", + source_post_id: "post-voc", + event_title: "VOC received", + event_type_code: "voc_received", + event_type_basis_code: "display_classification", + occurred_at: "2026-07-30T09:00:00Z", + time_basis_code: "document_time", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + observed_responsibilities: [], + responsibility_transition_code: null, + related_prior_paths: [], + }, + ], +}; + +afterEach(() => vi.unstubAllGlobals()); + +describe("ProjectHistoryDisclosure", () => { + it("loads the ABAC endpoint only after the buyer opens the project history", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(projection), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const onSearch = vi.fn(); + render( + , + ); + + expect(fetchMock).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Search related posts" })); + expect(onSearch).toHaveBeenCalledWith("P-100"); + fireEvent.click(screen.getByRole("button", { name: "Open project history" })); + + await screen.findByRole("heading", { name: "Project event timeline" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/project-history?"); + expect(String(url)).toContain("project_key=P-100"); + expect(String(url)).toContain("focus_post_id=post-voc"); + expect(String(url)).toContain("knowledge_cutoff=2026-08-01T00%3A00%3A00Z"); + expect(init.headers.Authorization).toBe("Bearer token-1"); + }); + + it("uses one non-leaking unavailable message for hidden, absent, and failed histories", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Open project history" })); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "Project history is unavailable for this evidence.", + ), + ); + expect(screen.queryByText(/hidden|forbidden|not found/i)).not.toBeInTheDocument(); + }); +}); From 51ea7718935b6e9073e2f1321d275c36136d73a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:49:02 +0900 Subject: [PATCH 62/63] chore: remove self-modifying project history repair --- .../scripts/fix_281_tepp_project_history.py | 261 ------------------ .../repair-281-tepp-project-history.yml | 124 --------- uv.lock | 2 +- 3 files changed, 1 insertion(+), 386 deletions(-) delete mode 100644 .github/scripts/fix_281_tepp_project_history.py delete mode 100644 .github/workflows/repair-281-tepp-project-history.yml diff --git a/.github/scripts/fix_281_tepp_project_history.py b/.github/scripts/fix_281_tepp_project_history.py deleted file mode 100644 index 113b40f5d..000000000 --- a/.github/scripts/fix_281_tepp_project_history.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Apply the bounded TEPP project-history buyer-surface integration for PR 281.""" - -from __future__ import annotations - -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one exact anchor or accept an already-applied replacement.""" - target = Path(path) - text = target.read_text(encoding="utf-8") - if new in text: - return - if text.count(old) != 1: - raise SystemExit(f"{path}: expected one integration anchor, found {text.count(old)}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def patch_api() -> None: - """Add the public project-history response types and GET client.""" - types_anchor = """export interface IssueTicket { -""" - types = """export interface TeppProjectHistoryEvent { - event_id: string; - event_type_code: string; - event_title: string; - occurred_at: string; - available_at: string; - availability_basis_code: string; - source_post_id: string; - evidence_text: string; - actor_ids: string[]; -} - -export interface TeppProjectHistoryFinding { - finding_code: string; - summary: string; - related_event_ids: string[]; - evidence_post_ids: string[]; -} - -export interface TeppProjectHistoryProjection { - contract_version: 1; - project_key: string; - project_name: string; - focus_event_id: string; - history_span_start: string; - history_span_end: string; - participant_count: number; - inference_status: "temporal_association_only"; - events: TeppProjectHistoryEvent[]; - findings: TeppProjectHistoryFinding[]; -} - -export interface IssueTicket { -""" - replace_once("frontend/src/api.ts", types_anchor, types) - - function_anchor = """export function fetchPostContent(accessToken: string, postId: string): Promise { -""" - function = """export function fetchTeppProjectHistory( - accessToken: string, - postId: string, - knowledgeCutoff?: string, -): Promise { - const query = knowledgeCutoff - ? `?knowledge_cutoff=${encodeURIComponent(knowledgeCutoff)}` - : ""; - return backendFetch( - `/api/posts/${postId}/project-history${query}`, - accessToken, - ); -} - -export function fetchPostContent(accessToken: string, postId: string): Promise { -""" - replace_once("frontend/src/api.ts", function_anchor, function) - - -def patch_app() -> None: - """Place the shared timeline on document, post-Ask, and Global Ask surfaces.""" - import_anchor = 'import { CutoffKnownBody } from "./components/CutoffKnownBody";\n' - import_line = ( - 'import { CutoffKnownBody } from "./components/CutoffKnownBody";\n' - 'import { TeppProjectHistoryPanel } from "./components/TeppProjectHistory";\n' - ) - replace_once("frontend/src/App.tsx", import_anchor, import_line) - - post_anchor = """ - {(post.source_stage_code || -""" - post_panel = """ - onSelectPost?.(evidencePostId)} - /> - {(post.source_stage_code || -""" - replace_once("frontend/src/App.tsx", post_anchor, post_panel) - - ask_state_anchor = """ const [asking, setAsking] = useState(false); - - async function handleAsk() { -""" - ask_state = """ const [asking, setAsking] = useState(false); - const timelinePostId = - answer?.cited_posts?.[0]?.post_id ?? answer?.cited_post_ids[0] ?? null; - - async function handleAsk() { -""" - replace_once("frontend/src/App.tsx", ask_state_anchor, ask_state) - - ask_answer_anchor = """ {answer.next_action ?

      {t(answer.next_action)}

      : null} - {answer.cited_posts && answer.cited_posts.length > 0 && ( -""" - ask_answer = """ {answer.next_action ?

      {t(answer.next_action)}

      : null} - {timelinePostId ? ( - - ) : null} - {answer.cited_posts && answer.cited_posts.length > 0 && ( -""" - replace_once("frontend/src/App.tsx", ask_answer_anchor, ask_answer) - - -def patch_backend() -> None: - """Expose an authorized, no-local-substitute TEPP project-history endpoint.""" - replace_once( - "backend/app/main.py", - "from datetime import datetime\n", - "from datetime import datetime, timezone\n", - ) - - client_import_anchor = "from lineageweave.http_client import HttpClientError\n" - client_imports = """from lineageweave.http_client import HttpClientError -from lineageweave.tepp_project_history import ( - PROJECT_HISTORY_PATH, - TeppProjectHistoryNotAvailable, - configured_tepp_project_history_client, -) -""" - replace_once("backend/app/main.py", client_import_anchor, client_imports) - - builder_import_anchor = "from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL\n" - builder_imports = """from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL -from backend.app.tepp_project_history import ( - build_project_history_request, - fetch_project_history_rows, -) -""" - replace_once("backend/app/main.py", builder_import_anchor, builder_imports) - - helper_anchor = """def _post_evaluation_client(): -""" - helper = """def _tepp_project_history_client(): - \"\"\"Build the credential-free TEPP project-history channel or fail closed.\"\"\" - configured = load_settings().tepp_transport_url.strip() - if configured.endswith("/v1/analysis-runs"): - configured = configured[: -len("/v1/analysis-runs")] + PROJECT_HISTORY_PATH - elif configured and not configured.endswith(PROJECT_HISTORY_PATH): - configured = "" - return configured_tepp_project_history_client(configured) - - -def _post_evaluation_client(): -""" - replace_once("backend/app/main.py", helper_anchor, helper) - - endpoint_anchor = """@app.get("/api/posts/{post_id}/content") -async def read_post_content( -""" - endpoint = """@app.get("/api/posts/{post_id}/project-history") -async def read_tepp_project_history( - post_id: str, - knowledge_cutoff: str | None = Query(None, max_length=64), - account: CurrentAccount = Depends(get_current_account), - pool: asyncpg.Pool = Depends(get_pool), -) -> dict[str, Any]: - \"\"\"Return TEPP's cutoff-safe project timeline for one visible post. - - LineageWeave selects only authorized source evidence. TEPP owns temporal - validation and coded associations. Missing TEPP returns 503; this endpoint - never fabricates a local psychometric or causal substitute. - \"\"\" - _require_post_read(account) - if knowledge_cutoff is None: - cutoff = datetime.now(timezone.utc) - else: - try: - cutoff = parse_as_of_clock(knowledge_cutoff) - except ValueError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - "knowledge_cutoff must be an ISO-8601 timestamp.", - ) from exc - async with pool.acquire() as conn: - focus = await conn.fetchrow( - "select post_id, visibility_code, corporate_entity_id " - f"from source_post where post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", - post_id, - ) - if focus is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "post not found") - if not _can_see_post(account, focus): - raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post") - rows = await fetch_project_history_rows( - conn, - focus_post_id=post_id, - knowledge_cutoff=cutoff, - can_see=lambda row: _can_see_post(account, row), - ) - if not rows: - raise HTTPException(status.HTTP_404_NOT_FOUND, "project history evidence not found") - try: - request = build_project_history_request( - rows, - focus_post_id=post_id, - tenant_workspace_id=str(focus["corporate_entity_id"]), - knowledge_cutoff=cutoff, - ) - except ValueError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - "visible project evidence does not satisfy the TEPP request contract.", - ) from exc - client = _tepp_project_history_client() - if not client.available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "TEPP project history is not configured; no local substitute was invented.", - ) - try: - projection = await asyncio.to_thread(client.project, request) - except (TeppProjectHistoryNotAvailable, ValueError) as exc: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "TEPP project history is unavailable or failed contract validation.", - ) from exc - return projection.to_json() - - -@app.get("/api/posts/{post_id}/content") -async def read_post_content( -""" - replace_once("backend/app/main.py", endpoint_anchor, endpoint) - - -def main() -> None: - """Apply the exact bounded integration.""" - patch_api() - patch_app() - patch_backend() - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/repair-281-tepp-project-history.yml b/.github/workflows/repair-281-tepp-project-history.yml deleted file mode 100644 index 5cbb3597e..000000000 --- a/.github/workflows/repair-281-tepp-project-history.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: Repair PR 281 project history - -on: - push: - branches: [feat/tepp-project-history-buyer-surface-v2180] - pull_request: - branches: [feat/event-lineage-node-keeps-gnb-focus-v2170] - types: [synchronize, edited] - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: repair-pr-281-project-history - cancel-in-progress: true - -jobs: - integrate-and-verify: - if: >- - github.ref == 'refs/heads/feat/tepp-project-history-buyer-surface-v2180' || - (github.event_name == 'pull_request' && github.event.pull_request.number == 281 && github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ubuntu-latest - timeout-minutes: 60 - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - REPAIR_BRANCH: feat/tepp-project-history-buyer-surface-v2180 - REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - steps: - - name: Checkout the exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: ${{ env.REPAIR_BASE_SHA }} - persist-credentials: true - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Install committed Python lock - run: uv sync --frozen --extra dev --extra backend - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install frontend dependencies - working-directory: frontend - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Apply the bounded endpoint and buyer-surface integration - run: | - python -m py_compile .github/scripts/fix_281_tepp_project_history.py - python .github/scripts/fix_281_tepp_project_history.py - git diff --check - - - name: Verify project-history contracts against PostgreSQL - run: | - uv run --frozen python -m pytest -q \ - tests/test_project_history_projection.py \ - tests/test_project_history_repository.py \ - tests/test_project_history_migration.py \ - tests/test_project_history_postgres.py \ - tests/test_tepp_project_history.py - uv run --frozen python -m compileall -q lineageweave backend/app - uv run --frozen python -m pytest -q - - - name: Verify accessible buyer surfaces - working-directory: frontend - run: | - pnpm exec vitest run \ - src/components/TeppProjectHistory.test.tsx \ - src/App.test.tsx \ - src/i18n.test.ts - pnpm run lint - pnpm run build - pnpm run build-storybook - - - name: Commit only the exact-head validated integration - shell: bash - run: | - rm -f .github/workflows/repair-281-tepp-project-history.yml - rm -f .github/scripts/fix_281_tepp_project_history.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add backend/app/main.py frontend/src/api.ts frontend/src/App.tsx - git add -u .github/workflows .github/scripts - git diff --cached --check - git commit -m "feat: connect project histories to Buyer Ask surfaces" - test -z "$(git status --porcelain)" || { - echo 'repair left uncommitted or untracked files' >&2 - git status --short - exit 1 - } - git fetch origin "${REPAIR_BRANCH}" - remote_head="$(git rev-parse "origin/${REPAIR_BRANCH}")" - if [ "$remote_head" != "$REPAIR_BASE_SHA" ]; then - echo "PR head moved from ${REPAIR_BASE_SHA} to ${remote_head}; refusing to publish an unverified integration." >&2 - exit 1 - fi - git push origin "HEAD:${REPAIR_BRANCH}" 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 669dfdf9b3b5cec8f00b6ac8035d8bf4c3949fb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:22:41 +0900 Subject: [PATCH 63/63] fix: use canonical popup focus contract --- frontend/src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 740a118d4..52dcf6630 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4200,7 +4200,6 @@ function PostList({ openedFromCustomerMaster || openedFromAskAgent } - focusAskOnLand={openedFromReportMember} focusKeyman={ openedFromWeeklyVoc || openedFromCalendar ||