From 73d037e53787ca50f566feece830bc77eeceb6bf Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 01:25:22 +0900 Subject: [PATCH 1/2] feat(dashboard): consume topic influence evidence --- CHANGELOG.md | 7 + backend/app/operations_dashboard.py | 272 +++++++++++++++++- ...poral-topic-context-influence-dashboard.md | 11 +- docs/product-technical-gap-baseline.md | 10 +- docs/storybook-inventory.md | 2 +- frontend/src/App.css | 108 +++++++ frontend/src/api.ts | 61 ++++ .../OperationsDashboard.stories.tsx | 61 ++++ .../components/OperationsDashboard.test.tsx | 58 +++- .../src/components/OperationsDashboard.tsx | 93 ++++++ ...212_topic_context_influence_projection.sql | 208 ++++++++++++++ tests/test_operations_dashboard.py | 111 ++++++- tests/test_schema.py | 150 ++++++++++ 13 files changed, 1143 insertions(+), 9 deletions(-) create mode 100644 migrations/0212_topic_context_influence_projection.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf0eb0f8..8fe0fe635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to this project are documented here. Format follows ### Added +- ADR 0210's Dashboard consumer now persists a normalized, exact-provenance + projection for TEPP temporal topics and fast-mlsirm case-deletion model + influence. The API authorizes the fitted analysis scope before returning + rows; the UI preserves ties, multiple membership, uncertainty, time states, + and source links, and otherwise names the missing producer contract without + calculating a local score. + - Event Lineage now persists each reconstructed connection's independent channel scores, the normalized weights actually used, and their contributions. The Event Lineage DAG discloses those exact values as inferred diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index af610cf43..12561feaf 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date +import json from typing import Any, Protocol from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL @@ -32,11 +33,11 @@ class _Connection(Protocol): async def fetchrow(self, query: str, *args: object) -> Any: """Fetch one projected row.""" - pass + pass # pragma: no cover - structural Protocol member async def fetch(self, query: str, *args: object) -> list[Any]: """Fetch projected rows.""" - pass + pass # pragma: no cover - structural Protocol member def _visible_period_sql(alias: str = "post") -> str: @@ -158,6 +159,7 @@ async def fetch_operations_dashboard( """, *args, ) + topic_context = await _fetch_topic_context_dashboard(conn, visible, args) facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in fact_rows: key = (str(row["post_id"]), row["case_kind_code"]) @@ -204,6 +206,7 @@ async def fetch_operations_dashboard( } for kind, label in CASE_KIND_LABELS.items() ], + "topic_context": topic_context, "cases": [ { "post_id": str(row["post_id"]), @@ -223,6 +226,271 @@ async def fetch_operations_dashboard( } +async def _fetch_topic_context_dashboard( + conn: _Connection, + visible_post_sql: str, + args: tuple[object, ...], +) -> dict[str, Any]: + """Project exact accepted producer rows or an actionable unavailable state.""" + authorized_model_scope = """ + ((scope.scope_kind_code = 'analysis_scope_corporate_entity' + and scope.corporate_entity_id::text = any($1::text[]) + and cardinality($2::text[]) = 0) + or + (scope.scope_kind_code = 'analysis_scope_process_unit' + and scope.process_unit_id::text = any($2::text[]))) + """ + readiness = await conn.fetchrow( + f""" + with visible_post as ( + select post.post_id + from source_post post + where {visible_post_sql} + ) + select exists ( + select 1 + from topic_context_membership membership + join topic_model_run model + on model.topic_model_run_id = membership.topic_model_run_id + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id + join visible_post on visible_post.post_id = membership.source_post_id + where {authorized_model_scope} + ) as tepp_posterior_persisted, + exists ( + select 1 + from topic_post_context_influence influence + join topic_context_membership membership + on membership.topic_model_run_id = influence.topic_model_run_id + and membership.topic_context_membership_id = influence.topic_context_membership_id + join topic_model_run model + on model.topic_model_run_id = influence.topic_model_run_id + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id + join visible_post on visible_post.post_id = membership.source_post_id + where {authorized_model_scope} + ) as fast_mlsirm_influence_persisted + """, + *args, + ) + rows = await conn.fetch( + f""" + with visible_post as ( + select post.post_id, + coalesce(post.event_occurred_at, post.created_at) as occurred_at + from source_post post + where {visible_post_sql} + ), eligible as ( + select model.topic_model_run_id, model.tepp_run_id, model.tepp_snapshot_id, + model.tepp_schema_version, model.tepp_model_contract_version, + model.tepp_artifact_sha256, model.posterior_draw_set_id, + model.posterior_draw_count, model.topic_count, + snapshot.snapshot_sha256 as source_snapshot_sha256, + analysis.knowledge_cutoff, + influence_run.topic_influence_run_id, + influence_run.fast_mlsirm_schema_version, + influence_run.fast_mlsirm_version, + influence_run.fast_mlsirm_code_revision, + influence_run.fast_mlsirm_artifact_sha256, + influence_run.compute_backend_code, + influence_run.precision_code, + influence_run.membership_fingerprint_sha256, + influence.topic_index, activity.state_code, + activity.valid_from as activity_valid_from, + activity.valid_to as activity_valid_to, + membership.dimension_code, membership.context_id, + context.context_label, membership.membership_weight, + membership.evidence_sha256 as membership_evidence_sha256, + membership.source_post_id, visible_post.occurred_at, + influence.influence_value, + influence.uncertainty_method_code, + influence.uncertainty_lower_value, + influence.uncertainty_upper_value, + influence.diagnostic_status_code, + influence_run.accepted_at + from topic_post_context_influence influence + join topic_influence_run influence_run + on influence_run.topic_model_run_id = influence.topic_model_run_id + and influence_run.topic_influence_run_id = influence.topic_influence_run_id + join topic_model_run model + on model.topic_model_run_id = influence.topic_model_run_id + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = analysis.analysis_source_snapshot_id + join topic_context_membership membership + on membership.topic_model_run_id = influence.topic_model_run_id + and membership.topic_context_membership_id = influence.topic_context_membership_id + join topic_context_definition context + on context.topic_model_run_id = membership.topic_model_run_id + and context.dimension_code = membership.dimension_code + and context.context_id = membership.context_id + join visible_post on visible_post.post_id = membership.source_post_id + join topic_activity_interval activity + on activity.topic_model_run_id = influence.topic_model_run_id + and activity.topic_index = influence.topic_index + and visible_post.occurred_at >= activity.valid_from + and visible_post.occurred_at < activity.valid_to + where visible_post.occurred_at >= membership.valid_from + and visible_post.occurred_at < membership.valid_to + and {authorized_model_scope} + ), selected as ( + select topic_model_run_id, topic_influence_run_id + from eligible + order by accepted_at desc, topic_model_run_id, topic_influence_run_id + limit 1 + ) + select eligible.*, + coalesce(( + select jsonb_agg(jsonb_build_object( + 'event_code', relation.event_code, + 'source_topic_index', relation.source_topic_index, + 'target_topic_index', relation.target_topic_index, + 'event_time', relation.event_time, + 'evidence_sha256', relation.evidence_sha256 + ) order by relation.event_time, relation.relation_ordinal) + from topic_lineage_relation relation + where relation.topic_model_run_id = eligible.topic_model_run_id + and (relation.source_topic_index = eligible.topic_index + or relation.target_topic_index = eligible.topic_index) + ), '[]'::jsonb) as lineage_events + from eligible + join selected using (topic_model_run_id, topic_influence_run_id) + order by eligible.topic_index, + case eligible.dimension_code + when 'business_unit' then 0 + when 'process_unit' then 1 + when 'team' then 2 + else 3 + end, + eligible.context_label, + eligible.influence_value desc, + eligible.occurred_at, + eligible.source_post_id + """, + *args, + ) + if not rows: + tepp_ready = bool(readiness and readiness["tepp_posterior_persisted"]) + return { + "status_code": "unavailable", + "reason_code": ( + "fast_mlsirm_influence_not_persisted" + if tepp_ready + else "tepp_topic_posterior_not_persisted" + ), + "next_action": ( + "동일 TEPP run·snapshot·cutoff에 결합된 fast-mlsirm 결과를 완료하세요." + if tepp_ready + else "TEPP posterior topic 계약 결과를 먼저 완료하세요." + ), + "required_contracts": [ + { + "authority": "TEPP", + "schema_version": "tepp.topic_context_posterior.v1", + "state_code": "persisted" if tepp_ready else "not_persisted", + }, + { + "authority": "fast-mlsirm", + "schema_version": "fast_mlsirm.topic_context_influence.v1", + "state_code": ( + "persisted" + if readiness and readiness["fast_mlsirm_influence_persisted"] + else "not_persisted" + ), + }, + ], + "model_run": None, + "topics": [], + } + + first = rows[0] + topics: dict[int, dict[str, Any]] = {} + for row in rows: + topic_index = int(row["topic_index"]) + raw_lineage_events = row["lineage_events"] + lineage_events = ( + json.loads(raw_lineage_events) + if isinstance(raw_lineage_events, str) + else list(raw_lineage_events) + ) + topic = topics.setdefault( + topic_index, + { + "topic_index": topic_index, + "activity_intervals": [], + "lineage_events": lineage_events, + "contexts": [], + }, + ) + interval = { + "state_code": row["state_code"], + "valid_from": row["activity_valid_from"].isoformat(), + "valid_to": row["activity_valid_to"].isoformat(), + } + if interval not in topic["activity_intervals"]: + topic["activity_intervals"].append(interval) + context_key = (row["dimension_code"], row["context_id"]) + context = next( + ( + item + for item in topic["contexts"] + if (item["dimension_code"], item["context_id"]) == context_key + ), + None, + ) + if context is None: + context = { + "dimension_code": row["dimension_code"], + "context_id": row["context_id"], + "context_label": row["context_label"], + "influences": [], + } + topic["contexts"].append(context) + context["influences"].append( + { + "post_id": str(row["source_post_id"]), + "occurred_at": row["occurred_at"].isoformat(), + "topic_state_code": row["state_code"], + "model_influence": float(row["influence_value"]), + "uncertainty_method_code": row["uncertainty_method_code"], + "uncertainty_lower_value": float(row["uncertainty_lower_value"]), + "uncertainty_upper_value": float(row["uncertainty_upper_value"]), + "diagnostic_status_code": row["diagnostic_status_code"], + "membership_weight": float(row["membership_weight"]), + "membership_evidence_sha256": row["membership_evidence_sha256"], + } + ) + + return { + "status_code": "accepted", + "reason_code": None, + "next_action": "Topic과 조직 수준을 선택해 model influence와 근거 글을 확인하세요.", + "required_contracts": [ + {"authority": "TEPP", "schema_version": first["tepp_schema_version"], "state_code": "persisted"}, + {"authority": "fast-mlsirm", "schema_version": first["fast_mlsirm_schema_version"], "state_code": "persisted"}, + ], + "model_run": { + "tepp_run_id": first["tepp_run_id"], + "tepp_snapshot_id": first["tepp_snapshot_id"], + "source_snapshot_sha256": first["source_snapshot_sha256"], + "knowledge_cutoff": first["knowledge_cutoff"].isoformat(), + "tepp_model_contract_version": first["tepp_model_contract_version"], + "tepp_artifact_sha256": first["tepp_artifact_sha256"], + "posterior_draw_set_id": first["posterior_draw_set_id"], + "posterior_draw_count": int(first["posterior_draw_count"]), + "topic_count": int(first["topic_count"]), + "fast_mlsirm_version": first["fast_mlsirm_version"], + "fast_mlsirm_code_revision": first["fast_mlsirm_code_revision"], + "fast_mlsirm_artifact_sha256": first["fast_mlsirm_artifact_sha256"], + "compute_backend_code": first["compute_backend_code"], + "precision_code": first["precision_code"], + "membership_fingerprint_sha256": first["membership_fingerprint_sha256"], + }, + "topics": list(topics.values()), + } + + def _period_label(period_start: date | None, period_end: date | None) -> str: """Format the exact event-time interval represented by the projection.""" if period_start and period_end: diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md index e9be55d48..0cfdbe1d8 100644 --- a/docs/adr/0210-temporal-topic-context-influence-dashboard.md +++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md @@ -1,7 +1,7 @@ # ADR 0210: TEPP temporal topics and fast-mlsirm context influence - Status: Accepted -- Implementation maturity: producer-contract required; consumer projection not yet shipped +- Implementation maturity: consumer projection candidate; accepted producer result unavailable - Date: 2026-08-25 - Depends on: ADR 0132 (TEPP topic-lineage boundary), ADR 0206 (operations Dashboard) - Upstream authorities: TEPP ADR 0012; fast-mlsirm ADR 0002 and ADR 0007 @@ -116,7 +116,7 @@ another dimension. ### LineageWeave consumer and persistence -Use normalized objects such as `topic_model_run`, `topic_definition`, +Use normalized objects `topic_model_run`, `topic_definition`, `topic_activity_interval`, `topic_lineage_relation`, `topic_post_coordinate`, `topic_context_membership`, `topic_influence_run`, and `topic_post_context_influence`. Large result tables are partitioned by tenant @@ -129,6 +129,13 @@ renormalizing scores. The frontend renders an exact-value table alongside the temporal topic view, uses text/pattern as well as color for topic state, and supports keyboard, touch, reduced motion, narrow viewports, and screen readers. +The LineageWeave consumer projection is allowed to land before activation. In +that state, it reports which exact producer contract is not persisted and +returns no topic, influence, rank, or fallback value. An accepted result is +readable only when its analysis-run scope is wholly authorized for the caller; +filtering individual result rows after a broader fit is insufficient because +the fitted value would still include hidden observations. + ```mermaid sequenceDiagram participant Source as Authorized source snapshot diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f6cd35a4e..22b22ea86 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -16,7 +16,7 @@ | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | | TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet | -| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | Product/technical contract is protected on `main`; neither required Rust CPU/GPU producer envelope is shipped, so the Dashboard surface remains unavailable (ADR 0208: no local Python substitute) | +| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open, #248 exports Laplace moments explicitly short of plausible values, and fast-mlsirm #1395 closed unmerged without a result envelope; runtime therefore remains honestly unavailable with no local Python or fallback score. | ### Technical contract and flow @@ -60,6 +60,14 @@ build was inspected at 1440×1000 and 390×844 and exposes neither corpus-wide total/pending/failed counts nor a misleading corpus failure alert in that scoped destination. Screenshots remain local synthetic audit evidence and are not committed. +The stacked topic-context consumer adds `TopicInfluenceAccepted` and the +unavailable topic section in `EvidenceReady`. Synthetic screenshots were +inspected at 1440×1200 and 390×844. At 390px the page had zero document-level +horizontal overflow while each exact-value table retained its named, +keyboard-focusable 332px viewport over 784px of table content. The new source +actions measured 54px high; sampled heading, caption, and table-header contrast +was 20.15:1, 5.73:1, and 18.62:1. Authenticated runtime evidence remains +required before protected delivery can be claimed. Authenticated authorized-corpus acceptance remains separate and may return only aggregate, non-identifying evidence to this repository. diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 3559cf139..e500cb078 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | +| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover mobile, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 79aa1c852..cf34add79 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1350,12 +1350,120 @@ .dashboard-case-card dd { margin: 0; font-weight: 600; } .dashboard-case-card button { margin-top: auto; } +.dashboard-topic-context { + min-width: 0; + margin: 1.5rem 0; + border-top: 2px solid var(--color-dashboard-ink); + padding-top: 1rem; +} + +.dashboard-topic-context > header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; +} + +.dashboard-topic-context > header p:last-child { max-width: 44rem; } + +.dashboard-topic-unavailable { + margin-top: 1rem; + border-left: 0.25rem solid var(--color-dashboard-ink); + padding: 1rem; + background: var(--color-dashboard-surface); +} + +.dashboard-topic-unavailable ul { margin-bottom: 0; } + +.dashboard-topic-list { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.dashboard-topic { + min-width: 0; + border: 1px solid var(--color-border); + background: var(--color-background); +} + +.dashboard-topic > summary, +.dashboard-topic-provenance > summary { + min-height: 44px; + padding: 0.75rem 1rem; + color: var(--color-text-heading); + font-weight: 700; + cursor: pointer; +} + +.dashboard-topic > summary:focus-visible, +.dashboard-topic-provenance > summary:focus-visible, +.dashboard-topic-table-scroll:focus-visible { + outline: 3px solid var(--color-primary); + outline-offset: 2px; +} + +.dashboard-topic-timeline, +.dashboard-topic-lineage { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 0; + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border); + list-style: none; +} + +.dashboard-topic-context-group { + min-width: 0; + padding: 1rem; + border-top: 1px solid var(--color-border); +} + +.dashboard-topic-table-scroll { + overflow-x: auto; +} + +.dashboard-topic-table-scroll table { + width: 100%; + min-width: 56rem; + border-collapse: collapse; + text-align: left; +} + +.dashboard-topic-table-scroll caption { + padding-bottom: 0.5rem; + color: var(--color-text); + text-align: left; +} + +.dashboard-topic-table-scroll th, +.dashboard-topic-table-scroll td { + padding: 0.75rem; + border: 1px solid var(--color-border); + vertical-align: top; +} + +.dashboard-topic-table-scroll th { background: var(--color-dashboard-surface); } +.dashboard-topic-table-scroll code { overflow-wrap: anywhere; } + +.dashboard-topic-provenance { + margin-top: 1rem; + border: 1px solid var(--color-border); +} + +.dashboard-topic-provenance dl { margin: 0; padding: 0 1rem 1rem; } +.dashboard-topic-provenance dl div { display: grid; grid-template-columns: 10rem 1fr; gap: 1rem; padding: 0.5rem 0; border-top: 1px solid var(--color-border); } +.dashboard-topic-provenance dd { margin: 0; overflow-wrap: anywhere; } + @media (max-width: 900px) { .operations-dashboard { padding: 1rem; } .operations-dashboard-heading { align-items: start; flex-direction: column; } .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-grid { grid-template-columns: 1fr; } + .dashboard-topic-context > header { align-items: start; flex-direction: column; } + .dashboard-topic-provenance dl div { grid-template-columns: 1fr; gap: 0.25rem; } } @media (prefers-color-scheme: dark) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c63436b9f..0bf9b2587 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -77,9 +77,70 @@ export interface OperationsDashboardResponse { event_count: number; post_count: number; }>; + topic_context: TopicContextDashboard; cases: OperationsDashboardCase[]; } +export interface TopicContextDashboard { + status_code: "accepted" | "unavailable"; + reason_code: string | null; + next_action: string; + required_contracts: Array<{ + authority: "TEPP" | "fast-mlsirm"; + schema_version: string; + state_code: "persisted" | "not_persisted"; + }>; + model_run: null | { + tepp_run_id: string; + tepp_snapshot_id: string; + source_snapshot_sha256: string; + knowledge_cutoff: string; + tepp_model_contract_version: string; + tepp_artifact_sha256: string; + posterior_draw_set_id: string; + posterior_draw_count: number; + topic_count: number; + fast_mlsirm_version: string; + fast_mlsirm_code_revision: string; + fast_mlsirm_artifact_sha256: string; + compute_backend_code: "rust_cpu" | "rust_gpu"; + precision_code: "f64" | "f32"; + membership_fingerprint_sha256: string; + }; + topics: Array<{ + topic_index: number; + activity_intervals: Array<{ + state_code: "active" | "dormant" | "reactivated"; + valid_from: string; + valid_to: string; + }>; + lineage_events: Array<{ + event_code: "birth" | "split" | "merge" | "retirement"; + source_topic_index: number; + target_topic_index: number | null; + event_time: string; + evidence_sha256: string; + }>; + contexts: Array<{ + dimension_code: "business_unit" | "process_unit" | "team" | "person"; + context_id: string; + context_label: string; + influences: Array<{ + post_id: string; + occurred_at: string; + topic_state_code: "active" | "dormant" | "reactivated"; + model_influence: number; + uncertainty_method_code: string; + uncertainty_lower_value: number; + uncertainty_upper_value: number; + diagnostic_status_code: "accepted"; + membership_weight: number; + membership_evidence_sha256: string; + }>; + }>; + }>; +} + export function fetchOperationsDashboard( accessToken: string, periodStart = "", diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 560bbbe33..82850e304 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -18,6 +18,14 @@ export const EvidenceReady: Story = { { case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 }, { case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 }, ], + topic_context: { + status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", + next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [], + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "not_persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "not_persisted" }, + ], + }, failed_analysis_count: 0, cases: [ { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] }, @@ -36,6 +44,59 @@ export const EvidenceReady: Story = { }, }; +export const TopicInfluenceAccepted: Story = { + args: { + ...EvidenceReady.args, + data: { + ...EvidenceReady.args!.data!, + topic_context: { + status_code: "accepted", reason_code: null, + next_action: "Topic과 조직 수준을 선택해 model influence와 근거 글을 확인하세요.", + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "persisted" }, + ], + model_run: { + tepp_run_id: "synthetic-tepp-run", tepp_snapshot_id: "synthetic-tepp-snapshot", source_snapshot_sha256: "a".repeat(64), + knowledge_cutoff: "2026-08-20T00:00:00Z", tepp_model_contract_version: "trsl-tm-1", + tepp_artifact_sha256: "b".repeat(64), posterior_draw_set_id: "synthetic-draws", + posterior_draw_count: 32, topic_count: 2, fast_mlsirm_version: "0.1.0", + fast_mlsirm_code_revision: "c".repeat(40), fast_mlsirm_artifact_sha256: "d".repeat(64), + compute_backend_code: "rust_gpu", precision_code: "f64", membership_fingerprint_sha256: "e".repeat(64), + }, + topics: [{ + topic_index: 0, + activity_intervals: [ + { state_code: "dormant", valid_from: "2026-08-01T00:00:00Z", valid_to: "2026-08-10T00:00:00Z" }, + { state_code: "reactivated", valid_from: "2026-08-10T00:00:00Z", valid_to: "2026-09-01T00:00:00Z" }, + ], + lineage_events: [{ event_code: "birth", source_topic_index: 0, target_topic_index: null, event_time: "2026-08-01T00:00:00Z", evidence_sha256: "f".repeat(64) }], + contexts: [ + { + dimension_code: "business_unit", context_id: "bu-synthetic", context_label: "Synthetic Energy Division", + influences: [{ post_id: "synthetic-post-1", occurred_at: "2026-08-12T00:00:00Z", topic_state_code: "reactivated", model_influence: 4.25, uncertainty_method_code: "posterior_interval", uncertainty_lower_value: 3.5, uncertainty_upper_value: 5, diagnostic_status_code: "accepted", membership_weight: 0.6, membership_evidence_sha256: "1".repeat(64) }], + }, + { + dimension_code: "team", context_id: "team-synthetic", context_label: "Synthetic Service Team", + influences: [ + { post_id: "synthetic-post-1", occurred_at: "2026-08-12T00:00:00Z", topic_state_code: "reactivated", model_influence: 4.25, uncertainty_method_code: "posterior_interval", uncertainty_lower_value: 3.5, uncertainty_upper_value: 5, diagnostic_status_code: "accepted", membership_weight: 0.4, membership_evidence_sha256: "2".repeat(64) }, + { post_id: "synthetic-post-2", occurred_at: "2026-08-13T00:00:00Z", topic_state_code: "reactivated", model_influence: 4.25, uncertainty_method_code: "posterior_interval", uncertainty_lower_value: 3.4, uncertainty_upper_value: 5.1, diagnostic_status_code: "accepted", membership_weight: 1, membership_evidence_sha256: "3".repeat(64) }, + ], + }, + ], + }], + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("heading", { name: "시간 흐름별 Topic model influence" })).toBeVisible(); + await expect(canvas.getByText(/휴면 \/ 재활성/)).toBeVisible(); + await expect(canvas.getAllByText("4.25")).toHaveLength(3); + await expect(canvas.getByText(/순번이나 임의 가중치를 추가하지 않습니다/)).toBeVisible(); + }, +}; + export const NarrowViewport: Story = { ...EvidenceReady, parameters: { viewport: { defaultViewport: "mobile1" } } }; export const ExternalInformationEmpty: Story = { diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index a3947e2a2..f0c7979f6 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { fetchOperationsDashboard } from "../api"; +import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard"; vi.mock("../api", async (importOriginal) => ({ @@ -9,7 +9,7 @@ vi.mock("../api", async (importOriginal) => ({ fetchOperationsDashboard: vi.fn(), })); -const data = { +const data: OperationsDashboardResponse = { period_label: "2026-08-01–2026-08-25 · Event time", total_post_count: 20, total_event_count: 8, @@ -21,6 +21,17 @@ const data = { { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 }, { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 }, ], + topic_context: { + status_code: "unavailable", + reason_code: "tepp_topic_posterior_not_persisted", + next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "not_persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "not_persisted" }, + ], + model_run: null, + topics: [], + }, cases: [{ post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", @@ -70,6 +81,49 @@ describe("OperationsDashboardView", () => { expect(screen.queryByText("분석 대기 건부터 처리하세요")).not.toBeInTheDocument(); }); + it("keeps unavailable topic measurement actionable without a fallback score", () => { + render( undefined} />); + expect(screen.getByText("Topic model influence를 아직 표시할 수 없습니다.")).toBeInTheDocument(); + expect(screen.getByText("TEPP posterior topic 계약 결과를 먼저 완료하세요.")).toBeInTheDocument(); + expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument(); + }); + + it("opens accepted exact influence evidence and retains equal values", async () => { + const onOpenPost = vi.fn(); + const influence = { + post_id: "post-1", occurred_at: "2026-08-12T00:00:00Z", topic_state_code: "active" as const, + model_influence: 4.25, uncertainty_method_code: "posterior_interval", + uncertainty_lower_value: 3.5, uncertainty_upper_value: 5, + diagnostic_status_code: "accepted" as const, membership_weight: 0.5, + membership_evidence_sha256: "a".repeat(64), + }; + const accepted: OperationsDashboardResponse = { + ...data, + topic_context: { + status_code: "accepted", reason_code: null, next_action: "근거 글을 확인하세요.", + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "persisted" }, + ], + model_run: { + tepp_run_id: "tepp-run", tepp_snapshot_id: "tepp-snapshot", source_snapshot_sha256: "b".repeat(64), + knowledge_cutoff: "2026-08-20T00:00:00Z", tepp_model_contract_version: "trsl-tm-1", + tepp_artifact_sha256: "c".repeat(64), posterior_draw_set_id: "draws-1", posterior_draw_count: 32, + topic_count: 2, fast_mlsirm_version: "0.1.0", fast_mlsirm_code_revision: "d".repeat(40), + fast_mlsirm_artifact_sha256: "e".repeat(64), compute_backend_code: "rust_cpu", precision_code: "f64", + membership_fingerprint_sha256: "f".repeat(64), + }, + topics: [{ topic_index: 0, activity_intervals: [{ state_code: "active", valid_from: "2026-08-01T00:00:00Z", valid_to: "2026-09-01T00:00:00Z" }], lineage_events: [{ event_code: "birth", source_topic_index: 0, target_topic_index: null, event_time: "2026-08-01T00:00:00Z", evidence_sha256: "1".repeat(64) }], contexts: [{ dimension_code: "team", context_id: "team-1", context_label: "Synthetic Team", influences: [influence, { ...influence, post_id: "post-2" }] }] }], + }, + }; + render(); + expect(screen.getAllByText("4.25")).toHaveLength(2); + expect(screen.getByText((_, element) => element?.tagName === "LI" && element.textContent === "2026-08-01 · birth")).toBeInTheDocument(); + expect(screen.getByText("tepp-snapshot")).toBeInTheDocument(); + await userEvent.click(screen.getAllByRole("button", { name: "근거 글 열기" })[1]); + expect(onOpenPost).toHaveBeenCalledWith("post-2"); + }); + it("keeps period controls mounted while a changed period loads", async () => { vi.mocked(fetchOperationsDashboard) .mockResolvedValueOnce(data) diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index cd739c0d6..7095b728f 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -1,6 +1,19 @@ import { useEffect, useState } from "react"; import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; +const dimensionLabels = { + business_unit: "사업부", + process_unit: "PU", + team: "팀", + person: "개인", +} as const; + +const topicStateLabels = { + active: "활성", + dormant: "휴면", + reactivated: "재활성", +} as const; + type Props = { accessToken: string; externalOnly?: boolean; @@ -85,6 +98,9 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost ) : null} + {!externalOnly ? ( + + ) : null} {!externalOnly && journeys.length ? (

