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
4 changes: 2 additions & 2 deletions backend/app/operations_case_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ async def persist_operations_cases(
)
if case.facts:
await conn.executemany(
"insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256) values ($1, $2, $3, $4, $5, $6, $7, $8)",
"insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256, relation_target_kind_code) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
(post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256)
(post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256, fact.relation_target_kind_code)
for ordinal, fact in enumerate(case.facts)
],
)
Expand Down
110 changes: 100 additions & 10 deletions backend/app/operations_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from typing import Any, Protocol

from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.ontology import LW
from lineageweave.prov_o import PROV_RELATIONS


CASE_KIND_LABELS = {
Expand All @@ -27,6 +29,78 @@
"issue_pattern": "반복 유형",
"improvement_action": "개선 조치",
}
CASE_KIND_ONTOLOGY_CLASSES = {
"claim_investigation": str(LW.ClaimInvestigation),
"rebid_handover": str(LW.RebidHandover),
"external_information": str(LW.ExternalInformation),
"repeat_issue": str(LW.RepeatIssue),
}
EXTERNAL_RELATION_TARGETS = {
"order": ("수주", str(LW.Order), str(LW.relatesToOrder)),
"project": ("프로젝트", str(LW.Project), str(LW.relatesToProject)),
"sales": ("영업", str(LW.SalesContext), str(LW.relatesToSales)),
"business_management": (
"사업 관리",
str(LW.BusinessManagementContext),
str(LW.relatesToBusinessManagement),
),
}
PROV_WAS_DERIVED_FROM = PROV_RELATIONS["wasDerivedFrom"].iri


def _operations_case_jsonld(
post_id: str,
case_kind_code: str,
evidence_post_id: str,
case_facts: list[dict[str, str]],
) -> dict[str, Any]:
"""Project one persisted case and its cited facts as bounded JSON-LD."""
case_id = f"urn:lineageweave:operations-case:{post_id}:{case_kind_code}"
statements: list[dict[str, Any]] = []
for ordinal, fact in enumerate(case_facts):
statement: dict[str, Any] = {
"@id": f"{case_id}:fact:{ordinal}",
"@type": [str(LW.OperationsCaseFact), "http://www.w3.org/ns/prov#Entity"],
str(LW.factTypeCode): fact["fact_type_code"],
str(LW.factValue): fact["value_text"],
PROV_WAS_DERIVED_FROM: {
"@id": f"urn:lineageweave:post:{fact['evidence_post_id']}",
"@type": [str(LW.Post), "http://www.w3.org/ns/prov#Entity"],
},
}
predicate = fact.get("relation_predicate_iri")
target_class = fact.get("relation_target_class_iri")
if predicate and target_class:
statement.update(
{
"http://www.w3.org/1999/02/22-rdf-syntax-ns#subject": {
"@id": case_id
},
"http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate": {
"@id": predicate
},
"http://www.w3.org/1999/02/22-rdf-syntax-ns#object": {
"@id": f"{case_id}:fact:{ordinal}:target",
"@type": target_class,
"http://www.w3.org/2000/01/rdf-schema#label": fact["value_text"],
},
Comment on lines +82 to +86

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: Reified relation object is a synthetic node, not a resolved entity

For external_relation facts, _operations_case_jsonld sets the reified rdf:object to a minted URN {case_id}:fact:{ordinal}:target typed as the target class with rdfs:label = value_text, not a resolved Order/Project/Sales entity IRI. Downstream consumers get an unlinked target node per fact.

Open in Devin Review

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

}
)
statements.append(statement)
return {
"@context": {
"lw": str(LW),
"prov": "http://www.w3.org/ns/prov#",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
},
"@id": case_id,
"@type": [CASE_KIND_ONTOLOGY_CLASSES[case_kind_code], "prov:Entity"],
"prov:wasDerivedFrom": {
"@id": f"urn:lineageweave:post:{evidence_post_id}",
"@type": [str(LW.Post), "prov:Entity"],
},
str(LW.hasOperationsFact): statements,
}


