diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index a2a1fc84f..92cfc1fc8 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -61,3 +61,8 @@ async def persist_operations_cases( for ordinal, fact in enumerate(case.facts) ], ) + if case.missing_fact_type_codes: + await conn.executemany( + "insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)", + [(post_id, case.case_kind_code, code) for code in case.missing_fact_type_codes], + ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index ca1b7d5cd..af610cf43 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -148,6 +148,16 @@ async def fetch_operations_dashboard( """, *args, ) + missing_rows = await conn.fetch( + f""" + select missing.post_id, missing.case_kind_code, missing.fact_type_code + from operations_case_missing_fact missing + join source_post post on post.post_id = missing.post_id + where {visible} + order by missing.post_id, missing.case_kind_code, missing.fact_type_code + """, + *args, + ) facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in fact_rows: key = (str(row["post_id"]), row["case_kind_code"]) @@ -160,6 +170,15 @@ async def fetch_operations_dashboard( "evidence_post_id": str(row["evidence_post_id"]), } ) + missing_facts: dict[tuple[str, str], list[dict[str, str]]] = {} + for row in missing_rows: + key = (str(row["post_id"]), row["case_kind_code"]) + missing_facts.setdefault(key, []).append( + { + "fact_type_code": row["fact_type_code"], + "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], + } + ) total = int(metrics["total_post_count"]) external = int(metrics["external_post_count"]) case_post_ids: dict[str, set[str]] = {} @@ -197,6 +216,7 @@ async def fetch_operations_dashboard( "evidence_post_id": str(row["evidence_post_id"]), "occurred_at": row["occurred_at"].isoformat(), "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), + "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []), } for row in case_rows ], diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 3f9f5505d..ce6a8c2ee 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -65,8 +65,14 @@ provenance. post evidence. 8. Claim-investigation and rebid/handover panels include positively classified cases and show extracted answers plus cited spans. A required answer that - the source does not support is stored as an explicit missing fact, so the + the source does not support is stored in the normalized + `operations_case_missing_fact` relation as an explicit missing fact, so the next action is collection or human correction rather than keyword guessing. + A provider result is invalid unless every required question is represented + exactly once as either a cited supported fact or an explicit missing fact; + a fact cannot be both. Missing facts carry no invented value or evidence + span and inherit the analysis run and authorized-source boundary through + their classification parent. 9. Project journeys group events only by an explicit source project or stored semantic project mention. A multi-project post may appear in multiple journeys. Unbound events remain visible as unassigned evidence and are not diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 270231981..f73925b70 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -8,8 +8,8 @@ | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI now reports separate per-kind Event and distinct-post counts beside cited qualitative facts; authenticated runtime acceptance pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI now reports separate per-kind Event and distinct-post counts; corpus backfill pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact with a collection action; authenticated runtime acceptance pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact; corpus backfill pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending | | Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 3716656cd..3559cf139 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`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, 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, 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` | | `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/api.ts b/frontend/src/api.ts index 60c8b0c6f..c63436b9f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -60,6 +60,7 @@ export interface OperationsDashboardCase { evidence_post_id: string; occurred_at: string; facts: OperationsDashboardFact[]; + missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>; } export interface OperationsDashboardResponse { diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index c9093993a..560bbbe33 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -20,10 +20,10 @@ export const EvidenceReady: Story = { ], 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" }] }, - { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }] }, - { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "business_relation", fact_type_label: "사업 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }] }, - { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }] }, + { 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" }] }, + { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] }, + { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }], missing_facts: [] }, + { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] }, ], }, onOpenPost: () => undefined, @@ -54,6 +54,16 @@ export const ExternalInformationEmpty: Story = { }, }; +export const RequiredFactMissing: Story = { + args: { + data: { ...EvidenceReady.args!.data!, cases: [EvidenceReady.args!.data!.cases[0]] }, + onOpenPost: () => undefined, + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeVisible(); + }, +}; + export const AnalysisPendingAndMissingEvidence: Story = { args: { data: { ...EvidenceReady.args!.data!, total_event_count: 0, pending_analysis_count: 3, cases: [] }, diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 7124d7ca7..a3947e2a2 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -25,6 +25,7 @@ const data = { 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", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }], + missing_facts: [{ fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], }], }; @@ -35,6 +36,7 @@ describe("OperationsDashboardView", () => { expect(screen.getByText("3 Event · 2글")).toBeInTheDocument(); expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); + expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" })); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 810795a00..cd739c0d6 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -112,6 +112,12 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.evidence_text}