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.summary_text}

{item.evidence_text}
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
+ {item.missing_facts.length ? ( +
+

추가 확인 필요

+ +
+ ) : null} ))} diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 43decd006..5e4c46fde 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -19,6 +19,12 @@ "issue_pattern", "improvement_action", } ) +REQUIRED_FACT_TYPES = { + "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}), + "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}), + "external_information": frozenset({"external_relation"}), + "repeat_issue": frozenset({"issue_pattern", "improvement_action"}), +} @dataclass(frozen=True) @@ -42,6 +48,7 @@ class OperationsCase: facts: tuple[OperationsCaseFact, ...] evidence_post_id: str = "" evidence_input_sha256: str = "" + missing_fact_type_codes: tuple[str, ...] = () @dataclass(frozen=True) @@ -88,8 +95,14 @@ def analyze( evidence_post_id, evidence_text (a verbatim span from that numbered source), and facts. Each fact has fact_type_code (one of order, specification_change, originating_order, sales_pool, discussion, counterparty, our_owner, decision, external_relation, -issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). Return [] only when the -record supports none of the case kinds. Never fill an unsupported fact. +issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). +Each item must also have missing_fact_type_codes. Put every required fact type for that case +that is not supported anywhere in the authorized sources in this array; never invent a value or +evidence span for it. Required types are: claim_investigation = order, +specification_change, originating_order, sales_pool; rebid_handover = discussion, +counterparty, our_owner, decision; external_information = external_relation; +repeat_issue = issue_pattern, improvement_action. Return [] only when the record supports none +of the case kinds. Stored context (hints, not proof): {context} Authorized numbered sources: @@ -123,8 +136,9 @@ def parse_operations_case_response( evidence = item.get("evidence_text") evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None) facts = item.get("facts") + missing_fact_types = item.get("missing_fact_type_codes") evidence_source = sources_by_id.get(evidence_post_id) - if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list): + if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list) or not isinstance(missing_fact_types, list): return None parsed_facts: list[OperationsCaseFact] = [] for fact in facts: @@ -137,7 +151,15 @@ def parse_operations_case_response( if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or not fact_evidence.strip() or fact_source is None or fact_evidence not in fact_source.text: return None parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256)) - cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256)) + supported_types = {fact.fact_type_code for fact in parsed_facts} + if ( + any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types) + or len(set(missing_fact_types)) != len(missing_fact_types) + or supported_types.intersection(missing_fact_types) + or supported_types.union(missing_fact_types) != REQUIRED_FACT_TYPES[item["case_kind_code"]] + ): + return None + cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types))) return tuple(cases) diff --git a/migrations/0211_operations_case_missing_fact.sql b/migrations/0211_operations_case_missing_fact.sql new file mode 100644 index 000000000..a72edcb55 --- /dev/null +++ b/migrations/0211_operations_case_missing_fact.sql @@ -0,0 +1,13 @@ +-- ADR 0206: unsupported required answers remain explicit without fabricated evidence. +create table if not exists operations_case_missing_fact ( + post_id uuid not null, + case_kind_code text not null, + fact_type_code text not null check (fact_type_code in ('order', 'specification_change', 'originating_order', 'sales_pool', 'discussion', 'counterparty', 'our_owner', 'decision', 'external_relation', 'issue_pattern', 'improvement_action')), + primary key (post_id, case_kind_code, fact_type_code), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create index if not exists operations_case_missing_fact_kind_idx + on operations_case_missing_fact (case_kind_code, fact_type_code, post_id); diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 312189879..ebfdb20fa 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -9,8 +9,8 @@ def test_parses_multiple_cases_and_grounded_facts() -> None: """One record may support multiple case kinds without losing evidence.""" body = "The revised specification caused the claim. Mina agreed with Alex to rebid." payload = [ - {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}]}, - {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}]}, + {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}], "missing_fact_type_codes": ["order", "originating_order", "sales_pool"]}, + {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}], "missing_fact_type_codes": ["discussion", "our_owner", "decision"]}, ] result = parse_operations_case_response(json.dumps(payload), body) assert result is not None @@ -19,7 +19,7 @@ def test_parses_multiple_cases_and_grounded_facts() -> None: def test_rejects_uncited_model_claim() -> None: """A plausible answer absent from the source is not persisted.""" - payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": []}] + payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": [], "missing_fact_type_codes": ["external_relation"]}] assert parse_operations_case_response(json.dumps(payload), "source body") is None @@ -37,11 +37,11 @@ def test_rejects_unknown_codes_and_malformed_json() -> None: def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None: """One normalized key has one grounded classification, never an empty span.""" duplicate = [ - {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": []}, - {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": []}, + {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, + {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, ] blank = [ - {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": []} + {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]} ] assert parse_operations_case_response(json.dumps(duplicate), "body") is None assert parse_operations_case_response(json.dumps(blank), "body") is None @@ -64,6 +64,7 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No "evidence_post_id": "linked", "evidence_text": "Specification S2 replaced S1.", }], + "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], }] result = parse_operations_case_response(json.dumps(payload), sources) @@ -73,3 +74,24 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No assert result[0].facts[0].evidence_input_sha256 == sources[1].input_sha256 payload[0]["facts"][0]["evidence_post_id"] = "unauthorized" assert parse_operations_case_response(json.dumps(payload), sources) is None + + +def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> None: + """The provider cannot silently omit or both support and miss a required answer.""" + payload = [{ + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published.", + "facts": [], + "missing_fact_type_codes": [], + }] + body = "A public notice was published." + assert parse_operations_case_response(json.dumps(payload), body) is None + + payload[0]["facts"] = [{ + "fact_type_code": "external_relation", + "value_text": "Sales opportunity", + "evidence_text": body, + }] + payload[0]["missing_fact_type_codes"] = ["external_relation"] + assert parse_operations_case_response(json.dumps(payload), body) is None diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py index d1270d5ca..a1d6f63f5 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -57,3 +57,26 @@ def test_persists_supported_empty_analysis() -> None: asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ())) assert len(conn.calls) == 2 assert conn.batches == [] + + +def test_persists_missing_required_facts_without_invented_evidence() -> None: + """Unsupported answers use the normalized missing-fact relation only.""" + conn = _Connection() + case = OperationsCase( + "claim_investigation", + "Claim", + "source", + (), + "post-1", + "a" * 64, + ("order", "specification_change", "originating_order", "sales_pool"), + ) + + asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", (case,))) + + assert conn.batches == [[ + ("post-1", "claim_investigation", "order"), + ("post-1", "claim_investigation", "specification_change"), + ("post-1", "claim_investigation", "originating_order"), + ("post-1", "claim_investigation", "sales_pool"), + ]] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index a9ed05915..d3a6ae43e 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -37,6 +37,12 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "fact_ordinal": 0, } ] + if "operations_case_missing_fact missing" in query: + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "fact_type_code": "sales_pool", + }] return [ { "post_id": "00000000-0000-0000-0000-000000000001", @@ -113,9 +119,12 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "evidence_post_id": "00000000-0000-0000-0000-000000000002", } ], + "missing_facts": [ + {"fact_type_code": "sales_pool", "fact_type_label": "수주 Pool"} + ], } ] - assert len(conn.queries) == 3 + assert len(conn.queries) == 4 for query, args in conn.queries: assert "visibility_code = 'public'" in query assert "corporate_entity_id::text = any($1::text[])" in query diff --git a/tests/test_schema.py b/tests/test_schema.py index d88d07bd2..ab0a2818c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -86,6 +86,15 @@ / "migrations" / "0182_report_leftover_map_unexplained.sql" ) +_OPERATIONS_CASE_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0208_operations_case_analysis.sql" +) +_OPERATIONS_CASE_EVIDENCE_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0209_operations_case_evidence_source.sql" +) +_OPERATIONS_CASE_MISSING_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" +) def _postgres_available() -> bool: @@ -131,6 +140,9 @@ def schema_db(): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -185,6 +197,10 @@ def test_migration_applies_cleanly(schema_db) -> None: "post_summary_action", "post_chat_result", "post_chat_citation", + "operations_case_analysis", + "operations_case_classification", + "operations_case_fact", + "operations_case_missing_fact", } assert expected <= tables