diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index 92cfc1fc8..ea92c4f22 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -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) ], ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index a1007bcc9..552bf094a 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -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 = { @@ -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"], + }, + } + ) + 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): @@ -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} @@ -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"]) @@ -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 ], diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 3e10282d7..21d923fd3 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -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 diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl index 436eb401f..187ebb0f1 100644 --- a/docs/ontology/lineageweave-kg-shapes.ttl +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -1,6 +1,7 @@ @prefix : . @prefix dcterms: . @prefix owl: . +@prefix prov: . @prefix rdf: . @prefix rdfs: . @prefix sh: . @@ -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." ; diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 24aeabdba..90ef1c4cb 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -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 . diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 588d4ab3e..0b5002cc6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { @@ -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; } export interface OperationsDashboardResponse { diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 560bbbe33..1ca026c82 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -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: "반복 유형" }] }, ], }, diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index bc7c561da..e0093ff24 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -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( 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"] }; diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 39fd75757..726ba4117 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -111,7 +111,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}

{item.summary_text}

{item.evidence_text}
-
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
+
{item.facts.map((fact) =>
{fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_label}` : ""}
{fact.value_text}
)}
{item.missing_facts.length ? (

추가 확인 필요

diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 39254f666..3c054cc59 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -19,6 +19,9 @@ "issue_pattern", "improvement_action", } ) +EXTERNAL_RELATION_TARGET_KINDS = frozenset( + {"order", "project", "sales", "business_management"} +) REQUIRED_FACT_TYPES = { "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}), "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}), @@ -36,6 +39,7 @@ class OperationsCaseFact: evidence_text: str evidence_post_id: str = "" evidence_input_sha256: str = "" + relation_target_kind_code: str | None = None @dataclass(frozen=True) @@ -96,6 +100,9 @@ def analyze( 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). +An external_relation fact must also have relation_target_kind_code (one of order, +project, sales, business_management). Other facts must use null. Classify this +semantically from the cited span; never infer it from keywords. 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, @@ -148,9 +155,34 @@ def parse_operations_case_response( fact_evidence = fact.get("evidence_text") fact_post_id = fact.get("evidence_post_id") or ("focal" if legacy_focal else None) fact_source = sources_by_id.get(fact_post_id) - 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: + relation_target_kind = fact.get("relation_target_kind_code") + 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 + or ( + fact["fact_type_code"] == "external_relation" + and relation_target_kind not in EXTERNAL_RELATION_TARGET_KINDS + ) + or ( + fact["fact_type_code"] != "external_relation" + and relation_target_kind is not None + ) + ): return None - parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256)) + parsed_facts.append( + OperationsCaseFact( + fact["fact_type_code"], + value.strip(), + fact_evidence, + fact_source.post_id, + fact_source.input_sha256, + relation_target_kind, + ) + ) supported_types = {fact.fact_type_code for fact in parsed_facts} missing_types = set(missing_fact_types) required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]] diff --git a/migrations/0213_operations_external_relation_target.sql b/migrations/0213_operations_external_relation_target.sql new file mode 100644 index 000000000..eb059744d --- /dev/null +++ b/migrations/0213_operations_external_relation_target.sql @@ -0,0 +1,15 @@ +-- ADR 0206: source-backed external-information relation target type. +alter table operations_case_fact + add column if not exists relation_target_kind_code text; + +alter table operations_case_fact + drop constraint if exists operations_case_fact_relation_target_kind_check, + add constraint operations_case_fact_relation_target_kind_check check ( + (fact_type_code = 'external_relation' + and (relation_target_kind_code is null or relation_target_kind_code in + ('order', 'project', 'sales', 'business_management'))) + or (fact_type_code <> 'external_relation' and relation_target_kind_code is null) + ); + +comment on column operations_case_fact.relation_target_kind_code is + 'Semantic target type supplied with cited external_relation evidence; null legacy rows are not projected as typed relations.'; diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 71ba6918c..24494731d 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -33,7 +33,14 @@ #: lowercase form is the deprecated compatibility vocabulary. CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" DEPRECATED_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" -STANDARD_SHACL_PATHS = frozenset({RDF.subject, RDF.predicate, RDF.object}) +STANDARD_SHACL_PATHS = frozenset( + { + RDF.subject, + RDF.predicate, + RDF.object, + URIRef("http://www.w3.org/ns/prov#wasDerivedFrom"), + } +) _MAPPING_FOR_KIND = { OWL.Class: OWL.equivalentClass, diff --git a/tests/test_ontology.py b/tests/test_ontology.py index e9514996c..0ef231bab 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -29,7 +29,7 @@ ontology_annotations, ) from rdflib import URIRef -from rdflib.namespace import OWL, RDF, RDFS, SKOS, XSD +from rdflib.namespace import OWL, PROV, RDF, RDFS, SKOS, XSD _SEED_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" @@ -238,6 +238,24 @@ def test_semantic_project_terms_preserve_post_evidence_and_confidence() -> None: assert (LW.semanticConfidence, RDFS.domain, LW.ProjectMention) in graph +def test_operations_relations_are_typed_reified_projections() -> None: + """Dashboard facts reuse RDF reification and PROV-O, never KG aliases.""" + graph = load_ontology() + assert (LW.ExternalInformation, RDFS.subClassOf, LW.OperationsCase) in graph + assert (LW.OperationsCase, RDFS.subClassOf, PROV.Entity) in graph + assert (LW.OperationsCaseFact, RDFS.subClassOf, RDF.Statement) in graph + assert (LW.OperationsCaseFact, RDFS.subClassOf, PROV.Entity) in graph + assert (LW.relatesToOrder, RDFS.range, LW.Order) in graph + assert (LW.relatesToProject, RDFS.range, LW.Project) in graph + assert (LW.relatesToSales, RDFS.range, LW.SalesContext) in graph + assert ( + LW.relatesToBusinessManagement, + RDFS.range, + LW.BusinessManagementContext, + ) in graph + assert graph.value(LW.relatesToProject, LW.lookupCode) is None + + def test_ontology_iri_is_repository_case_canonical() -> None: """ADR 0207: the ontology IRI and every term IRI use the repository-case namespace -- the exact path GitHub Pages serves -- diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 7167420a2..d5df88c2d 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -130,6 +130,7 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete "fact_type_code": "external_relation", "value_text": "Sales opportunity", "evidence_text": body, + "relation_target_kind_code": "sales", }, { "fact_type_code": "our_owner", @@ -142,5 +143,50 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete assert parse_operations_case_response(json.dumps(payload), body) is not None - payload[0]["missing_fact_type_codes"] = ["our_owner"] +def test_external_relation_requires_a_semantic_target_type() -> None: + """Only source-backed typed external links enter the ontology projection.""" + body = "The public tender applies to Synthetic Project A." + fact = { + "fact_type_code": "external_relation", + "value_text": "Synthetic Project A", + "evidence_text": body, + "relation_target_kind_code": "project", + } + payload = [{ + "case_kind_code": "external_information", + "summary_text": "Tender relates to a project", + "evidence_text": body, + "facts": [fact], + "missing_fact_type_codes": [], + }] + + result = parse_operations_case_response(json.dumps(payload), body) + + assert result is not None + assert result[0].facts[0].relation_target_kind_code == "project" + del fact["relation_target_kind_code"] + assert parse_operations_case_response(json.dumps(payload), body) is None + fact["relation_target_kind_code"] = "guessed" + assert parse_operations_case_response(json.dumps(payload), body) is None + + +def test_optional_fact_cannot_be_marked_missing() -> None: + """A cited optional fact cannot simultaneously be declared missing.""" + body = "A public notice was published and assigned to the sales team." + payload = [{ + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published", + "facts": [{ + "fact_type_code": "external_relation", + "value_text": "Sales opportunity", + "evidence_text": body, + "relation_target_kind_code": "sales", + }, { + "fact_type_code": "our_owner", + "value_text": "Sales team", + "evidence_text": "assigned to the sales team", + }], + "missing_fact_type_codes": ["our_owner"], + }] 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 a1d6f63f5..e6df5b1b2 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -47,7 +47,7 @@ def test_digest_and_atomic_normalized_persistence() -> None: assert len(source_body_digest("source")) == 64 assert "delete from operations_case_analysis" in conn.calls[0][0] assert conn.batches == [ - [("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest)] + [("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest, None)] ] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index ab972bda7..25631b0ba 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -35,6 +35,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", "fact_ordinal": 0, + "relation_target_kind_code": None, } ] if "operations_case_missing_fact missing" in query: @@ -100,6 +101,11 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "post_count": 0, }, ] + semantic_projection = result["cases"][0].pop("semantic_projection") + assert semantic_projection["@type"][0].endswith("#ClaimInvestigation") + assert semantic_projection["prov:wasDerivedFrom"]["@id"].endswith( + "00000000-0000-0000-0000-000000000002" + ) assert result["cases"] == [ { "post_id": "00000000-0000-0000-0000-000000000001", @@ -110,6 +116,8 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "summary_text": "원인 수주가 연결됨", "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "ontology_class_iri": "https://contextualwisdomlab.github.io/LineageWeave/ontology#ClaimInvestigation", + "provenance_relation_iri": "http://www.w3.org/ns/prov#wasDerivedFrom", "occurred_at": "2026-08-12T00:00:00+00:00", "facts": [ { @@ -118,6 +126,8 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "value_text": "Synthetic order 7", "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "ontology_class_iri": "https://contextualwisdomlab.github.io/LineageWeave/ontology#OperationsCaseFact", + "provenance_relation_iri": "http://www.w3.org/ns/prov#wasDerivedFrom", } ], "missing_facts": [ @@ -179,6 +189,57 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: ) +@pytest.mark.anyio +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 fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.queries.append((query, args)) + if "operations_case_fact fact" in query: + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "external_information", + "fact_type_code": "external_relation", + "value_text": "Synthetic Project", + "evidence_text": "Synthetic tender evidence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "fact_ordinal": 0, + "relation_target_kind_code": "project", + }] + if "operations_case_missing_fact missing" in query: + return [] + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "external_information", + "summary_text": "External tender", + "evidence_text": "Synthetic tender evidence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "project_name": "Synthetic Project", + "project_names": ["Synthetic Project"], + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + "event_count": 1, + }] + + result = await fetch_operations_dashboard(ExternalConnection(), []) + + fact = result["cases"][0]["facts"][0] + assert fact["relation_target_kind_code"] == "project" + assert fact["relation_predicate_iri"].endswith("#relatesToProject") + statement = result["cases"][0]["semantic_projection"][ + "https://contextualwisdomlab.github.io/LineageWeave/ontology#hasOperationsFact" + ][0] + assert statement["http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate"] == { + "@id": fact["relation_predicate_iri"] + } + assert statement["http://www.w3.org/ns/prov#wasDerivedFrom"]["@id"].endswith( + "00000000-0000-0000-0000-000000000002" + ) + target = statement["http://www.w3.org/1999/02/22-rdf-syntax-ns#object"] + assert target["@id"].endswith(":fact:0:target") + assert target["@type"].endswith("#Project") + + @pytest.fixture def anyio_backend() -> str: """Use the installed asyncio backend for async projection tests.""" diff --git a/tests/test_schema.py b/tests/test_schema.py index ab0a2818c..0b4aaae69 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -95,6 +95,11 @@ _OPERATIONS_CASE_MISSING_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" ) +_OPERATIONS_EXTERNAL_RELATION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0213_operations_external_relation_target.sql" +) def _postgres_available() -> bool: @@ -143,6 +148,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(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text()) conn.commit() yield conn finally: