diff --git a/CHANGELOG.d/2.16.1-planned-facility-relation.md b/CHANGELOG.d/2.16.1-planned-facility-relation.md new file mode 100644 index 000000000..65b3627f4 --- /dev/null +++ b/CHANGELOG.d/2.16.1-planned-facility-relation.md @@ -0,0 +1,5 @@ +## 2.16.1 + +- Add the fail-closed `lw_plans_to_operate` semantic relationship for planned + facilities backed by the same literal actor/facility evidence, a matching + R&R actor, and an independently extracted project mention (ADR 0142). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5441a23b2..3349f4e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ All notable changes to this project are documented here. Format follows keeps its prior behavior. - `frontend/index.html` now shows a visible, styled message when JavaScript is disabled, instead of a silent blank page. -- ADR 0142 proposes reusing the existing semantic-relationship channel with - one new `lw_plans_to_operate` predicate for planned-facility evidence, - scoped to the decision only -- no inference code ships with it. +- Planned-facility evidence now reuses the semantic-relationship channel with + `lw_plans_to_operate` (ADR 0142). The relationship is retained only when the + same source span names a matching R&R actor and project-backed facility; it + never represents an already-operating facility. - Event Lineage now reports why a post has no DAG (ADR 0143): "no_relation_found" when reconstruct compared it against real candidates and found no relation, or "no_comparison_group" when it was diff --git a/docs/adr/0142-planned-facility-project-evidence.md b/docs/adr/0142-planned-facility-project-evidence.md index 7cfea2ac0..f6f0df9dc 100644 --- a/docs/adr/0142-planned-facility-project-evidence.md +++ b/docs/adr/0142-planned-facility-project-evidence.md @@ -1,7 +1,7 @@ # ADR 0142 — A planned facility becomes project/entity evidence only through the existing semantic-relationship channel, with an explicit "planned" predicate -**Decision status:** Proposed -**Date:** 2026-08-22 +**Decision status:** Accepted +**Date:** 2026-08-24 ## Context @@ -23,9 +23,9 @@ context risks the same class of mistake — asserting a fact ("Org X operates Facility Y") that the source text does not actually state ("Org X plans to build Facility Y"). -This ADR is scoped to the decision only. **No inference code is written as -part of this ADR** — implementing it is separately reviewed follow-up work, -per the gap doc's own instruction and AGENTS.md's ADR-first rule. +The initial proposed revision was decision-only. The separately reviewed +follow-up now implements the accepted admission rule without adding a second +relationship table or catalog-creation path. ### What already exists that this can reuse @@ -122,12 +122,11 @@ governs when the extractor may emit it. ## Consequences -- The next implementation step (separately reviewed, not part of this ADR) - is: bump `POST_SUMMARY_CONTRACT_VERSION`, extend the extraction prompt - with the admission rule above, add `lw_plans_to_operate` to the ontology - TTL and `SEMANTIC_RELATION_PREDICATES`, and add fixture-backed tests - proving a *plan* phrase emits the new predicate while an *existing - operation* phrase does not. +- The implementation bumps `POST_SUMMARY_CONTRACT_VERSION`, extends the + extraction prompt and ontology registry, and independently rechecks the + R&R actor, project backing, facility type, and literal evidence span before + retaining a model-emitted `lw_plans_to_operate` row. Missing evidence drops + only that relationship; other supported semantic relationships remain. - Because the predicate is additive to an existing closed vocabulary and channel, no migration is needed beyond the ontology TTL update and the Python frozenset — `post_summary_semantic_relationship`'s schema already @@ -143,3 +142,15 @@ governs when the extractor may emit it. purpose (`lw_has_goal`, `lw_has_next_step`, etc. are the same kind of LineageWeave-specific narrative-structure predicate already in the vocabulary), not a new category of exception. + +## References (APA 7th) + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization +system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +W3C OWL Working Group. (2012). *OWL 2 Web Ontology Language document overview +(2nd ed.)*. World Wide Web Consortium. https://www.w3.org/TR/owl2-overview/ diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index a659b1615..18ccef03b 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -729,6 +729,11 @@ rdfs:label "supports"@en ; rdfs:comment "LineageWeave profile relation for an explicit source statement that an agent supports an entity such as a named project. It is not inferred from co-occurrence or role evidence."@en . +:plansToOperate a owl:ObjectProperty ; + rdfs:domain prov:Agent ; + rdfs:label "plans to operate"@en ; + rdfs:comment "An explicit source-backed intention to operate a planned facility, not evidence that the facility is already operating."@en . + :subjectName a owl:DatatypeProperty ; rdfs:domain :SemanticRelationship ; rdfs:range xsd:string . @@ -1376,6 +1381,7 @@ [ a :SemanticPredicateMapping ; :predicateCode "lw_has_result" ; :predicateIri :hasResultClue ; rdfs:label "Has result clue"@en ], [ a :SemanticPredicateMapping ; :predicateCode "lw_has_condition" ; :predicateIri :hasConditionClue ; rdfs:label "Has condition clue"@en ], [ a :SemanticPredicateMapping ; :predicateCode "lw_inferred_from" ; :predicateIri :inferredFrom ; rdfs:label "Inferred from"@en ], + [ a :SemanticPredicateMapping ; :predicateCode "lw_plans_to_operate" ; :predicateIri :plansToOperate ; rdfs:label "Plans to operate"@en ], [ a :SemanticPredicateMapping ; :predicateCode "lw_responsible_for" ; :predicateIri :responsibleFor ; rdfs:label "Responsible for"@en ], [ a :SemanticPredicateMapping ; :predicateCode "lw_supports" ; :predicateIri :supports ; rdfs:label "Supports"@en ] . diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index dc335a05b..9ece420b1 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -139,6 +139,7 @@ "lw_has_result", "lw_has_condition", "lw_inferred_from", + "lw_plans_to_operate", "lw_responsible_for", "lw_supports", } @@ -218,7 +219,7 @@ ) # Stored rows without this contract version are legacy summaries and must be # regenerated from the current source body before the popup treats them as evidence. -POST_SUMMARY_CONTRACT_VERSION = 19 +POST_SUMMARY_CONTRACT_VERSION = 20 _GENERIC_TEAM_ACTOR_NAMES = frozenset( {"사업부", "부서", "팀", "business unit", "department", "division"} @@ -936,6 +937,15 @@ def summarize_with_hints( - use lw_supports for an explicit organization-to-named-project support statement such as "provided installation support for [project]" or "supported [project]"; generic work wording is not enough; +- use lw_plans_to_operate only for an explicitly planned facility relation + stated by the source. The actor type must be organization or team, the + facility object type must be industrial_asset or place. The same supporting phrase must name both + the actor and facility. The application + admits the candidate only when a separate extraction pass has a matching + ROLES actor and a matching PROJECTS row. Do not suppress an explicit source relation + merely because those separate sections are not shown in this + request. Never use this planned predicate for a facility the source says is + already operating; - emit separate organization-to-project rows when the source explicitly assigns different organizations different project responsibilities; - do not turn attendance, an affiliation field, or a project mention alone @@ -1324,6 +1334,56 @@ def _parse_plain_semantic_relationships(content: str) -> tuple[SemanticRelations return tuple(relationships) +def _normalize_evidence_name(value: str) -> str: + """Normalize a source name for evidence-presence checks without translating it.""" + return " ".join(unicodedata.normalize("NFKC", value).casefold().split()) + + +def _admit_planned_facility_relationships( + relationships: tuple[SemanticRelationship, ...], + roles: tuple[RoleResponsibility, ...], + projects: tuple[ProjectMention, ...], + source_text: str, +) -> tuple[SemanticRelationship, ...]: + """Keep planned-operation claims only when ADR 0142 evidence is complete.""" + role_actors = { + (_normalize_evidence_name(role.actor_name), role.actor_type_code) + for role in roles + if role.actor_type_code in {ACTOR_TYPE_ORGANIZATION, ACTOR_TYPE_TEAM} + } + project_keys = { + normalize_project_key(name) + for project in projects + for name in (project.project_name, project.canonical_name) + } + normalized_source = _normalize_evidence_name(source_text) + admitted: list[SemanticRelationship] = [] + for relationship in relationships: + if relationship.predicate_code != "lw_plans_to_operate": + admitted.append(relationship) + continue + evidence = _normalize_evidence_name(relationship.evidence_text) + if ( + relationship.subject_type not in {"organization", "team"} + or relationship.object_type not in {"industrial_asset", "place"} + or ( + _normalize_evidence_name(relationship.subject_name), + { + "organization": ACTOR_TYPE_ORGANIZATION, + "team": ACTOR_TYPE_TEAM, + }.get(relationship.subject_type), + ) + not in role_actors + or normalize_project_key(relationship.object_name) not in project_keys + or _normalize_evidence_name(relationship.subject_name) not in evidence + or _normalize_evidence_name(relationship.object_name) not in evidence + or evidence not in normalized_source + ): + continue + admitted.append(relationship) + return tuple(admitted) + + def _parse_plain_summary_details( content: str, *, @@ -2002,7 +2062,12 @@ def summarize_with_hints( relations_content = chat_completion_content(relations_body) if re.search(r"(?im)^\s*RELATIONS\s*:", relations_content) is None: raise ValueError("summary relation response did not match the required format") - semantic_relationships = _parse_plain_semantic_relationships(relations_content) + semantic_relationships = _admit_planned_facility_relationships( + _parse_plain_semantic_relationships(relations_content), + roles, + projects, + f"{post_title}\n{post_body}", + ) return PostSummary( korean_summary=korean_summary, key_events=key_events, diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 4644e42d4..e8726bb77 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -28,11 +28,14 @@ sample_records, ) from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, ContextualOrchestratorPostSummaryClient, NullPostSummaryClient, + ProjectMention, RoleResponsibility, SemanticRelationship, _SUMMARY_REQUEST_PROMPT_TEMPLATE, + _admit_planned_facility_relationships, _formalize_korean_summary, _parse_optional_project_key, _parse_plain_quantitative_observations, @@ -584,6 +587,11 @@ def fake_post_json(url, payload, *, headers, timeout): assert "different product family in a nearby list is not a substitute" in " ".join( relations_prompt.split() ) + assert "lw_plans_to_operate" in relations_prompt + assert "same supporting phrase must name both" in relations_prompt + assert "matching PROJECTS row" in relations_prompt + assert "separate extraction pass" in relations_prompt + assert "Do not suppress an explicit source relation" in relations_prompt assert summary.roles_and_responsibilities[0].actor_name == "Jordan Hale" assert summary.project_mentions[0].canonical_name == "hvdc-pilot" assert summary.semantic_relationships == ( @@ -599,6 +607,128 @@ def fake_post_json(url, payload, *, headers, timeout): ) +def _planned_facility_details( + *, + actor_type: str = "organization", + roles: str | None = None, + projects: str = ( + "Aurora Charging Hub | Aurora Charging Hub | Aurora Charging Hub plan | 0.95" + ), + object_type: str = "industrial_asset", + evidence: str = "Synthetic Utility plans to operate Aurora Charging Hub", +) -> str: + roles = roles or f"Synthetic Utility | 운영 계획 담당 | {actor_type} | NONE" + return ( + f"ROLES:\n{roles}\nPROJECTS:\n{projects}\nRELATIONS:\n" + f"Synthetic Utility | {actor_type} | lw_plans_to_operate | " + f"Aurora Charging Hub | {object_type} | {evidence} | 0.93" + ) + + +def _summarize_with_details(monkeypatch, details: str): + responses = iter( + ( + {"choices": [{"message": {"content": "합성 요약\nKEY EVENTS: 운영 계획"}}]}, + {"choices": [{"message": {"content": details}}]}, + {"choices": [{"message": {"content": details}}]}, + ) + ) + monkeypatch.setattr( + "lineageweave.post_summary.post_json", + lambda *args, **kwargs: next(responses), + ) + + return ContextualOrchestratorPostSummaryClient( + "https://orchestrator.test", "token" + ).summarize("Synthetic plan", "Synthetic Utility plans to operate Aurora Charging Hub") + + +@pytest.mark.parametrize("object_type", ("industrial_asset", "place")) +@pytest.mark.parametrize("actor_type", ("organization", "team")) +def test_summary_admits_explicit_planned_facility_relation_with_project_backing( + monkeypatch, actor_type: str, object_type: str +) -> None: + summary = _summarize_with_details( + monkeypatch, + _planned_facility_details(actor_type=actor_type, object_type=object_type), + ) + + assert len(summary.semantic_relationships) == 1 + relation = summary.semantic_relationships[0] + assert relation.subject_name == "Synthetic Utility" + assert relation.subject_type == actor_type + assert relation.predicate_code == "lw_plans_to_operate" + assert relation.object_name == "Aurora Charging Hub" + assert relation.object_type == object_type + assert relation.evidence_text == "Synthetic Utility plans to operate Aurora Charging Hub" + assert relation.confidence == 0.93 + + +@pytest.mark.parametrize( + "details", + ( + pytest.param( + _planned_facility_details(projects="NONE"), + id="missing-project-backing", + ), + pytest.param( + _planned_facility_details(roles="NONE"), + id="missing-role-actor", + ), + pytest.param( + _planned_facility_details(evidence="Aurora Charging Hub plan"), + id="evidence-missing-actor", + ), + pytest.param( + _planned_facility_details( + evidence="Synthetic Utility will manage Aurora Charging Hub" + ), + id="evidence-not-in-source", + ), + pytest.param( + _planned_facility_details(object_type="project"), + id="wrong-facility-type", + ), + ), +) +def test_summary_drops_planned_facility_relation_when_admission_evidence_is_missing( + monkeypatch, details: str +) -> None: + assert _summarize_with_details(monkeypatch, details).semantic_relationships == () + + +def test_planned_facility_admission_normalizes_unicode_equivalent_actor_names() -> None: + relationship = SemanticRelationship( + subject_name="Synthetic Utility", + subject_type="organization", + predicate_code="lw_plans_to_operate", + object_name="Aurora Charging Hub", + object_type="industrial_asset", + evidence_text="Synthetic Utility plans to operate Aurora Charging Hub", + confidence=0.93, + ) + + assert _admit_planned_facility_relationships( + (relationship,), + ( + RoleResponsibility( + "Synthetic Utility", + "운영 계획 담당", + ACTOR_TYPE_ORGANIZATION, + ), + ), + ( + ProjectMention( + "Aurora Charging Hub", + "Aurora Charging Hub", + "Aurora Charging Hub plan", + 0.95, + ), + ), + relationship.evidence_text, + ) == (relationship,) + + def test_summary_details_parse_failure_does_not_expose_provider_response(monkeypatch) -> None: """Malformed provider output gets a stable parser error, never raw text.""" responses = iter(