class _Connection(Protocol):
Expand Down Expand Up @@ -156,7 +230,7 @@ async def fetch_operations_dashboard(
f"""
select fact.post_id, fact.case_kind_code, fact.fact_type_code,
fact.value_text, fact.evidence_text, fact.evidence_post_id,
fact.fact_ordinal
fact.fact_ordinal, fact.relation_target_kind_code
from operations_case_fact fact
join source_post post on post.post_id = fact.post_id
where {visible}
Expand All @@ -179,15 +253,23 @@ async def fetch_operations_dashboard(
facts: dict[tuple[str, str], list[dict[str, str]]] = {}
for row in fact_rows:
key = (str(row["post_id"]), row["case_kind_code"])
facts.setdefault(key, []).append(
{
"fact_type_code": row["fact_type_code"],
"fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]],
"value_text": row["value_text"],
"evidence_text": row["evidence_text"],
"evidence_post_id": str(row["evidence_post_id"]),
}
)
projected_fact = {
"fact_type_code": row["fact_type_code"],
"fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]],
"value_text": row["value_text"],
"evidence_text": row["evidence_text"],
"evidence_post_id": str(row["evidence_post_id"]),
"ontology_class_iri": str(LW.OperationsCaseFact),
"provenance_relation_iri": PROV_WAS_DERIVED_FROM,
}
target_kind = row["relation_target_kind_code"]
if target_kind in EXTERNAL_RELATION_TARGETS:
target_label, target_class, predicate = EXTERNAL_RELATION_TARGETS[target_kind]
projected_fact["relation_target_kind_code"] = target_kind
projected_fact["relation_target_kind_label"] = target_label
projected_fact["relation_target_class_iri"] = target_class
projected_fact["relation_predicate_iri"] = predicate
facts.setdefault(key, []).append(projected_fact)
missing_facts: dict[tuple[str, str], list[dict[str, str]]] = {}
for row in missing_rows:
key = (str(row["post_id"]), row["case_kind_code"])
Expand Down Expand Up @@ -232,9 +314,17 @@ async def fetch_operations_dashboard(
"summary_text": row["summary_text"],
"evidence_text": row["evidence_text"],
"evidence_post_id": str(row["evidence_post_id"]),
"ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]],
"provenance_relation_iri": PROV_WAS_DERIVED_FROM,
"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"]), []),
"semantic_projection": _operations_case_jsonld(
str(row["post_id"]),
row["case_kind_code"],
str(row["evidence_post_id"]),
facts.get((str(row["post_id"]), row["case_kind_code"]), []),
),
}
for row in case_rows
],
Expand Down
8 changes: 8 additions & 0 deletions docs/adr/0206-evidence-operations-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ provenance.
authorized, and that rank is never a psychometric measure or substitute for
TEPP. Missing estimates remain unavailable; no hand-picked weight is
introduced.
15. Operations classifications and facts have a governed OWL/JSON-LD read
projection. Each case is a `prov:Entity`; each fact is an RDF-reified
`prov:Entity` linked to its exact cited Post by `prov:wasDerivedFrom`.
External-information relations carry a provider-returned, closed semantic
target type (`order`, `project`, `sales`, or `business_management`) and map
to typed ontology properties. This is not a `knowledge_graph_edge` alias:
PostgreSQL operations tables remain authoritative, and an older untyped
relation remains absent from the typed projection until re-analysis.

## Consequences

Expand Down
20 changes: 20 additions & 0 deletions docs/ontology/lineageweave-kg-shapes.ttl
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@prefix : <https://contextualwisdomlab.github.io/LineageWeave/ontology#> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
Expand Down Expand Up @@ -166,6 +167,25 @@
sh:datatype xsd:string ;
] .

:OperationsCaseFactShape a sh:NodeShape ;
rdfs:label "Operations case fact shape" ;
sh:targetClass :OperationsCaseFact ;
sh:property [
sh:path :factTypeCode ;
sh:minCount 1 ; sh:maxCount 1 ;
sh:datatype xsd:string ; sh:minLength 1 ;
] ;
sh:property [
sh:path :factValue ;
sh:minCount 1 ; sh:maxCount 1 ;
sh:datatype xsd:string ; sh:minLength 1 ;
] ;
sh:property [
sh:path prov:wasDerivedFrom ;
sh:minCount 1 ; sh:maxCount 1 ;
sh:class :Post ;
] .

:OurSidePersonShape a sh:NodeShape ;
rdfs:label "Our-side person shape" ;
sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ;
Expand Down
58 changes: 58 additions & 0 deletions docs/ontology/lineageweave-kg.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,61 @@
:semanticConfidence a owl:DatatypeProperty ;
rdfs:domain :ProjectMention ;
rdfs:range xsd:decimal .

#################################################################
# Evidence-grounded operations Dashboard (ADR 0206).
#
# These are governed read-projection terms over operations_case_* rows,
# not knowledge_graph_edge aliases. Each reified fact retains its cited
# source post through prov:wasDerivedFrom. Untyped legacy external-relation
# rows stay outside the typed relation projection.
#################################################################

:OperationsCase a owl:Class ;
rdfs:subClassOf prov:Entity ;
rdfs:label "Operations case"@en .

:ClaimInvestigation a owl:Class ;
rdfs:subClassOf :OperationsCase ;
rdfs:label "Claim investigation"@en .

:RebidHandover a owl:Class ;
rdfs:subClassOf :OperationsCase ;
rdfs:label "Rebid or handover"@en .

:ExternalInformation a owl:Class ;
rdfs:subClassOf :OperationsCase ;
rdfs:label "External information"@en .

:RepeatIssue a owl:Class ;
rdfs:subClassOf :OperationsCase ;
rdfs:label "Repeat issue"@en .

:OperationsCaseFact a owl:Class ;
rdfs:subClassOf rdf:Statement, prov:Entity ;
rdfs:label "Operations case fact"@en .

:Order a owl:Class ; rdfs:label "Order"@en .
:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .

:relatesToOrder a owl:ObjectProperty ;
rdfs:domain :ExternalInformation ; rdfs:range :Order .

:relatesToProject a owl:ObjectProperty ;
rdfs:domain :ExternalInformation ; rdfs:range :Project .

:relatesToSales a owl:ObjectProperty ;
rdfs:domain :ExternalInformation ; rdfs:range :SalesContext .

:relatesToBusinessManagement a owl:ObjectProperty ;
rdfs:domain :ExternalInformation ; rdfs:range :BusinessManagementContext .

:hasOperationsFact a owl:ObjectProperty ;
rdfs:domain :OperationsCase ; rdfs:range :OperationsCaseFact .

:factTypeCode a owl:DatatypeProperty ;
rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string .

:factValue a owl:DatatypeProperty ;
rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string .
9 changes: 9 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export interface OperationsDashboardFact {
value_text: string;
evidence_text: string;
evidence_post_id: string;
ontology_class_iri?: string;
provenance_relation_iri?: string;
relation_target_kind_code?: "order" | "project" | "sales" | "business_management";
relation_target_kind_label?: string;
relation_target_class_iri?: string;
relation_predicate_iri?: string;
}

export interface OperationsDashboardCase {
Expand All @@ -61,6 +67,9 @@ export interface OperationsDashboardCase {
occurred_at: string;
facts: OperationsDashboardFact[];
missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>;
ontology_class_iri?: string;
provenance_relation_iri?: string;
semantic_projection?: Record<string, unknown>;
}

export interface OperationsDashboardResponse {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OperationsDashboard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const EvidenceReady: Story = {
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" }] },
{ 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-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", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], 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: "반복 유형" }] },
],
},
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/OperationsDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ describe("OperationsDashboardView", () => {
expect(screen.queryByText("5건 · 25.0%")).not.toBeInTheDocument();
});

it("labels a source-backed external relation by its semantic target", () => {
const externalCase = {
...data.cases[0],
case_kind_code: "external_information",
facts: [{
...data.cases[0].facts[0],
fact_type_code: "external_relation",
fact_type_label: "업무 관계",
relation_target_kind_code: "project" as const,
relation_target_kind_label: "프로젝트",
}],
missing_facts: [],
};
render(<OperationsDashboardView data={{ ...data, cases: [externalCase] }} onOpenPost={() => undefined} />);
expect(screen.getByText("업무 관계 · 프로젝트")).toBeInTheDocument();
});

it("places multi-project evidence in every explicit journey and orders events oldest first", () => {
const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] };
const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] };
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OperationsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
<div className="dashboard-case-title"><span>{item.case_kind_label}</span><strong>{item.project_name ?? "프로젝트 연결 분석 중"}</strong></div>
<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>
<dl>{item.facts.map((fact) => <div key={`${fact.fact_type_code}-${fact.value_text}`}><dt>{fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_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>
Expand Down
Loading