프로젝트 여정

@@ -129,3 +145,80 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
); } + +/** Renders persisted ADR-0210 producer evidence without calculating a local score. */ +export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDashboardResponse; onOpenPost: (postId: string) => void }) { + const topicContext = data.topic_context; + return ( +
+
+

TEPP · fast-mlsirm

시간 흐름별 Topic model influence

+

사업 가치가 아닌, 해당 글을 제외했을 때 Topic·조직 수준 모형이 변하는 정도입니다.

+
+ {topicContext.status_code === "unavailable" ? ( +
+ Topic model influence를 아직 표시할 수 없습니다. +

{topicContext.next_action}

+
    {topicContext.required_contracts.map((contract) => ( +
  • {contract.authority} · {contract.schema_version} · {contract.state_code === "persisted" ? "저장 완료" : "승인 결과 없음"}
  • + ))}
+
+ ) : ( + <> +

{topicContext.next_action}

+
+ {topicContext.topics.map((topic) => ( +
+ Topic {topic.topic_index + 1} · {topic.activity_intervals.map((interval) => topicStateLabels[interval.state_code]).join(" / ")} +
    + {topic.activity_intervals.map((interval) => ( +
  • + {topicStateLabels[interval.state_code]} +
  • + ))} +
+ {topic.lineage_events.length ?
    + {topic.lineage_events.map((event) =>
  • · {event.event_code}{event.target_topic_index === null ? "" : ` → Topic ${event.target_topic_index + 1}`}
  • )} +
: null} + {topic.contexts.map((context) => ( +
+

{dimensionLabels[context.dimension_code]} · {context.context_label}

+
+ + + + {context.influences.map((influence) => ( + + + + + + + + + ))} +
값이 같으면 동점이며, 순번이나 임의 가중치를 추가하지 않습니다.
Event 발생일상태Model influence불확실성소속 근거원문
{topicStateLabels[influence.topic_state_code]}{influence.model_influence}{influence.uncertainty_lower_value}–{influence.uncertainty_upper_value} · {influence.uncertainty_method_code}weight {influence.membership_weight} · {influence.membership_evidence_sha256}
+
+
+ ))} +
+ ))} +
+ {topicContext.model_run ? ( +
+ 모형·실행 근거 +
+
TEPP run
{topicContext.model_run.tepp_run_id}
+
TEPP snapshot
{topicContext.model_run.tepp_snapshot_id}
+
Snapshot
{topicContext.model_run.source_snapshot_sha256}
+
Knowledge cutoff
+
Posterior draws
{topicContext.model_run.posterior_draw_count} · {topicContext.model_run.posterior_draw_set_id}
+
fast-mlsirm
{topicContext.model_run.fast_mlsirm_version} · {topicContext.model_run.compute_backend_code} · {topicContext.model_run.precision_code}
+
+
+ ) : null} + + )} +
+ ); +} diff --git a/migrations/0212_topic_context_influence_projection.sql b/migrations/0212_topic_context_influence_projection.sql new file mode 100644 index 000000000..13daab657 --- /dev/null +++ b/migrations/0212_topic_context_influence_projection.sql @@ -0,0 +1,208 @@ +-- ADR 0210: normalized TEPP topic and fast-mlsirm influence projection. +-- LineageWeave stores accepted producer evidence; it performs no estimator math. + +create table if not exists topic_model_run ( + topic_model_run_id uuid primary key default uuid_generate_v4(), + analysis_run_id uuid not null unique references analysis_run (analysis_run_id), + tepp_run_id text not null unique check (length(btrim(tepp_run_id)) between 1 and 256), + tepp_snapshot_id text not null check (length(btrim(tepp_snapshot_id)) between 1 and 256), + tepp_schema_version text not null check (tepp_schema_version = 'tepp.topic_context_posterior.v1'), + tepp_model_contract_version text not null check (length(btrim(tepp_model_contract_version)) between 1 and 256), + tepp_artifact_sha256 text not null unique check (tepp_artifact_sha256 ~ '^[0-9a-f]{64}$'), + reported_source_snapshot_sha256 text not null check (reported_source_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + reported_knowledge_cutoff timestamptz not null, + posterior_draw_set_id text not null check (length(btrim(posterior_draw_set_id)) between 1 and 256), + posterior_draw_count integer not null check (posterior_draw_count > 0), + topic_count integer not null check (topic_count >= 2), + inference_status_code text not null check (inference_status_code = 'posterior_topic_coordinates_not_importance'), + accepted_at timestamptz not null default now() +); + +create table if not exists topic_definition ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + topic_index integer not null check (topic_index >= 0), + primary key (topic_model_run_id, topic_index) +); + +create table if not exists topic_activity_interval ( + topic_model_run_id uuid not null, + topic_index integer not null, + valid_from timestamptz not null, + valid_to timestamptz not null, + state_code text not null check (state_code in ('active', 'dormant', 'reactivated')), + primary key (topic_model_run_id, topic_index, valid_from), + foreign key (topic_model_run_id, topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade, + check (valid_from < valid_to) +); + +create table if not exists topic_lineage_relation ( + topic_model_run_id uuid not null, + relation_ordinal integer not null check (relation_ordinal >= 0), + event_code text not null check (event_code in ('birth', 'split', 'merge', 'retirement')), + source_topic_index integer not null, + target_topic_index integer, + event_time timestamptz not null, + evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'), + primary key (topic_model_run_id, relation_ordinal), + foreign key (topic_model_run_id, source_topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade, + foreign key (topic_model_run_id, target_topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade, + check ( + (event_code in ('split', 'merge') and target_topic_index is not null) + or (event_code in ('birth', 'retirement') and target_topic_index is null) + ) +); + +create table if not exists topic_context_definition ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + dimension_code text not null check (dimension_code in ('business_unit', 'process_unit', 'team', 'person')), + context_id text not null check (length(btrim(context_id)) between 1 and 256), + context_label text not null check (length(btrim(context_label)) between 1 and 512), + primary key (topic_model_run_id, dimension_code, context_id) +); + +create table if not exists topic_context_membership ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + topic_context_membership_id uuid not null default uuid_generate_v4(), + source_post_id uuid not null references source_post (post_id) on delete restrict, + dimension_code text not null check (dimension_code in ('business_unit', 'process_unit', 'team', 'person')), + context_id text not null check (length(btrim(context_id)) between 1 and 256), + membership_weight double precision not null check ( + membership_weight > 0 and membership_weight < 'Infinity'::double precision + ), + valid_from timestamptz not null, + valid_to timestamptz not null, + evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'), + primary key (topic_model_run_id, topic_context_membership_id), + unique (topic_model_run_id, source_post_id, dimension_code, context_id, valid_from), + foreign key (topic_model_run_id, dimension_code, context_id) + references topic_context_definition (topic_model_run_id, dimension_code, context_id) + on delete cascade, + check (valid_from < valid_to) +); + +create table if not exists topic_influence_run ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + topic_influence_run_id uuid not null default uuid_generate_v4(), + fast_mlsirm_schema_version text not null check (fast_mlsirm_schema_version = 'fast_mlsirm.topic_context_influence.v1'), + fast_mlsirm_version text not null check (length(btrim(fast_mlsirm_version)) between 1 and 128), + fast_mlsirm_code_revision text not null check (fast_mlsirm_code_revision ~ '^(?:[0-9a-f]{40}|[0-9a-f]{64})$'), + fast_mlsirm_artifact_sha256 text not null unique check (fast_mlsirm_artifact_sha256 ~ '^[0-9a-f]{64}$'), + reported_tepp_run_id text not null, + reported_snapshot_sha256 text not null check (reported_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + reported_knowledge_cutoff timestamptz not null, + membership_fingerprint_sha256 text not null check (membership_fingerprint_sha256 ~ '^[0-9a-f]{64}$'), + compute_backend_code text not null check (compute_backend_code in ('rust_cpu', 'rust_gpu')), + precision_code text not null check (precision_code in ('f64', 'f32')), + posterior_draw_coverage integer not null check (posterior_draw_coverage > 0), + convergence_status_code text not null check (convergence_status_code = 'converged'), + identification_status_code text not null check (identification_status_code = 'identified'), + parity_status_code text not null check (parity_status_code = 'passed'), + accepted_at timestamptz not null default now(), + primary key (topic_model_run_id, topic_influence_run_id) +); + +create table if not exists topic_post_context_influence ( + topic_model_run_id uuid not null, + topic_influence_run_id uuid not null, + topic_context_membership_id uuid not null, + topic_index integer not null, + influence_value double precision not null check ( + influence_value >= 0 and influence_value < 'Infinity'::double precision + ), + uncertainty_method_code text not null check (length(btrim(uncertainty_method_code)) between 1 and 128), + uncertainty_lower_value double precision not null check ( + uncertainty_lower_value >= 0 and uncertainty_lower_value < 'Infinity'::double precision + ), + uncertainty_upper_value double precision not null check ( + uncertainty_upper_value >= uncertainty_lower_value + and uncertainty_upper_value < 'Infinity'::double precision + ), + diagnostic_status_code text not null check (diagnostic_status_code = 'accepted'), + primary key ( + topic_model_run_id, + topic_influence_run_id, + topic_context_membership_id, + topic_index + ), + foreign key (topic_model_run_id, topic_influence_run_id) + references topic_influence_run (topic_model_run_id, topic_influence_run_id) on delete cascade, + foreign key (topic_model_run_id, topic_context_membership_id) + references topic_context_membership (topic_model_run_id, topic_context_membership_id) on delete cascade, + foreign key (topic_model_run_id, topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade +); + +create index if not exists topic_activity_interval_time_idx + on topic_activity_interval (valid_from, valid_to, topic_model_run_id, topic_index); +create index if not exists topic_context_membership_post_time_idx + on topic_context_membership (source_post_id, valid_from, valid_to, topic_model_run_id); +create index if not exists topic_post_context_influence_read_idx + on topic_post_context_influence (topic_model_run_id, topic_index, influence_value desc); + +create or replace function validate_topic_model_run_binding() +returns trigger +language plpgsql +as $$ +declare + canonical_snapshot_sha256 text; + canonical_knowledge_cutoff timestamptz; + canonical_run_kind_code text; +begin + select snapshot.snapshot_sha256, run.knowledge_cutoff, run.run_kind_code + into canonical_snapshot_sha256, canonical_knowledge_cutoff, canonical_run_kind_code + from analysis_run run + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where run.analysis_run_id = new.analysis_run_id; + + if canonical_run_kind_code is distinct from 'analysis_run_topic_lineage' + or new.reported_source_snapshot_sha256 is distinct from canonical_snapshot_sha256 + or new.reported_knowledge_cutoff is distinct from canonical_knowledge_cutoff then + raise exception 'topic_model_run_provenance_binding_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists topic_model_run_binding_check on topic_model_run; +create trigger topic_model_run_binding_check +before insert or update on topic_model_run +for each row execute function validate_topic_model_run_binding(); + +create or replace function validate_topic_influence_run_binding() +returns trigger +language plpgsql +as $$ +declare + canonical_tepp_run_id text; + canonical_snapshot_sha256 text; + canonical_knowledge_cutoff timestamptz; + canonical_draw_count integer; +begin + select model.tepp_run_id, snapshot.snapshot_sha256, run.knowledge_cutoff, + model.posterior_draw_count + into canonical_tepp_run_id, canonical_snapshot_sha256, + canonical_knowledge_cutoff, canonical_draw_count + from topic_model_run model + join analysis_run run on run.analysis_run_id = model.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where model.topic_model_run_id = new.topic_model_run_id; + + if new.reported_tepp_run_id is distinct from canonical_tepp_run_id + or new.reported_snapshot_sha256 is distinct from canonical_snapshot_sha256 + or new.reported_knowledge_cutoff is distinct from canonical_knowledge_cutoff + or new.posterior_draw_coverage is distinct from canonical_draw_count then + raise exception 'topic_influence_provenance_binding_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists topic_influence_run_binding_check on topic_influence_run; +create trigger topic_influence_run_binding_check +before insert or update on topic_influence_run +for each row execute function validate_topic_influence_run_binding(); diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index d3a6ae43e..b580d3080 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -15,6 +15,11 @@ def __init__(self) -> None: async def fetchrow(self, query: str, *args: object) -> dict[str, int]: self.queries.append((query, args)) + if "tepp_posterior_persisted" in query: + return { + "tepp_posterior_persisted": False, + "fast_mlsirm_influence_persisted": False, + } return { "total_post_count": 4, "total_event_count": 3, @@ -43,6 +48,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "case_kind_code": "claim_investigation", "fact_type_code": "sales_pool", }] + if "from topic_post_context_influence influence" in query: + return [] return [ { "post_id": "00000000-0000-0000-0000-000000000001", @@ -124,7 +131,9 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: ], } ] - assert len(conn.queries) == 4 + assert result["topic_context"]["status_code"] == "unavailable" + assert result["topic_context"]["reason_code"] == "tepp_topic_posterior_not_persisted" + assert len(conn.queries) == 6 for query, args in conn.queries: assert "visibility_code = 'public'" in query assert "corporate_entity_id::text = any($1::text[])" in query @@ -136,6 +145,99 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query +@pytest.mark.anyio +async def test_dashboard_projects_exact_topic_influence_without_local_scoring() -> None: + """Accepted rows retain ties, membership evidence, and producer identity.""" + + class TopicConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": True, + "fast_mlsirm_influence_persisted": True, + } + return await super().fetchrow(query, *args) + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + if "from topic_post_context_influence influence" not in query: + return await super().fetch(query, *args) + self.queries.append((query, args)) + common = { + "topic_model_run_id": "model-1", + "tepp_run_id": "tepp-1", + "tepp_snapshot_id": "tepp-snapshot-1", + "tepp_schema_version": "tepp.topic_context_posterior.v1", + "tepp_model_contract_version": "trsl-tm-1", + "tepp_artifact_sha256": "a" * 64, + "posterior_draw_set_id": "draws-1", + "posterior_draw_count": 32, + "topic_count": 2, + "source_snapshot_sha256": "b" * 64, + "knowledge_cutoff": datetime(2026, 8, 20, tzinfo=timezone.utc), + "topic_influence_run_id": "influence-1", + "fast_mlsirm_schema_version": "fast_mlsirm.topic_context_influence.v1", + "fast_mlsirm_version": "0.1.0", + "fast_mlsirm_code_revision": "c" * 40, + "fast_mlsirm_artifact_sha256": "d" * 64, + "compute_backend_code": "rust_gpu", + "precision_code": "f64", + "membership_fingerprint_sha256": "e" * 64, + "topic_index": 0, + "state_code": "reactivated", + "activity_valid_from": datetime(2026, 8, 1, tzinfo=timezone.utc), + "activity_valid_to": datetime(2026, 9, 1, tzinfo=timezone.utc), + "dimension_code": "team", + "context_id": "team-synthetic", + "context_label": "Synthetic Service Team", + "membership_weight": 0.5, + "membership_evidence_sha256": "f" * 64, + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + "influence_value": 4.25, + "uncertainty_method_code": "posterior_interval", + "uncertainty_lower_value": 3.5, + "uncertainty_upper_value": 5.0, + "diagnostic_status_code": "accepted", + "lineage_events": '[{"event_code":"birth","source_topic_index":0,"target_topic_index":null,"event_time":"2026-08-01T00:00:00+00:00","evidence_sha256":"' + "1" * 64 + '"}]', + } + return [ + {**common, "source_post_id": "00000000-0000-0000-0000-000000000001"}, + { + **common, + "source_post_id": "00000000-0000-0000-0000-000000000002", + "lineage_events": [{"event_code": "birth"}], + }, + ] + + result = await fetch_operations_dashboard(TopicConnection(), []) + topic_context = result["topic_context"] + assert topic_context["status_code"] == "accepted" + assert topic_context["model_run"]["compute_backend_code"] == "rust_gpu" + influences = topic_context["topics"][0]["contexts"][0]["influences"] + assert [item["model_influence"] for item in influences] == [4.25, 4.25] + assert influences[0]["membership_weight"] == 0.5 + assert topic_context["topics"][0]["lineage_events"][0]["event_code"] == "birth" + + +@pytest.mark.anyio +async def test_dashboard_names_missing_fast_result_after_tepp_persistence() -> None: + """A persisted TEPP membership never becomes a fabricated influence value.""" + + class TeppOnlyConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": True, + "fast_mlsirm_influence_persisted": False, + } + return await super().fetchrow(query, *args) + + result = await fetch_operations_dashboard(TeppOnlyConnection(), []) + assert result["topic_context"]["reason_code"] == "fast_mlsirm_influence_not_persisted" + assert result["topic_context"]["topics"] == [] + + @pytest.mark.anyio async def test_dashboard_zero_denominator_and_invalid_period() -> None: """An empty corpus has 0%, while an inverted interval fails closed.""" @@ -143,6 +245,11 @@ async def test_dashboard_zero_denominator_and_invalid_period() -> None: class EmptyConnection(_Connection): async def fetchrow(self, query: str, *args: object) -> dict[str, int]: self.queries.append((query, args)) + if "tepp_posterior_persisted" in query: + return { + "tepp_posterior_persisted": False, + "fast_mlsirm_influence_persisted": False, + } return dict.fromkeys( ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count", "failed_analysis_count"), 0, @@ -155,6 +262,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: empty = await fetch_operations_dashboard(EmptyConnection(), []) assert empty["external_percent"] == 0.0 assert all(metric["event_count"] == metric["post_count"] == 0 for metric in empty["case_metrics"]) + assert (await fetch_operations_dashboard(EmptyConnection(), [], [], date(2026, 8, 1)))["period_label"] == "2026-08-01 이후 · Event 발생일" + assert (await fetch_operations_dashboard(EmptyConnection(), [], [], None, date(2026, 8, 31)))["period_label"] == "2026-08-31 이전 · Event 발생일" with pytest.raises(ValueError, match="period_start"): await fetch_operations_dashboard( EmptyConnection(), [], [], date(2026, 9, 1), date(2026, 8, 31) diff --git a/tests/test_schema.py b/tests/test_schema.py index ab0a2818c..1623dd344 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -95,6 +95,17 @@ _OPERATIONS_CASE_MISSING_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" ) +_ANALYSIS_RUN_REGISTRY_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql" +) +_TOPIC_CONTEXT_INFLUENCE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0212_topic_context_influence_projection.sql" +) +_TOPIC_LINEAGE_KIND_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0131_analysis_run_topic_lineage_kind.sql" +) def _postgres_available() -> bool: @@ -127,6 +138,8 @@ def schema_db(): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_ANALYSIS_RUN_REGISTRY_MIGRATION.read_text()) + cur.execute(_TOPIC_LINEAGE_KIND_MIGRATION.read_text()) cur.execute(_PROJECT_MENTION_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) @@ -143,6 +156,7 @@ def schema_db(): cur.execute(_OPERATIONS_CASE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) + cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -201,10 +215,146 @@ def test_migration_applies_cleanly(schema_db) -> None: "operations_case_classification", "operations_case_fact", "operations_case_missing_fact", + "topic_model_run", + "topic_definition", + "topic_activity_interval", + "topic_lineage_relation", + "topic_context_definition", + "topic_context_membership", + "topic_influence_run", + "topic_post_context_influence", } assert expected <= tables +def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> None: + """Accepted influence runs cannot cross a TEPP run, snapshot, or cutoff.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select tgname + from pg_trigger + where tgname = 'topic_influence_run_binding_check' + and not tgisinternal + """ + ) + assert cur.fetchone() == ("topic_influence_run_binding_check",) + cur.execute( + """ + select tgname + from pg_trigger + where tgname = 'topic_model_run_binding_check' + and not tgisinternal + """ + ) + assert cur.fetchone() == ("topic_model_run_binding_check",) + cur.execute( + """ + select conname + from pg_constraint + where conrelid = 'topic_post_context_influence'::regclass + and contype = 'f' + """ + ) + foreign_keys = {row[0] for row in cur.fetchall()} + assert len(foreign_keys) == 3 + + account_id = uuid.uuid4() + snapshot_id = uuid.uuid4() + run_id = uuid.uuid4() + with schema_db.cursor() as cur: + cur.execute( + "insert into user_account (user_account_id, external_subject_id, display_name, email_address) values (%s, %s, %s, %s)", + (str(account_id), f"synthetic-{account_id}", "Synthetic Reviewer", f"synthetic-{account_id}@example.invalid"), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (analysis_source_snapshot_id, snapshot_sha256, source_contract_version, + maximum_available_time, captured_at, created_at) + values (%s, %s, 'synthetic-v1', '2026-08-01T00:00:00Z', + '2026-08-02T00:00:00Z', '2026-08-03T00:00:00Z') + """, + (str(snapshot_id), "a" * 64), + ) + cur.execute( + """ + insert into analysis_run + (analysis_run_id, analysis_source_snapshot_id, run_kind_code, + requested_by_account_id, idempotency_key, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, %s, 'analysis_run_topic_lineage', %s, 'synthetic-topic-run', + '2026-08-01T00:00:00Z', 'synthetic-v1', %s, %s, + '2026-08-04T00:00:00Z') + """, + (str(run_id), str(snapshot_id), str(account_id), "b" * 64, "c" * 40), + ) + cur.execute("savepoint topic_model_mismatch") + with pytest.raises(psycopg2.errors.RaiseException, match="topic_model_run_provenance_binding_mismatch"): + cur.execute( + """ + insert into topic_model_run + (analysis_run_id, tepp_run_id, tepp_snapshot_id, + tepp_schema_version, tepp_model_contract_version, + tepp_artifact_sha256, reported_source_snapshot_sha256, + reported_knowledge_cutoff, posterior_draw_set_id, + posterior_draw_count, topic_count, inference_status_code) + values (%s, 'tepp-mismatch', 'snapshot-mismatch', + 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s, + '2026-08-01T00:00:00Z', 'draws-1', 8, 2, + 'posterior_topic_coordinates_not_importance') + """, + (str(run_id), "d" * 64, "e" * 64), + ) + cur.execute("rollback to savepoint topic_model_mismatch") + cur.execute( + """ + insert into topic_model_run + (analysis_run_id, tepp_run_id, tepp_snapshot_id, + tepp_schema_version, tepp_model_contract_version, + tepp_artifact_sha256, reported_source_snapshot_sha256, + reported_knowledge_cutoff, posterior_draw_set_id, + posterior_draw_count, topic_count, inference_status_code) + values (%s, 'tepp-accepted', 'snapshot-accepted', + 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s, + '2026-08-01T00:00:00Z', 'draws-1', 8, 2, + 'posterior_topic_coordinates_not_importance') + returning topic_model_run_id + """, + (str(run_id), "d" * 64, "a" * 64), + ) + model_id = cur.fetchone()[0] + cur.execute("savepoint topic_influence_mismatch") + with pytest.raises(psycopg2.errors.RaiseException, match="topic_influence_provenance_binding_mismatch"): + cur.execute( + """ + insert into topic_influence_run + (topic_model_run_id, fast_mlsirm_schema_version, + fast_mlsirm_version, fast_mlsirm_code_revision, + fast_mlsirm_artifact_sha256, reported_tepp_run_id, + reported_snapshot_sha256, reported_knowledge_cutoff, + membership_fingerprint_sha256, compute_backend_code, + precision_code, posterior_draw_coverage, + convergence_status_code, identification_status_code, + parity_status_code) + values (%s, 'fast_mlsirm.topic_context_influence.v1', '0.1.0', + %s, %s, 'different-tepp-run', %s, + '2026-08-01T00:00:00Z', %s, 'rust_cpu', 'f64', 8, + 'converged', 'identified', 'passed') + """, + (model_id, "f" * 40, "1" * 64, "a" * 64, "2" * 64), + ) + cur.execute("rollback to savepoint topic_influence_mismatch") + + +def test_topic_influence_projection_migration_replays(schema_db) -> None: + """The additive topic projection remains safe under sorted startup replay.""" + with schema_db.cursor() as cur: + cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text()) + schema_db.commit() + + def test_post_lineage_edge_requires_an_allen_interval_code(schema_db) -> None: with schema_db.cursor() as cur: cur.execute( From a9c25e65974e0f5635ed52679671f5cecc0f2308 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 01:30:17 +0900 Subject: [PATCH 2/2] test(dashboard): keep topic unavailable mocks explicit --- tests/test_operations_dashboard.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 3002945fc..343df2b39 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -302,8 +302,19 @@ async def test_external_information_projects_a_typed_prov_o_relation() -> None: """A cited semantic target becomes RDF reification, never a KG alias.""" class ExternalConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": False, + "fast_mlsirm_influence_persisted": False, + } + return await super().fetchrow(query, *args) + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: self.queries.append((query, args)) + if "from topic_post_context_influence influence" in query: + return [] if "operations_case_fact fact" in query: return [{ "post_id": "00000000-0000-0000-0000-000000000001",