Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/app/operations_case_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)
20 changes: 20 additions & 0 deletions backend/app/operations_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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]] = {}
Expand Down Expand Up @@ -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
],
Expand Down
8 changes: 7 additions & 1 deletion docs/adr/0206-evidence-operations-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/components/OperationsDashboard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [] },
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/OperationsDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
}],
};

Expand All @@ -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: "원인 수주 근거 열기" }));
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/OperationsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
<h3>{item.summary_text}</h3>
<blockquote>{item.evidence_text}</blockquote>
<dl>{item.facts.map((fact) => <div key={`${fact.fact_type_code}-${fact.value_text}`}><dt>{fact.fact_type_label}</dt><dd>{fact.value_text} <button type="button" className="btn-link" onClick={() => onOpenPost(fact.evidence_post_id)}>{fact.fact_type_label} 근거 열기</button></dd></div>)}</dl>
{item.missing_facts.length ? (
<section className="dashboard-missing-facts" aria-label="추가 확인이 필요한 항목">
<h4>추가 확인 필요</h4>
<ul>{item.missing_facts.map((fact) => <li key={fact.fact_type_code}>{fact.fact_type_label}: 권한 범위 내 근거가 없습니다. 관련 원문을 연결하세요.</li>)}</ul>
</section>
) : null}
<button type="button" className="btn-secondary" onClick={() => onOpenPost(item.evidence_post_id)}>분류 근거 글 열기</button>
</article>
))}
Expand Down
30 changes: 26 additions & 4 deletions lineageweave/operations_case_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Comment on lines +154 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Completeness gate rejects the whole response

The gate at operations_case_analysis.py requires each case's supported plus missing fact types to exactly equal that kind's required set. Any case that fails makes parse_operations_case_response return None, which raises ValueError and fails the whole analysis job. This also forbids a case from carrying a supported fact of a type belonging to another kind, unlike the prior facts loop.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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)


Expand Down
Loading