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 CHANGELOG.d/2.16.1-planned-facility-relation.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 22 additions & 11 deletions docs/adr/0142-planned-facility-project-evidence.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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/
6 changes: 6 additions & 0 deletions docs/ontology/lineageweave-kg.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Comment thread
seonghobae marked this conversation as resolved.

:subjectName a owl:DatatypeProperty ;
rdfs:domain :SemanticRelationship ;
rdfs:range xsd:string .
Expand Down Expand Up @@ -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 ] .

Expand Down
69 changes: 67 additions & 2 deletions lineageweave/post_summary.py
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"lw_has_result",
"lw_has_condition",
"lw_inferred_from",
"lw_plans_to_operate",
Comment thread
seonghobae marked this conversation as resolved.
"lw_responsible_for",
"lw_supports",
}
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
seonghobae marked this conversation as resolved.
):
continue
admitted.append(relationship)
return tuple(admitted)


def _parse_plain_summary_details(
content: str,
*,
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions tests/test_post_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 == (
Expand All @@ -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(
Expand Down