From 27f925a3433cb138ab1e0216c6aa781ea74ed8da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:38:10 +0900 Subject: [PATCH 001/117] feat: R&R's named actor is a PROV-O Agent, not always a person (0.68.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed against real Milestone 2 SAP CRM VOC data: post_summary.py's R&R extraction forced every named actor into a person slot, but real business correspondence routinely names an organization acting in its own name ("당사," "SEWA," "Siemens," "GECO"), not an individual. - RoleResponsibility.actor_name (renamed from person_name) gains actor_type_code (prov_person/prov_organization, W3C PROV-O grounded: Lebo, Sahoo, & McGuinness, 2013) and an LLM-inferred affiliated_organization_name for person actors -- a bare name without an employer is hard to place. - Ontology: :RoleActorPerson rdfs:subClassOf prov:Person, :RoleActorOrganization rdfs:subClassOf prov:Organization -- genuine subclasses of the real external PROV-O classes, distinct from the ontology's existing :Person (a cataloged Keyman with a stable person_id; an R&R actor is a free-text name with no cataloged identity). - migrations/0012_role_responsibility_agent_type.sql renames the column via RENAME COLUMN (preserves existing rows), not a drop/recreate. - Popup R&R list shows a Person/Organization badge and the inferred affiliation; only a person actor still links to the Keyman panel. - Also fixes a real deployment gap found via browser E2E testing: migrations 0005-0011 had accumulated on main without ever being applied to the long-running demo Postgres volume, surfacing as CORS-looking failures (missing-table 500s lose their CORS header) on Evaluate, Reports, Summary, and Chat. ADR 0006. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 28 +++++ CHANGELOG.md | 23 ++++ backend/app/post_summary_ingestion.py | 45 +++++-- backend/tests/test_api.py | 23 ++-- ...0006-role-responsibility-agent-ontology.md | 113 ++++++++++++++++++ docs/ontology/lineageweave-kg.ttl | 42 ++++++- frontend/package.json | 2 +- frontend/src/App.css | 23 ++++ frontend/src/App.test.tsx | 14 ++- frontend/src/App.tsx | 17 ++- frontend/src/api.ts | 4 +- lineageweave/__init__.py | 2 +- lineageweave/post_summary.py | 83 +++++++++++-- migrations/0001_initial_schema.sql | 13 +- .../0012_role_responsibility_agent_type.sql | 31 +++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 13 +- tests/test_ontology.py | 45 ++++++- tests/test_post_summary.py | 44 ++++++- 19 files changed, 509 insertions(+), 58 deletions(-) create mode 100644 docs/adr/0006-role-responsibility-agent-ontology.md create mode 100644 migrations/0012_role_responsibility_agent_type.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95d..754ebed59 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -682,3 +682,31 @@ checks a well-known public foundation name ("Mozilla Foundation") against a deliberately fabricated one in the same request, asserting the former comes back `verify_corroborated` with a real evidence URL and the latter `verify_uncorroborated` with none. + +## Phase 7: R&R's named actor is a PROV-O Agent, not always a person + +Confirmed against real Milestone 2 SAP CRM VOC data, not a hypothetical: +`post_summary.py`'s R&R extraction forced every named actor into a +person slot, but real business correspondence routinely names an +organization acting in its own name ("당사" [our company], "SEWA," +"Siemens," "GECO"), not an individual. See +[ADR 0006](docs/adr/0006-role-responsibility-agent-ontology.md). + +Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013): +`RoleResponsibility` (renamed field `actor_name`, was `person_name` -- +the field can hold an organization's name now, so "person" in the name +would be wrong) gains `actor_type_code` (`prov_person` / +`prov_organization`, defaulting to person when the model omits it) and +`affiliated_organization_name` (an LLM-inferred affiliation for a +person actor, since a bare name without an employer is hard to place). +The ontology gains `:RoleActorPerson rdfs:subClassOf prov:Person` and +`:RoleActorOrganization rdfs:subClassOf prov:Organization` -- genuine +subclasses of the real external PROV-O classes (imported via the +`prov:` namespace), kept distinct from the ontology's existing `:Person` +(node_type's cataloged Keyman with a stable `person_id`) since an R&R +actor is a free-text name with no cataloged identity of its own. +`migrations/0012_role_responsibility_agent_type.sql` renames the +`post_summary_role` column via `RENAME COLUMN` (preserves existing +rows) rather than a drop/recreate. The popup's R&R list shows a +Person/Organization badge and the inferred affiliation; only a person +actor still links to the Keyman panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f014d05a..48b79dfd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.68.0] - 2026-08-14 + +### Changed + +- R&R's named actor is no longer forced into a person slot. A real + business post can name an organization acting in its own name + ("당사," "SEWA," "Siemens," "GECO"), not an individual -- + `RoleResponsibility.actor_name` (renamed from `person_name`) now + carries `actor_type_code` (Person / Organization, W3C PROV-O + grounded) and an LLM-inferred `affiliated_organization_name` for + person actors. The popup's R&R list shows a Person/Organization + badge and the inferred affiliation; only a person actor still links + to the Keyman panel. See ADR 0006. + +### Fixed + +- Applied migrations 0005-0011 (post evaluation, period reports, FIPC + linking, shared metric bank, report item information, persisted + chat) to the long-running demo database -- these had accumulated on + `main` without ever being applied to the running demo Postgres + volume, surfacing as CORS-looking failures (missing-table 500s + without CORS headers) on Evaluate, Reports, Summary, and Chat. + ## [0.67.0] - 2026-08-14 ### Added diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 7e7569d68..366195a3c 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -7,6 +7,7 @@ import asyncpg from lineageweave.fixtures import fixture_thread_cast +from lineageweave.ontology import ontology_annotations from lineageweave.post_summary import PostSummary, RoleResponsibility @@ -23,8 +24,8 @@ async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dic post_id, ) roles = await conn.fetch( - "select person_name, responsibility from post_summary_role " - "where post_id = $1 order by person_name", + "select actor_name, responsibility, actor_type_code, affiliated_organization_name " + "from post_summary_role where post_id = $1 order by actor_name", post_id, ) return { @@ -32,7 +33,13 @@ async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dic "korean_summary": header["korean_summary"], "key_events": [row["event_text"] for row in events], "roles_and_responsibilities": [ - {"person_name": row["person_name"], "responsibility": row["responsibility"]} + { + "actor_name": row["actor_name"], + "responsibility": row["responsibility"], + "actor_type_code": row["actor_type_code"], + "affiliated_organization_name": row["affiliated_organization_name"], + **ontology_annotations(row["actor_type_code"]), + } for row in roles ], } @@ -55,10 +62,14 @@ async def persist_post_summary(conn: asyncpg.Connection, post_id: str, summary: ) for role in summary.roles_and_responsibilities: await conn.execute( - "insert into post_summary_role (post_id, person_name, responsibility) values ($1, $2, $3)", + "insert into post_summary_role " + "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "values ($1, $2, $3, $4, $5)", post_id, - role.person_name, + role.actor_name, role.responsibility, + role.actor_type_code, + role.affiliated_organization_name, ) payload = await fetch_persisted_summary(conn, post_id) if payload is None: @@ -75,8 +86,16 @@ def seeded_demo_summary() -> PostSummary: ), key_events=("출하 지연 후속 연락",), roles_and_responsibilities=( - RoleResponsibility(person_name="Ada West", responsibility="일정 확인 후속"), - RoleResponsibility(person_name="Priya Nair", responsibility="고객 측 수신"), + RoleResponsibility( + actor_name="Ada West", + responsibility="일정 확인 후속", + affiliated_organization_name="Demo Corp", + ), + RoleResponsibility( + actor_name="Priya Nair", + responsibility="고객 측 수신", + affiliated_organization_name="Northridge Grid", + ), ), ) @@ -108,7 +127,11 @@ def _roles_for_fixture(post_title: str) -> tuple[RoleResponsibility, ...]: if cast is None or not cast.person_names: return () return tuple( - RoleResponsibility(person_name=name, responsibility=responsibility) + RoleResponsibility( + actor_name=name, + responsibility=responsibility, + affiliated_organization_name=_FIXTURE_ROLE_AFFILIATION.get(name), + ) for name in cast.person_names if (responsibility := _FIXTURE_ROLE_RESPONSIBILITY.get(name)) ) @@ -120,6 +143,12 @@ def _roles_for_fixture(post_title: str) -> tuple[RoleResponsibility, ...]: "Jordan Hale": "사양 검토", } +_FIXTURE_ROLE_AFFILIATION = { + "Ada West": "Demo Corp", + "Priya Nair": "Northridge Grid", + "Jordan Hale": "Westfield Power", +} + def _summary(korean: str, *events: str) -> PostSummary: return PostSummary(korean_summary=korean, key_events=events) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 74ab44701..0d3639b1e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -142,7 +142,9 @@ def seeded_db(demo_analyst_token): "('relation_verification_status', 'verify_uncorroborated', 'No corroborating evidence found'), " "('evaluation_criterion', 'general_sentiment_positive', 'Constructive stance'), " "('evaluation_criterion', 'general_sentiment_negative', 'Negative stance'), " - "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity')" + "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity'), " + "('prov_agent_type', 'prov_person', 'Person'), " + "('prov_agent_type', 'prov_organization', 'Organization')" ) cur.execute( "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " @@ -363,8 +365,9 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token (seeded_db["public_post_id"],), ) cur.execute( - "insert into post_summary_role (post_id, person_name, responsibility) " - "values (%s, 'Ada West', '후속 연락')", + "insert into post_summary_role " + "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "values (%s, 'Ada West', '후속 연락', 'prov_person', 'Demo Corp')", (seeded_db["public_post_id"],), ) finally: @@ -378,9 +381,13 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token body = response.json() assert body["korean_summary"] == "저장된 한국어 요약입니다." assert body["key_events"] == ["저장된 이벤트"] - assert body["roles_and_responsibilities"] == [ - {"person_name": "Ada West", "responsibility": "후속 연락"} - ] + assert len(body["roles_and_responsibilities"]) == 1 + role = body["roles_and_responsibilities"][0] + assert role["actor_name"] == "Ada West" + assert role["responsibility"] == "후속 연락" + assert role["actor_type_code"] == "prov_person" + assert role["affiliated_organization_name"] == "Demo Corp" + assert role["ontology_label"] == "Role actor (person)" def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, seeded_db) -> None: @@ -407,7 +414,7 @@ def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, s body = response.json() assert "에이다" in body["korean_summary"] assert body["key_events"] - assert any(role["person_name"] == "Ada West" for role in body["roles_and_responsibilities"]) + assert any(role["actor_name"] == "Ada West" for role in body["roles_and_responsibilities"]) def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_token, seeded_db) -> None: @@ -466,7 +473,7 @@ def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_toke assert fork.status_code == 200, fork.text assert "재협상" in fork.json()["korean_summary"] assert fork.json()["key_events"] - fork_roles = {role["person_name"] for role in fork.json()["roles_and_responsibilities"]} + fork_roles = {role["actor_name"] for role in fork.json()["roles_and_responsibilities"]} assert fork_roles == {"Ada West", "Priya Nair"} calendar = client.get( diff --git a/docs/adr/0006-role-responsibility-agent-ontology.md b/docs/adr/0006-role-responsibility-agent-ontology.md new file mode 100644 index 000000000..45b8008e6 --- /dev/null +++ b/docs/adr/0006-role-responsibility-agent-ontology.md @@ -0,0 +1,113 @@ +# ADR 0006 — R&R's named actor is a PROV-O Agent, not always a person + +**Decision status:** Accepted +**Date:** 2026-08-14 + +## Context + +The product brief flags a real gap in `post_summary.py`'s R&R (roles & +responsibilities) extraction, confirmed against real SAP CRM VOC records +during Milestone 2 analysis: the acting party a post's text names is not +always a person. Real business correspondence routinely names an +organization acting in its own name -- "당사" (our company), "SEWA," +"Siemens," "GECO" -- not a named individual. The brief's own wording: +"주체가 사람이 아니라 기관 ... 으로 나타나는 경우도 있으므로 일반적인 +표준 Ontology로 조치할 것" (the acting subject sometimes appears as an +organization rather than a person, so handle it with a general standard +Ontology), plus "사람만 넣어서는 소속 기관을 이해하기 어려우므로 소속 +기관 추론까지 포함시킬 것" (a bare person name is hard to place without +their organization, so infer the affiliation too). + +Before this change, `RoleResponsibility.person_name` had no way to +express "this actor is an organization" -- every entry was forced into +a person slot, and an organization actor's name would sit +indistinguishable from an unresolved person. + +## Decision + +Ground the distinction in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013): +`prov:Agent` is the general acting-party class, with `prov:Person` and +`prov:Organization` as its two recognized subclasses -- an existing, +widely-adopted standard for exactly this "who/what acted" provenance +question, not a bespoke local invention. + +`RoleResponsibility` (`lineageweave/post_summary.py`) gains: +- `actor_name` (renamed from `person_name` -- the field can now hold an + organization's name too, so "person" in the field name would be + actively wrong). +- `actor_type_code`: `prov_person` / `prov_organization` + (`common_lookup_value` category `prov_agent_type`), defaulting to + `prov_person` when the LLM's response omits the field, matching this + repo's existing degrade-gracefully-not-fail discipline. +- `affiliated_organization_name`: for a person actor, the organization + the text names or clearly implies they work for, inferred by the same + LLM call rather than left for a human to cross-reference against the + Keyman panel separately. `None` when the text gives nothing to infer, + or when the actor is itself an organization (its own name already + answers "which organization"). + +The LLM prompt now explicitly instructs the model to decide +person-vs-organization per actor rather than defaulting every named +actor to a person, and to give an affiliation when the text supports +one. + +Ontology (`docs/ontology/lineageweave-kg.ttl`, extending +[ADR 0004](0004-knowledge-graph-ontology.md)'s vocabulary): +`:RoleActorPerson rdfs:subClassOf prov:Person` and +`:RoleActorOrganization rdfs:subClassOf prov:Organization`, each +carrying the `:lookupCode` annotation linking it to the matching +`common_lookup_value` row -- these are genuinely subclasses of the real +external PROV-O classes (imported via the `prov:` prefix), not +same-named local terms that merely resemble the standard. Kept distinct +from the ontology's existing `:Person` (node_type's `node_person`, +i.e. a cataloged Keyman with a stable `person_id`): an R&R actor is a +free-text name with no cataloged identity of its own, and may not even +resolve to a Keyman row. + +Persistence: `post_summary_role` gains `actor_type_code` (FK to +`common_lookup_value`, default `prov_person`) and +`affiliated_organization_name`; `person_name` is renamed to +`actor_name` via `migrations/0012_role_responsibility_agent_type.sql`'s +`ALTER TABLE ... RENAME COLUMN` (preserves every existing row's data, +unlike a drop/recreate) plus the two new `ADD COLUMN IF NOT EXISTS` +statements, with `migrations/0001_initial_schema.sql` updated directly +for a fresh install, matching this repo's established pattern (e.g. +ADR 0005's `verification_status_code` additions). + +UI: the popup's R&R list (`frontend/src/App.tsx`) shows a +Person/Organization badge per actor and the inferred affiliation in +parentheses; only a person actor is still linked to the Keyman panel +(an organization actor has no `person_id` to link to). + +## Consequences + +- `RoleResponsibility.person_name` is a breaking rename to `actor_name` + across the JSON wire contract (`GET /api/posts/{id}/summary`), the + DB column, and every call site. Accepted because the field's old name + was actively misleading once an organization actor is a real, + intended value, not a hypothetical edge case -- confirmed against + real Milestone 2 SAP CRM VOC data. +- `prov_agent_type` is a `common_lookup_value` category seeded by its + own migration file (0012), not literally embedded in + `scripts/seed_demo_data.py`'s SQL string the way ADR 0004's original + five covered categories are -- `tests/test_ontology.py`'s round-trip + check reads 0012's file content alongside the seed script's own text + so this still closes the loop, rather than being silently excluded + the way `evaluation_criterion` / `relation_verification_status` + currently are. +- The affiliation inference is opportunistic, not authoritative: it is + a same-request LLM guess from the post's own text, not resolved + against `corporate_entity` the way Keyman affiliations are (see + `lineageweave/corporate_hierarchy_resolution.py`). A future slice + could route it through the same resolver if real usage shows the + free-text name needs matching back to a cataloged organization. + +## Related + +Extends [ADR 0004](0004-knowledge-graph-ontology.md)'s Ontology/ +Semantic-Layer vocabulary and reuses its round-trip enforcement +mechanism (`tests/test_ontology.py`). + +## 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/ diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index c892d609f..884368164 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -4,23 +4,26 @@ @prefix rdfs: . @prefix skos: . @prefix xsd: . +@prefix prov: . ################################################################# # LineageWeave Knowledge Graph Ontology # # The formal OWL 2 / RDFS / SKOS vocabulary for the -# `knowledge_graph_edge` table's node/edge types and the +# `knowledge_graph_edge` table's node/edge types, the # `entity_relationship_type` / `person_side` / `corporate_entity_level` -# controlled vocabularies in migrations/0001_initial_schema.sql. +# controlled vocabularies in migrations/0001_initial_schema.sql, and +# `post_summary_role.actor_type_code` (migrations/0012). # # `knowledge_graph_edge` (source_node_type_code, source_node_id) -- # [edge_type_code] --> (target_node_type_code, target_node_id) is # already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014); # this file is the formal semantic layer over it -- PostgreSQL stays # the source of record. See docs/adr/0004-knowledge-graph-ontology.md -# for the full design rationale, and tests/test_ontology.py for the -# round-trip check that every code below actually exists as a -# common_lookup_value row, and vice versa. +# for the KG design rationale, docs/adr/0006-role-responsibility-agent-ontology.md +# for the R&R actor-type rationale (grounded in W3C PROV-O), and +# tests/test_ontology.py for the round-trip check that every code below +# actually exists as a common_lookup_value row, and vice versa. # # Every custom term carries a :lookupCode annotation naming the exact # `common_lookup_value.lookup_code` it corresponds to -- that literal @@ -29,7 +32,7 @@ a owl:Ontology ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; - rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, and corporate_entity_level controlled vocabularies." . + rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." . :lookupCode a owl:AnnotationProperty ; rdfs:label "lookup code" ; @@ -152,3 +155,30 @@ :GroupLevel skos:narrower :CompanyLevel . :CompanyLevel skos:narrower :PlantLevel . + +################################################################# +# Classes -- prov_agent_type (post_summary_role.actor_type_code) +# +# A post's R&R (roles & responsibilities) actor is not always a person +# -- real business correspondence routinely names an organization +# acting in its own name ("당사" [our company], "SEWA," "Siemens," +# "GECO"). Grounded directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, +# 2013): prov:Agent is the general acting-party class, with prov:Person +# and prov:Organization its two recognized subclasses. These are +# distinct from :Person / :OurSidePerson / :CounterpartyPerson above: +# node_type's :Person is a cataloged_person row with a stable person_id +# a Keyman panel links to; an R&R actor is a free-text name with no +# cataloged identity of its own (it may not even resolve to a Keyman). +################################################################# + +:RoleActorPerson a owl:Class ; + rdfs:subClassOf prov:Person ; + rdfs:label "Role actor (person)" ; + rdfs:comment "An R&R actor that is a named individual, per prov:Person." ; + :lookupCode "prov_person" . + +:RoleActorOrganization a owl:Class ; + rdfs:subClassOf prov:Organization ; + rdfs:label "Role actor (organization)" ; + rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ; + :lookupCode "prov_organization" . diff --git a/frontend/package.json b/frontend/package.json index c121006c3..0e8e1c3e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.67.0", + "version": "0.68.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index c69099258..177fcf7c7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -245,6 +245,29 @@ border-radius: 1rem; } +.actor-type-badge { + font-size: 0.7rem; + padding: 0.05rem 0.4rem; + border-radius: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.actor-type-prov_person { + background: #e8eaf6; + color: #303f9f; +} + +.actor-type-prov_organization { + background: #fff3e0; + color: #e65100; +} + +.rr-affiliation { + opacity: 0.7; + font-size: 0.9rem; +} + .verification-verify_pending { background: #e0e0e0; color: #444; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f2d9ee8b4..747f4220a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -453,8 +453,18 @@ describe("App, authenticated", () => { korean_summary: "이것은 요약입니다.", key_events: ["첫 번째 이벤트"], roles_and_responsibilities: [ - { person_name: "Ada West", responsibility: "우리 측 후속" }, - { person_name: "Priya Nair", responsibility: "고객 측 수신" }, + { + actor_name: "Ada West", + responsibility: "우리 측 후속", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + }, + { + actor_name: "Priya Nair", + responsibility: "고객 측 수신", + actor_type_code: "prov_person", + affiliated_organization_name: "Northridge Grid", + }, ], }), ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ef388524..be899929b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1176,13 +1176,19 @@ function PostDetailPopup({

R&R

    {summary.roles_and_responsibilities.map((rr, i) => { - const person = keymen?.find((row) => row.person_name === rr.person_name); + const isPerson = rr.actor_type_code === "prov_person"; + const person = isPerson + ? keymen?.find((row) => row.person_name === rr.actor_name) + : undefined; return (
  • + + {isPerson ? "Person" : "Organization"} + {" "} {person ? ( ) : ( - {rr.person_name} + {rr.actor_name} + )} + {rr.affiliated_organization_name && ( + ({rr.affiliated_organization_name}) )} : {rr.responsibility}
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b6692776..03d7a71ee 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -83,8 +83,10 @@ export interface RelatedNode { } export interface PostRoleResponsibility { - person_name: string; + actor_name: string; responsibility: string; + actor_type_code: string; + affiliated_organization_name: string | null; } export interface PostAiSummary { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index a4119bf0e..90bd896b0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.67.0" +__version__ = "0.68.0" diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index 4974268c2..e229081c6 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -14,7 +14,20 @@ bullet, not a summary sentence. - **R&R (roles & responsibilities)**: semantic role labeling (Gildea & Jurafsky, 2002) -- who did what, framed as an agent/action/responsibility - triple per person named in the post, not prose. + triple per named actor in the post, not prose. The actor is not always + a person: real business correspondence routinely names an organization + as the acting party ("당사" [our company], "SEWA," "Siemens," "GECO"), + not an individual. Modeling every actor as a person loses this + distinction and makes an organization's affiliation-less name look + like an unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & + McGuinness, 2013): ``prov:Agent`` is the general acting-party class, + with ``prov:Person`` and ``prov:Organization`` as its two recognized + subclasses -- the same distinction ``keyman_extraction``'s two-sided + (our-side/counterparty) person model already keeps for *people*, one + level up. A person actor also gets an inferred + ``affiliated_organization_name`` where the text supports it: a bare + person name without who they work for is hard to place in the same + way an unresolved organization name is. Same pluggable-client, never-fake-a-missing-channel discipline as every other Phase 2/3 channel: :class:`NullPostSummaryClient` makes the channel @@ -30,13 +43,35 @@ from .http_client import post_json +# common_lookup_value category "prov_agent_type" -- PROV-O's prov:Person / +# prov:Organization, the two subclasses of prov:Agent this repo models. +ACTOR_TYPE_PERSON = "prov_person" +ACTOR_TYPE_ORGANIZATION = "prov_organization" +_VALID_ACTOR_TYPE_CODES = frozenset({ACTOR_TYPE_PERSON, ACTOR_TYPE_ORGANIZATION}) + @dataclass(frozen=True) class RoleResponsibility: - """One person's role/responsibility as derived from the post text.""" + """One actor's role/responsibility as derived from the post text. + + Attributes: + actor_name: the person's or organization's name as named in the + text. + responsibility: what they are responsible for or did. + actor_type_code: ``ACTOR_TYPE_PERSON`` or ``ACTOR_TYPE_ORGANIZATION`` + (PROV-O ``prov:Person`` / ``prov:Organization``) -- which this + actor actually is, not assumed to be a person. + affiliated_organization_name: for a person actor, the + organization the text says or implies they work for, when + the text supports it; ``None`` when the text gives no + affiliation to infer, or for an organization actor (its own + name already answers "which organization"). + """ - person_name: str + actor_name: str responsibility: str + actor_type_code: str = ACTOR_TYPE_PERSON + affiliated_organization_name: str | None = None @dataclass(frozen=True) @@ -81,16 +116,27 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: 2. A list of key events: discrete, datable occurrences mentioned in the post (e.g. "a bid was submitted", "a delivery date was confirmed"), each as a short phrase. -3. A list of roles & responsibilities: for each named person in the post, - one short phrase describing what they are responsible for or did, - according to the text. +3. A list of roles & responsibilities: for each named actor in the post + -- a person OR an organization acting in its own name (e.g. "당사" + [our company], "SEWA," "Siemens," "GECO") -- one short phrase + describing what they are responsible for or did, according to the + text. Do not force an organization's name into a person slot: decide + whether each actor is a person or an organization, and say which. + When the actor is a person and the text names or clearly implies who + they work for, also give that organization's name -- a bare person + name without their employer is hard to place. Reply with ONLY a JSON object (no markdown fences, no prose) with exactly these fields: "korean_summary": string "key_events": array of strings - "roles_and_responsibilities": array of objects, each with - "person_name" and "responsibility" string fields + "roles_and_responsibilities": array of objects, each with: + "actor_name": string + "responsibility": string + "actor_type": exactly "person" or "organization" + "affiliated_organization_name": string, or null when the actor is an + organization, or when the actor is a person and the text gives no + affiliation to infer Post title: {title} Post body: {body} @@ -134,15 +180,32 @@ def parse_summary_response(content: str) -> PostSummary | None: for entry in rr_raw: if not isinstance(entry, dict): continue - name = entry.get("person_name") + name = entry.get("actor_name") responsibility = entry.get("responsibility") + actor_type_raw = entry.get("actor_type") + actor_type_code = ( + ACTOR_TYPE_ORGANIZATION if actor_type_raw == "organization" else ACTOR_TYPE_PERSON + ) + affiliation_raw = entry.get("affiliated_organization_name") + affiliated_organization_name = ( + affiliation_raw.strip() + if isinstance(affiliation_raw, str) and affiliation_raw.strip() + else None + ) if ( isinstance(name, str) and name.strip() and isinstance(responsibility, str) and responsibility.strip() ): - roles.append(RoleResponsibility(person_name=name.strip(), responsibility=responsibility.strip())) + roles.append( + RoleResponsibility( + actor_name=name.strip(), + responsibility=responsibility.strip(), + actor_type_code=actor_type_code, + affiliated_organization_name=affiliated_organization_name, + ) + ) return PostSummary( korean_summary=korean_summary.strip(), diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 6a2676a4c..c73912195 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -43,7 +43,8 @@ comment on table common_lookup_value is 'Every ENUM-like value in this schema (voc_type, post_visibility, ' 'entity_relationship_type, person_side, edge_type, node_type, ' 'ticket_status, permission, corporate_entity_level, ' - 'relation_verification_status, evaluation_criterion) lives here once. ' + 'relation_verification_status, evaluation_criterion, prov_agent_type) ' + 'lives here once. ' 'lookup_code is unique across all categories -- see the unique(lookup_code) comment.'; -- --------------------------------------------------------------------- @@ -211,11 +212,17 @@ create table post_summary_event ( primary key (post_id, event_ordinal) ); +-- actor_type_code: R&R Ontology, see migrations/0012_role_responsibility_agent_type.sql +-- and ADR 0006 -- a named actor is not always a person (an organization +-- can act in its own name, e.g. "당사," "SEWA"), so this is not folded +-- into person_name's own meaning. create table post_summary_role ( post_id uuid not null references post_summary_result (post_id) on delete cascade, - person_name text not null, + actor_name text not null, responsibility text not null, - primary key (post_id, person_name) + actor_type_code text not null default 'prov_person' references common_lookup_value (lookup_code), + affiliated_organization_name text, + primary key (post_id, actor_name) ); -- Persisted in-popup Q&A. Seed writes a synthetic exchange so diff --git a/migrations/0012_role_responsibility_agent_type.sql b/migrations/0012_role_responsibility_agent_type.sql new file mode 100644 index 000000000..30b483b0b --- /dev/null +++ b/migrations/0012_role_responsibility_agent_type.sql @@ -0,0 +1,31 @@ +-- Roles & responsibilities' named actor is not always a person -- real +-- business correspondence routinely names an organization acting in its +-- own name ("당사" [our company], "SEWA," "Siemens," "GECO"), not an +-- individual. Adds a PROV-O-grounded person/organization distinction +-- (see ADR 0006) plus an inferred affiliated-organization name for +-- person actors. The rename below (person_name -> actor_name) preserves +-- every existing row's data -- a plain RENAME COLUMN, not a drop/recreate +-- -- since a volume that already ran the pre-0006 0001 has real rows +-- under the old name. + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values + ('prov_agent_type', 'prov_person', 'Person', 0), + ('prov_agent_type', 'prov_organization', 'Organization', 1) +on conflict (lookup_code) do nothing; + +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_name = 'post_summary_role' and column_name = 'person_name' + ) then + alter table post_summary_role rename column person_name to actor_name; + end if; +end $$; + +alter table post_summary_role + add column if not exists actor_type_code text not null default 'prov_person' + references common_lookup_value (lookup_code); + +alter table post_summary_role + add column if not exists affiliated_organization_name text; diff --git a/pyproject.toml b/pyproject.toml index d321fcaa0..1b0e694a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.67.0" +version = "0.68.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 72318f33a..59e487d19 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -106,6 +106,7 @@ def seed( cur.execute((migrations / "0009_shared_metric_bank.sql").read_text()) cur.execute((migrations / "0010_report_item_information.sql").read_text()) cur.execute((migrations / "0011_post_chat_result.sql").read_text()) + cur.execute((migrations / "0012_role_responsibility_agent_type.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -404,8 +405,16 @@ def _write_post_summary(cur, post_id, summary) -> None: ) for role in summary.roles_and_responsibilities: cur.execute( - "insert into post_summary_role (post_id, person_name, responsibility) values (%s, %s, %s)", - (post_id, role.person_name, role.responsibility), + "insert into post_summary_role " + "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "values (%s, %s, %s, %s, %s)", + ( + post_id, + role.actor_name, + role.responsibility, + role.actor_type_code, + role.affiliated_organization_name, + ), ) diff --git a/tests/test_ontology.py b/tests/test_ontology.py index 41423ada8..0a85d7360 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -31,12 +31,27 @@ _SEED_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" +# 0012 seeds prov_agent_type via its own migration SQL (ADR 0006), not +# literally embedded in seed_demo_data.py's own source text the way the +# other covered categories are -- read alongside it below so the +# round-trip still sees those two codes. +_PROV_AGENT_TYPE_MIGRATION_PATH = ( + Path(__file__).resolve().parents[1] / "migrations" / "0012_role_responsibility_agent_type.sql" +) + # The categories this ontology covers (ADR 0004's scope). seed_demo_data.py # also seeds categories this ontology deliberately does not model yet # (post_visibility, voc_type, permission, ticket_status) -- those are # real, expected gaps, not a test bug. _ONTOLOGY_COVERED_CATEGORIES = frozenset( - {"node_type", "edge_type", "entity_relationship_type", "person_side", "corporate_entity_level"} + { + "node_type", + "edge_type", + "entity_relationship_type", + "person_side", + "corporate_entity_level", + "prov_agent_type", + } ) _INSERT_TUPLE_PATTERN = re.compile(r"\('([a-z_]+)',\s*'([a-z_]+)'") @@ -44,12 +59,12 @@ def _seeded_lookup_codes_for_covered_categories() -> set[str]: """Every `(lookup_category, lookup_code)` pair seed_demo_data.py's own - SQL literally inserts, filtered to the categories this ontology - covers. Parsed from source, not executed -- this is a static - consistency check between two committed files, not a live-database - test. + SQL, plus 0012's migration SQL, literally inserts, filtered to the + categories this ontology covers. Parsed from source, not executed -- + this is a static consistency check between committed files, not a + live-database test. """ - source = _SEED_SCRIPT_PATH.read_text() + source = _SEED_SCRIPT_PATH.read_text() + _PROV_AGENT_TYPE_MIGRATION_PATH.read_text() return { code for category, code in _INSERT_TUPLE_PATTERN.findall(source) @@ -129,6 +144,24 @@ def test_mentions_property_domain_and_range_match_the_schema() -> None: assert (LW.mentions, RDFS.range, LW.Person) in graph +def test_prov_agent_type_terms_resolve_and_subclass_real_prov_o() -> None: + """Beyond the generic round-trip above: the two prov_agent_type terms + must actually subclass the real external W3C PROV-O classes, not + just carry a matching :lookupCode -- the whole point of grounding + this in a standard ontology is that :RoleActorPerson really is a + prov:Person, not a same-named local invention. + """ + from rdflib import URIRef + from rdflib.namespace import Namespace + + prov = Namespace("http://www.w3.org/ns/prov#") + graph = load_ontology() + assert iri_for_lookup_code("prov_person") == str(LW.RoleActorPerson) + assert iri_for_lookup_code("prov_organization") == str(LW.RoleActorOrganization) + assert (LW.RoleActorPerson, RDFS.subClassOf, URIRef(prov.Person)) in graph + assert (LW.RoleActorOrganization, RDFS.subClassOf, URIRef(prov.Organization)) in graph + + def test_corporate_entity_level_hierarchy_is_broadest_first() -> None: """Group is broader than Company is broader than Plant -- the Acme Group -> Acme Electronics Korea -> plant direction the diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 52694bc3b..7812df03e 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -39,13 +39,47 @@ def test_parses_a_well_formed_json_object() -> None: content = ( '{"korean_summary": "회의 후속 조치에 대한 요약입니다.", ' '"key_events": ["입찰 워크숍 진행", "검사 일정 확인 요청"], ' - '"roles_and_responsibilities": [{"person_name": "Jordan Hale", "responsibility": "입찰 일정 안내"}]}' + '"roles_and_responsibilities": [{"actor_name": "Jordan Hale", "responsibility": "입찰 일정 안내", ' + '"actor_type": "person", "affiliated_organization_name": "Westfield Power"}]}' ) summary = parse_summary_response(content) assert summary is not None assert summary.korean_summary == "회의 후속 조치에 대한 요약입니다." assert summary.key_events == ("입찰 워크숍 진행", "검사 일정 확인 요청") - assert summary.roles_and_responsibilities[0].person_name == "Jordan Hale" + role = summary.roles_and_responsibilities[0] + assert role.actor_name == "Jordan Hale" + assert role.actor_type_code == "prov_person" + assert role.affiliated_organization_name == "Westfield Power" + + +def test_organization_actor_is_not_forced_into_a_person_slot() -> None: + """A named actor that is genuinely an organization (e.g. our own + company acting in its own name, not a named individual) must parse + as ``prov_organization``, not silently default to person -- the + default only applies when the model omits ``actor_type`` entirely. + """ + content = ( + '{"korean_summary": "당사가 요청 사항을 확인했습니다.", "key_events": [], ' + '"roles_and_responsibilities": [{"actor_name": "당사", "responsibility": "요청 확인", ' + '"actor_type": "organization", "affiliated_organization_name": null}]}' + ) + summary = parse_summary_response(content) + assert summary is not None + role = summary.roles_and_responsibilities[0] + assert role.actor_name == "당사" + assert role.actor_type_code == "prov_organization" + assert role.affiliated_organization_name is None + + +def test_missing_actor_type_defaults_to_person() -> None: + content = ( + '{"korean_summary": "요약", "key_events": [], ' + '"roles_and_responsibilities": [{"actor_name": "Ada West", "responsibility": "후속"}]}' + ) + summary = parse_summary_response(content) + assert summary is not None + assert summary.roles_and_responsibilities[0].actor_type_code == "prov_person" + assert summary.roles_and_responsibilities[0].affiliated_organization_name is None def test_missing_korean_summary_returns_none() -> None: @@ -75,7 +109,7 @@ def test_every_sample_record_has_a_seeded_korean_summary() -> None: assert summary.korean_summary not in seen seen.add(summary.korean_summary) cast = fixture_thread_cast(rec.label) - names = {role.person_name for role in summary.roles_and_responsibilities} + names = {role.actor_name for role in summary.roles_and_responsibilities} if cast is not None and cast.person_names: assert set(cast.person_names) <= names else: @@ -91,7 +125,7 @@ def test_every_sample_record_has_a_seeded_korean_summary() -> None: def test_malformed_roles_entries_are_skipped_not_crashed_on() -> None: content = ( '{"korean_summary": "요약", "key_events": [], ' - '"roles_and_responsibilities": [{"person_name": "Only Name"}, "not an object"]}' + '"roles_and_responsibilities": [{"actor_name": "Only Name"}, "not an object"]}' ) summary = parse_summary_response(content) assert summary is not None @@ -119,5 +153,5 @@ def test_contextual_orchestrator_summarizes_a_non_trivial_post() -> None: # block -- not just an English sentence handed back unchanged. assert any("가" <= ch <= "힣" for ch in summary.korean_summary) assert len(summary.key_events) >= 1 - people_named = {rr.person_name for rr in summary.roles_and_responsibilities} + people_named = {rr.actor_name for rr in summary.roles_and_responsibilities} assert any("Jordan" in name or "Priya" in name for name in people_named) From 7e1c0a27b28b8258a0ba5b40e5af7cdff3b61228 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:09:00 +0900 Subject: [PATCH 002/117] fix: keep R&R examples synthetic and validate actor_type_code Drop real-organization names from docs, prompts, and comments. Seed a synthetic organization actor so the Person/Organization badge is visible without a live LLM, and reject unknown actor_type_code values. --- ARCHITECTURE.md | 7 ++--- CHANGELOG.md | 13 ++------- backend/app/post_summary_ingestion.py | 7 ++++- backend/tests/test_api.py | 5 +++- ...0006-role-responsibility-agent-ontology.md | 24 ++++++--------- docs/ontology/lineageweave-kg.ttl | 6 ++-- frontend/src/App.test.tsx | 8 +++++ lineageweave/post_summary.py | 29 ++++++++++++------- migrations/0001_initial_schema.sql | 2 +- .../0012_role_responsibility_agent_type.sql | 4 +-- tests/test_post_summary.py | 6 ++++ 11 files changed, 62 insertions(+), 49 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 754ebed59..8933e01ab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -685,11 +685,10 @@ and the latter `verify_uncorroborated` with none. ## Phase 7: R&R's named actor is a PROV-O Agent, not always a person -Confirmed against real Milestone 2 SAP CRM VOC data, not a hypothetical: `post_summary.py`'s R&R extraction forced every named actor into a -person slot, but real business correspondence routinely names an -organization acting in its own name ("당사" [our company], "SEWA," -"Siemens," "GECO"), not an individual. See +person slot, but business correspondence routinely names an +organization acting in its own name ("당사" [our company], +"Demo Corp"), not an individual. See [ADR 0006](docs/adr/0006-role-responsibility-agent-ontology.md). Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013): diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b79dfd3..09028f0d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ All notable changes to this project are documented here. Format follows ### Changed -- R&R's named actor is no longer forced into a person slot. A real +- R&R's named actor is no longer forced into a person slot. A business post can name an organization acting in its own name - ("당사," "SEWA," "Siemens," "GECO"), not an individual -- + ("당사," "Demo Corp"), not an individual -- `RoleResponsibility.actor_name` (renamed from `person_name`) now carries `actor_type_code` (Person / Organization, W3C PROV-O grounded) and an LLM-inferred `affiliated_organization_name` for @@ -18,15 +18,6 @@ All notable changes to this project are documented here. Format follows badge and the inferred affiliation; only a person actor still links to the Keyman panel. See ADR 0006. -### Fixed - -- Applied migrations 0005-0011 (post evaluation, period reports, FIPC - linking, shared metric bank, report item information, persisted - chat) to the long-running demo database -- these had accumulated on - `main` without ever being applied to the running demo Postgres - volume, surfacing as CORS-looking failures (missing-table 500s - without CORS headers) on Evaluate, Reports, Summary, and Chat. - ## [0.67.0] - 2026-08-14 ### Added diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 366195a3c..fa39b4033 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -8,7 +8,7 @@ from lineageweave.fixtures import fixture_thread_cast from lineageweave.ontology import ontology_annotations -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ACTOR_TYPE_ORGANIZATION, PostSummary, RoleResponsibility async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dict[str, Any] | None: @@ -96,6 +96,11 @@ def seeded_demo_summary() -> PostSummary: responsibility="고객 측 수신", affiliated_organization_name="Northridge Grid", ), + RoleResponsibility( + actor_name="당사", + responsibility="출하 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), ), ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0d3639b1e..56588b655 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -414,7 +414,10 @@ def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, s body = response.json() assert "에이다" in body["korean_summary"] assert body["key_events"] - assert any(role["actor_name"] == "Ada West" for role in body["roles_and_responsibilities"]) + roles = {role["actor_name"]: role for role in body["roles_and_responsibilities"]} + assert roles["Ada West"]["actor_type_code"] == "prov_person" + assert roles["당사"]["actor_type_code"] == "prov_organization" + assert roles["당사"]["ontology_label"] == "Role actor (organization)" def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_token, seeded_db) -> None: diff --git a/docs/adr/0006-role-responsibility-agent-ontology.md b/docs/adr/0006-role-responsibility-agent-ontology.md index 45b8008e6..8ded02b81 100644 --- a/docs/adr/0006-role-responsibility-agent-ontology.md +++ b/docs/adr/0006-role-responsibility-agent-ontology.md @@ -5,18 +5,13 @@ ## Context -The product brief flags a real gap in `post_summary.py`'s R&R (roles & -responsibilities) extraction, confirmed against real SAP CRM VOC records -during Milestone 2 analysis: the acting party a post's text names is not -always a person. Real business correspondence routinely names an -organization acting in its own name -- "당사" (our company), "SEWA," -"Siemens," "GECO" -- not a named individual. The brief's own wording: -"주체가 사람이 아니라 기관 ... 으로 나타나는 경우도 있으므로 일반적인 -표준 Ontology로 조치할 것" (the acting subject sometimes appears as an -organization rather than a person, so handle it with a general standard -Ontology), plus "사람만 넣어서는 소속 기관을 이해하기 어려우므로 소속 -기관 추론까지 포함시킬 것" (a bare person name is hard to place without -their organization, so infer the affiliation too). +`post_summary.py`'s R&R (roles & responsibilities) extraction treats +the acting party a post's text names as if it were always a person. +Business correspondence routinely names an organization acting in its +own name -- "당사" (our company), "Demo Corp" -- not a named +individual. The product requirement is to handle that with a general +standard ontology, and to infer a person actor's affiliation so a +bare name is not left unplaced. Before this change, `RoleResponsibility.person_name` had no way to express "this actor is an organization" -- every entry was forced into @@ -84,9 +79,8 @@ parentheses; only a person actor is still linked to the Keyman panel - `RoleResponsibility.person_name` is a breaking rename to `actor_name` across the JSON wire contract (`GET /api/posts/{id}/summary`), the DB column, and every call site. Accepted because the field's old name - was actively misleading once an organization actor is a real, - intended value, not a hypothetical edge case -- confirmed against - real Milestone 2 SAP CRM VOC data. + was actively misleading once an organization actor is an intended + value, not a hypothetical edge case. - `prov_agent_type` is a `common_lookup_value` category seeded by its own migration file (0012), not literally embedded in `scripts/seed_demo_data.py`'s SQL string the way ADR 0004's original diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 884368164..032773c4c 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -160,9 +160,9 @@ # Classes -- prov_agent_type (post_summary_role.actor_type_code) # # A post's R&R (roles & responsibilities) actor is not always a person -# -- real business correspondence routinely names an organization -# acting in its own name ("당사" [our company], "SEWA," "Siemens," -# "GECO"). Grounded directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, +# -- business correspondence routinely names an organization acting +# in its own name ("당사" [our company], "Demo Corp"). Grounded +# directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, # 2013): prov:Agent is the general acting-party class, with prov:Person # and prov:Organization its two recognized subclasses. These are # distinct from :Person / :OurSidePerson / :CounterpartyPerson above: diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 747f4220a..36cd81648 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -465,6 +465,12 @@ describe("App, authenticated", () => { actor_type_code: "prov_person", affiliated_organization_name: "Northridge Grid", }, + { + actor_name: "당사", + responsibility: "출하 일정 확정", + actor_type_code: "prov_organization", + affiliated_organization_name: null, + }, ], }), ); @@ -819,6 +825,8 @@ describe("App, authenticated", () => { expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); + expect(screen.getByText("당사").closest("li")).toHaveTextContent("Organization"); + expect(screen.queryByRole("button", { name: "R&R Keyman: 당사" })).not.toBeInTheDocument(); await waitFor(() => expect(screen.getByText("간접")).toBeInTheDocument()); expect(screen.getByText("간접").closest("li")).toHaveTextContent("Linked post"); // The popup Event Lineage is the same A-100 reconstruct DAG as the home diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index e229081c6..65169eed5 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -15,13 +15,13 @@ - **R&R (roles & responsibilities)**: semantic role labeling (Gildea & Jurafsky, 2002) -- who did what, framed as an agent/action/responsibility triple per named actor in the post, not prose. The actor is not always - a person: real business correspondence routinely names an organization - as the acting party ("당사" [our company], "SEWA," "Siemens," "GECO"), - not an individual. Modeling every actor as a person loses this - distinction and makes an organization's affiliation-less name look - like an unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & - McGuinness, 2013): ``prov:Agent`` is the general acting-party class, - with ``prov:Person`` and ``prov:Organization`` as its two recognized + a person: business correspondence routinely names an organization + as the acting party ("당사" [our company], "Demo Corp"), not an + individual. Modeling every actor as a person loses this distinction + and makes an organization's affiliation-less name look like an + unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, + 2013): ``prov:Agent`` is the general acting-party class, with + ``prov:Person`` and ``prov:Organization`` as its two recognized subclasses -- the same distinction ``keyman_extraction``'s two-sided (our-side/counterparty) person model already keeps for *people*, one level up. A person actor also gets an inferred @@ -73,6 +73,13 @@ class RoleResponsibility: actor_type_code: str = ACTOR_TYPE_PERSON affiliated_organization_name: str | None = None + def __post_init__(self) -> None: + if self.actor_type_code not in _VALID_ACTOR_TYPE_CODES: + raise ValueError( + f"actor_type_code must be one of {sorted(_VALID_ACTOR_TYPE_CODES)}, " + f"got {self.actor_type_code!r}" + ) + @dataclass(frozen=True) class PostSummary: @@ -118,10 +125,10 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: each as a short phrase. 3. A list of roles & responsibilities: for each named actor in the post -- a person OR an organization acting in its own name (e.g. "당사" - [our company], "SEWA," "Siemens," "GECO") -- one short phrase - describing what they are responsible for or did, according to the - text. Do not force an organization's name into a person slot: decide - whether each actor is a person or an organization, and say which. + [our company], "Demo Corp") -- one short phrase describing what they + are responsible for or did, according to the text. Do not force an + organization's name into a person slot: decide whether each actor is + a person or an organization, and say which. When the actor is a person and the text names or clearly implies who they work for, also give that organization's name -- a bare person name without their employer is hard to place. diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index c73912195..ddb7da4f8 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -214,7 +214,7 @@ create table post_summary_event ( -- actor_type_code: R&R Ontology, see migrations/0012_role_responsibility_agent_type.sql -- and ADR 0006 -- a named actor is not always a person (an organization --- can act in its own name, e.g. "당사," "SEWA"), so this is not folded +-- can act in its own name, e.g. "당사," "Demo Corp"), so this is not folded -- into person_name's own meaning. create table post_summary_role ( post_id uuid not null references post_summary_result (post_id) on delete cascade, diff --git a/migrations/0012_role_responsibility_agent_type.sql b/migrations/0012_role_responsibility_agent_type.sql index 30b483b0b..a46715fd1 100644 --- a/migrations/0012_role_responsibility_agent_type.sql +++ b/migrations/0012_role_responsibility_agent_type.sql @@ -1,6 +1,6 @@ --- Roles & responsibilities' named actor is not always a person -- real +-- Roles & responsibilities' named actor is not always a person -- -- business correspondence routinely names an organization acting in its --- own name ("당사" [our company], "SEWA," "Siemens," "GECO"), not an +-- own name ("당사" [our company], "Demo Corp"), not an -- individual. Adds a PROV-O-grounded person/organization distinction -- (see ADR 0006) plus an inferred affiliated-organization name for -- person actors. The rename below (person_name -> actor_name) preserves diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 7812df03e..3d8a9ac9a 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -24,6 +24,7 @@ from lineageweave.post_summary import ( ContextualOrchestratorPostSummaryClient, NullPostSummaryClient, + RoleResponsibility, parse_summary_response, ) @@ -71,6 +72,11 @@ def test_organization_actor_is_not_forced_into_a_person_slot() -> None: assert role.affiliated_organization_name is None +def test_unknown_actor_type_code_is_rejected() -> None: + with pytest.raises(ValueError, match="actor_type_code"): + RoleResponsibility(actor_name="Ada West", responsibility="후속", actor_type_code="person") + + def test_missing_actor_type_defaults_to_person() -> None: content = ( '{"korean_summary": "요약", "key_events": [], ' From 57b217d9d31955f91c2b94a954f6923ba6c0e878 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:11:49 +0900 Subject: [PATCH 003/117] feat: capture Keyman job title and avoid same-name merges (v0.69.0) PersonMention now carries an optional job_title extracted by the LLM from role phrasing (e.g. "our legal counsel, Sam Okonkwo"), not just named affiliations. cataloged_person.last_known_job_title persists it, and _upsert_person treats a conflicting stated title as evidence that a same-name match is a different real person rather than a re-mention, so two "Kim Cheolsu"s with different titles get distinct person rows. Keyman panel renders the title next to the person and per-affiliation role_title, which existed in the schema but was never surfaced before. Migration 0013 adds the column additively; 0001_initial_schema.sql bakes it in for fresh installs, matching this repo's existing pattern. --- ARCHITECTURE.md | 27 +++++++++++ CHANGELOG.md | 17 +++++++ backend/app/keyman_ingestion.py | 65 ++++++++++++++++++++------ backend/app/knowledge_graph.py | 3 +- backend/tests/test_api.py | 70 ++++++++++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 6 +++ frontend/src/App.tsx | 6 +++ frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- lineageweave/keyman_extraction.py | 37 ++++++++++++--- migrations/0001_initial_schema.sql | 6 +++ migrations/0013_person_job_title.sql | 11 +++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_keyman_extraction.py | 26 +++++++++++ 16 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 migrations/0013_person_job_title.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8933e01ab..b1110dba5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -709,3 +709,30 @@ actor is a free-text name with no cataloged identity of its own. rows) rather than a drop/recreate. The popup's R&R list shows a Person/Organization badge and the inferred affiliation; only a person actor still links to the Keyman panel. + +## Phase 8: same-name Keymen are not silently merged; titles are captured + +Two different real people can share a name -- `keyman_extraction.py` +never captured a stated job title/position, so nothing distinguished +"Kim Cheolsu, sales manager" from an unrelated "Kim Cheolsu, purchasing +lead" beyond the bare name. `PersonMention` gains `job_title: str | +None`, and the extraction prompt now explicitly asks for one when the +text states it (never left out as a same-name disambiguation signal). + +Persistence, in two places for a reason: `person_affiliation.role_title` +(a schema column that already existed, previously never populated) for +a title tied to a specific organization, and a new +`cataloged_person.last_known_job_title` (`migrations/0013_person_job_title.sql`) +for a title stated without a named organization to attach it to (e.g. +"our legal counsel, Sam Okonkwo" -- `fixtures.ambiguous_keyman_post()`'s +own real example, which has zero affiliated organizations for Sam). +Both feed `_upsert_person`'s disambiguation check +(`backend/app/keyman_ingestion.py`): a same person_name+person_side_code +match is only reused when the new mention's stated title, if any, does +not conflict with a title already on file -- a genuine stated conflict +creates a fresh `cataloged_person` row instead of merging two different +people. A missing title on either side is not treated as a conflict +(titles legitimately change -- a promotion -- and most mentions state no +title at all), so this only splits on an actual stated disagreement, +verified by a real test that two posts naming the same name with +genuinely different stated titles produce two distinct person rows. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09028f0d1..b51aaaa65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.69.0] - 2026-08-14 + +### Added + +- Keyman extraction now captures a stated job title/position + (`PersonMention.job_title`), since two different real people can + share a name and a title is real evidence for telling them apart. + Persisted to `person_affiliation.role_title` (an existing schema + column, previously never populated) and a new + `cataloged_person.last_known_job_title` for a title stated without a + named organization to attach it to. +- `_upsert_person` no longer blindly merges a same-name+side match: a + genuinely conflicting stated title creates a fresh person row instead + of reusing one, verified by a real test with two posts naming the + same name and different titles. +- Keyman panel shows the person's title next to their name. + ## [0.68.0] - 2026-08-14 ### Changed diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index e5451a9bc..23f1027ec 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -6,10 +6,22 @@ `person_affiliation` (N:N, matched to a real `corporate_entity` via similarity-based resolution -- see `lineageweave.corporate_hierarchy_resolution`, so an abbreviation or -trailing legal suffix still resolves, not just an exact string match), -and `post_person_mention`. Finishes by calling -`knowledge_graph.persist_edges_for_post` so the Knowledge Graph edges are -computed from the same write, not a separate manual step. +trailing legal suffix still resolves, not just an exact string match -- +plus `role_title`, a schema column that already existed and was +previously never populated by this pipeline), and `post_person_mention`. +Finishes by calling `knowledge_graph.persist_edges_for_post` so the +Knowledge Graph edges are computed from the same write, not a separate +manual step. + +Same-name disambiguation: `_upsert_person`'s name+side match is a real, +known simplification (documented above), but a stated job title is real +evidence a same-name match should NOT blindly trust -- when the new +mention names a title that conflicts with a title already on file for +that name+side (both stated, genuinely different), a fresh +`cataloged_person` row is created rather than merging two people who +happen to share a name. A person's title legitimately changes over time +(a promotion), so this only splits on an actual stated conflict, never +on a missing title on either side. """ from __future__ import annotations @@ -34,18 +46,41 @@ async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[Co async def _upsert_person(conn: asyncpg.Connection, mention: PersonMention) -> str: - """Reuse a same-name, same-side row so re-extraction does not duplicate.""" - row = await conn.fetchrow( - "select person_id from cataloged_person where person_name = $1 and person_side_code = $2", + """Reuse a same-name, same-side row so re-extraction does not duplicate + -- unless the new mention's stated job title conflicts with a title + already on file for that name+side (`last_known_job_title`, checked + even when this mention names no affiliated organization -- a title + is real same-name-disambiguation evidence on its own, see module + docstring), in which case a same name is not trusted as the same + real person. + """ + candidates = await conn.fetch( + "select person_id, last_known_job_title from cataloged_person " + "where person_name = $1 and person_side_code = $2", mention.person_name, mention.person_side_code, ) - if row is not None: - return str(row["person_id"]) + if candidates and mention.job_title: + for candidate in candidates: + on_file = candidate["last_known_job_title"] + if on_file is not None and on_file != mention.job_title: + continue # stated title conflicts -- do not reuse this row + if on_file is None: + await conn.execute( + "update cataloged_person set last_known_job_title = $1 where person_id = $2", + mention.job_title, + candidate["person_id"], + ) + return str(candidate["person_id"]) + elif candidates: + return str(candidates[0]["person_id"]) + row = await conn.fetchrow( - "insert into cataloged_person (person_name, person_side_code) values ($1, $2) returning person_id", + "insert into cataloged_person (person_name, person_side_code, last_known_job_title) " + "values ($1, $2, $3) returning person_id", mention.person_name, mention.person_side_code, + mention.job_title, ) return str(row["person_id"]) @@ -77,14 +112,18 @@ async def ingest_post_keymen( corporate_entity_id = resolve_corporate_entity(organization_name, candidates) await conn.execute( """ - insert into person_affiliation (person_id, affiliated_organization_name, affiliated_corporate_entity_id) - values ($1, $2, $3) + insert into person_affiliation + (person_id, affiliated_organization_name, affiliated_corporate_entity_id, role_title) + values ($1, $2, $3, $4) on conflict (person_id, affiliated_organization_name) - do update set affiliated_corporate_entity_id = excluded.affiliated_corporate_entity_id + do update set + affiliated_corporate_entity_id = excluded.affiliated_corporate_entity_id, + role_title = coalesce(excluded.role_title, person_affiliation.role_title) """, person_id, organization_name, corporate_entity_id, + mention.job_title, ) if mentions: diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index bb398d141..97f45a9d4 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -63,7 +63,7 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict """Load mentioned people and their affiliations for one post.""" person_rows = await conn.fetch( """ - select p.person_id, p.person_name, p.person_side_code, ppm.mention_context + select p.person_id, p.person_name, p.person_side_code, p.last_known_job_title, ppm.mention_context from post_person_mention ppm join cataloged_person p on p.person_id = ppm.person_id where ppm.post_id = $1 @@ -105,6 +105,7 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict "person_side_code": row["person_side_code"], "person_side_label": side_labels.get(row["person_side_code"], row["person_side_code"]), "mention_context": row["mention_context"], + "last_known_job_title": row["last_known_job_title"], "affiliations": affiliations_by_person.get(str(row["person_id"]), []), } for row in person_rows diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 56588b655..3884d1711 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -957,6 +957,76 @@ def test_extract_keymen_requires_post_admin(client, demo_analyst_token, seeded_d _ORCHESTRATOR_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY") +def test_extract_keymen_does_not_merge_same_name_people_with_conflicting_titles( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Two different real people can share a name -- extracting a second + post that names the same person_name+side but a genuinely different + stated job_title must NOT reuse the first post's cataloged_person row. + A deterministic fake client (not a real orchestrator call) so this + is CI-stable: the point under test is `_upsert_person`'s own SQL + logic, not LLM extraction quality. + """ + from lineageweave.keyman_extraction import COUNTERPARTY, PersonMention + + _grant_post_admin(seeded_db["dsn"]) + + class _FakeClient: + available = True + + def __init__(self, job_title: str) -> None: + self._job_title = job_title + + def extract(self, post_title: str, post_body: str) -> list[PersonMention]: + return [PersonMention(person_name="Kim Cheolsu", person_side_code=COUNTERPARTY, job_title=self._job_title)] + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + post_ids = [] + for title in ("Sales follow-up", "Purchasing follow-up"): + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s " + "returning post_id", + (title, "placeholder body", seeded_db["own_private_post_id"]), + ) + post_ids.append(str(cur.fetchone()[0])) + finally: + admin_conn.close() + + monkeypatch.setattr("backend.app.main._entity_relationship_client", lambda: _FakeClient("unused")) + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeClient("Sales Manager")) + response_a = client.post( + f"/api/posts/{post_ids[0]}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response_a.status_code == 200, response_a.text + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeClient("Purchasing Lead")) + response_b = client.post( + f"/api/posts/{post_ids[1]}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response_b.status_code == 200, response_b.text + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select count(distinct person_id) from cataloged_person where person_name = 'Kim Cheolsu'" + ) + distinct_people = cur.fetchone()[0] + finally: + admin_conn.close() + + assert distinct_people == 2, "conflicting stated job titles for the same name must not be merged into one person" + + @pytest.mark.skipif( not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY), reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run", diff --git a/frontend/package.json b/frontend/package.json index 0e8e1c3e3..3ae022b9a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.68.0", + "version": "0.69.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 177fcf7c7..da74389cc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -239,6 +239,12 @@ font-size: 0.85rem; } +.keyman-role-title { + opacity: 0.6; + font-size: 0.8rem; + font-style: italic; +} + .verification-badge { font-size: 0.8rem; padding: 0.1rem 0.5rem; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be899929b..043a6542c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -623,6 +623,9 @@ function KeymanPanel({ > {person.person_name} ({person.person_side_label ?? person.person_side_code}) + {person.last_known_job_title && ( + {person.last_known_job_title} + )} {person.affiliations.length > 0 && ( {" -- "} @@ -645,6 +648,9 @@ function KeymanPanel({ ) : ( affiliation.organization_name )} + {affiliation.role_title && ( + ({affiliation.role_title}) + )} ))} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 03d7a71ee..3a8d52756 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -26,6 +26,7 @@ export interface Keyman { person_side_code: string; person_side_label?: string; mention_context: string | null; + last_known_job_title: string | null; affiliations: Affiliation[]; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 90bd896b0..83c985d42 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.68.0" +__version__ = "0.69.0" diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py index d7e8de952..f853e2f9f 100644 --- a/lineageweave/keyman_extraction.py +++ b/lineageweave/keyman_extraction.py @@ -35,14 +35,26 @@ class PersonMention: """One person the extractor found in a post's text. - ``affiliated_organization_names`` may be empty (mentioned without a - stated affiliation) or contain more than one name (the N:N case the - product requirement describes). + Attributes: + affiliated_organization_names: may be empty (mentioned without a + stated affiliation) or contain more than one name (the N:N + case the product requirement describes). + job_title: the person's title/position as the text states it + (e.g. "영업팀장," "구매담당"), or ``None`` when the text does + not say. Two different real people can share a name -- a + name alone is not a reliable identity key, and dropping a + stated title would throw away the one signal the text + offers to tell them apart. Persisted onto + ``person_affiliation.role_title`` (a schema column that + already existed, previously never populated) and used by + ``_upsert_person`` as a same-name disambiguation signal: + see ``backend/app/keyman_ingestion.py``. """ person_name: str person_side_code: str affiliated_organization_names: tuple[str, ...] = field(default_factory=tuple) + job_title: str | None = None class KeymanExtractionClient(Protocol): @@ -71,9 +83,13 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]: _EXTRACTION_PROMPT_TEMPLATE = """\ Read the post below and list every named person it mentions. For each -person, classify which side they are on and list every organization they +person, classify which side they are on, list every organization they are affiliated with according to the text (a person may belong to more -than one organization, or none if the text does not say). +than one organization, or none if the text does not say), and give their +job title or position if the text states one. Two different real people +can share the same name -- a stated title/position (e.g. "sales +manager," "purchasing lead") is real evidence for telling them apart, so +report it whenever the text gives one rather than leaving it out. Reply with ONLY a JSON array (no markdown fences, no prose), where each element has exactly these fields: @@ -82,6 +98,8 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]: "counterparty" (an external customer, partner, competitor, or other outside organization) "affiliations": a JSON array of organization name strings (can be empty) + "job_title": the person's stated title/position as a string, or null + when the text does not give one If no people are named, reply with an empty JSON array: [] @@ -126,8 +144,15 @@ def parse_keyman_response(content: str) -> list[PersonMention]: if not isinstance(affiliations_raw, list): affiliations_raw = [] affiliations = tuple(a.strip() for a in affiliations_raw if isinstance(a, str) and a.strip()) + job_title_raw = entry.get("job_title") + job_title = job_title_raw.strip() if isinstance(job_title_raw, str) and job_title_raw.strip() else None mentions.append( - PersonMention(person_name=name.strip(), person_side_code=side, affiliated_organization_names=affiliations) + PersonMention( + person_name=name.strip(), + person_side_code=side, + affiliated_organization_names=affiliations, + job_title=job_title, + ) ) return mentions diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index ddb7da4f8..372b20485 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -333,10 +333,16 @@ create table report_item_information ( -- --------------------------------------------------------------------- -- Cataloged people mentioned in posts (Keyman). Named cataloged_person, -- not person, so every table name is two or more snake_case words. +-- last_known_job_title: the disambiguation signal migrations/0013 adds. +-- Lives here, not only on person_affiliation.role_title, because a +-- stated title ("our legal counsel, Sam Okonkwo") is real same-name +-- evidence even when the text names no specific organization to attach +-- a person_affiliation row to. create table cataloged_person ( person_id uuid primary key default uuid_generate_v4(), person_name text not null, person_side_code text not null references common_lookup_value (lookup_code), + last_known_job_title text, created_at timestamptz not null default now() ); diff --git a/migrations/0013_person_job_title.sql b/migrations/0013_person_job_title.sql new file mode 100644 index 000000000..5904a6e0b --- /dev/null +++ b/migrations/0013_person_job_title.sql @@ -0,0 +1,11 @@ +-- Same-name-people disambiguation signal: a stated job title/position is +-- real evidence a same person_name+person_side_code match is NOT the +-- same real individual. Lives on cataloged_person itself, not only +-- person_affiliation.role_title, because a title is real disambiguation +-- evidence even when the text names no specific organization to attach +-- an affiliation row to (e.g. "our legal counsel, Sam Okonkwo"). +-- ADD COLUMN IF NOT EXISTS so a volume that already ran 0001 still +-- upgrades. + +alter table cataloged_person + add column if not exists last_known_job_title text; diff --git a/pyproject.toml b/pyproject.toml index 1b0e694a8..d5ee2dacb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.68.0" +version = "0.69.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 59e487d19..f1d158915 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -107,6 +107,7 @@ def seed( cur.execute((migrations / "0010_report_item_information.sql").read_text()) cur.execute((migrations / "0011_post_chat_result.sql").read_text()) cur.execute((migrations / "0012_role_responsibility_agent_type.sql").read_text()) + cur.execute((migrations / "0013_person_job_title.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_keyman_extraction.py b/tests/test_keyman_extraction.py index b1ab49da8..d9de45710 100644 --- a/tests/test_keyman_extraction.py +++ b/tests/test_keyman_extraction.py @@ -47,6 +47,24 @@ def test_parses_a_well_formed_json_array() -> None: assert mentions[1].affiliated_organization_names == ("Acme Corp", "Acme Holdings") +def test_job_title_is_captured_when_present() -> None: + content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": [], "job_title": "Sales Manager"}]' + mentions = parse_keyman_response(content) + assert mentions[0].job_title == "Sales Manager" + + +def test_job_title_is_none_not_empty_string_when_absent() -> None: + content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": []}]' + mentions = parse_keyman_response(content) + assert mentions[0].job_title is None + + +def test_null_job_title_is_none_not_the_string_null() -> None: + content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": [], "job_title": null}]' + mentions = parse_keyman_response(content) + assert mentions[0].job_title is None + + def test_strips_a_markdown_code_fence() -> None: content = '```json\n[{"name": "Jo Park", "side": "our_side", "affiliations": []}]\n```' mentions = parse_keyman_response(content) @@ -110,3 +128,11 @@ def test_contextual_orchestrator_extracts_keymen_from_an_ambiguous_post() -> Non assert jordan.person_side_code == OUR_SIDE assert priya.person_side_code == COUNTERPARTY assert len(priya.affiliated_organization_names) >= 2 + + # Sam Okonkwo is named only by role ("our legal counsel, Sam Okonkwo") -- + # a real assertion that job_title extraction reads the text, not a + # synthetic fixture built just to satisfy this one field. + sam = next((m for name, m in by_name.items() if "Sam" in name or "Okonkwo" in name), None) + assert sam is not None + assert sam.job_title is not None + assert "counsel" in sam.job_title.lower() or "legal" in sam.job_title.lower() From 94bf669994169e06bda24730c04edd2fb3597947 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:22:02 +0900 Subject: [PATCH 004/117] fix: seed synthetic Keyman titles so the panel is not empty After make seed, Ada West / Priya Nair / Jordan Hale carry last_known_job_title so the new title chip is visible without a live extraction. --- CHANGELOG.md | 4 +++- frontend/src/App.test.tsx | 2 ++ scripts/seed_demo_data.py | 24 +++++++++++++++--------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b51aaaa65..6b65c7bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ All notable changes to this project are documented here. Format follows genuinely conflicting stated title creates a fresh person row instead of reusing one, verified by a real test with two posts naming the same name and different titles. -- Keyman panel shows the person's title next to their name. +- Keyman panel shows the person's title next to their name. After + `make seed`, Ada West is "Account manager" and Priya Nair is + "Procurement lead" so the title is visible without a live LLM. ## [0.68.0] - 2026-08-14 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 36cd81648..4961e5a1e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -484,6 +484,7 @@ describe("App, authenticated", () => { person_name: "Ada West", person_side_code: "our_side", person_side_label: "Our side", + last_known_job_title: "Account manager", mention_context: null, affiliations: [{ organization_name: "Demo Corp", corporate_entity_id: "corp-1", role_title: null }], }, @@ -973,6 +974,7 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "Keyman affiliation: Demo Corp" })).toBeInTheDocument(); expect(screen.getByText("(Company)")).toBeInTheDocument(); expect(screen.getAllByText(/Ada West \(Our side\)/).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Account manager")).toBeInTheDocument(); expect(screen.queryByText(/our_side/)).not.toBeInTheDocument(); expect(screen.getByText("unresolved")).toBeInTheDocument(); expect(screen.getByText(/Voice of Customer\s*\(voc\)/)).toBeInTheDocument(); diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f1d158915..098e0b505 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -253,8 +253,9 @@ def seed( from lineageweave.knowledge_graph import knowledge_graph_edges_for_post cur.execute( - "insert into cataloged_person (person_name, person_side_code) values " - "('Ada West', 'our_side'), ('Priya Nair', 'counterparty') " + "insert into cataloged_person (person_name, person_side_code, last_known_job_title) values " + "('Ada West', 'our_side', 'Account manager'), " + "('Priya Nair', 'counterparty', 'Procurement lead') " "returning person_name, person_id" ) people = dict(cur.fetchall()) @@ -582,22 +583,27 @@ def _seed_fixture_evaluations(cur) -> None: def _ensure_demo_people(cur, corporate_entity_id) -> dict[str, str]: """Ada West / Priya Nair / Jordan Hale plus their affiliations. Idempotent.""" people: dict[str, str] = {} - for name, side in ( - ("Ada West", "our_side"), - ("Priya Nair", "counterparty"), - ("Jordan Hale", "our_side"), + for name, side, title in ( + ("Ada West", "our_side", "Account manager"), + ("Priya Nair", "counterparty", "Procurement lead"), + ("Jordan Hale", "our_side", "Bid coordinator"), ): cur.execute("select person_id from cataloged_person where person_name = %s", (name,)) row = cur.fetchone() if row is None: cur.execute( - "insert into cataloged_person (person_name, person_side_code) " - "values (%s, %s) returning person_id", - (name, side), + "insert into cataloged_person (person_name, person_side_code, last_known_job_title) " + "values (%s, %s, %s) returning person_id", + (name, side, title), ) people[name] = str(cur.fetchone()[0]) else: people[name] = str(row[0]) + cur.execute( + "update cataloged_person set last_known_job_title = coalesce(last_known_job_title, %s) " + "where person_id = %s", + (title, people[name]), + ) cur.execute( "insert into person_affiliation " "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " From cd01c7628f2c7c025b4c7516bc155cdeff2c1edc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 12:01:16 +0900 Subject: [PATCH 005/117] fix: read Keycloak admin password from the environment Strix flagged the local-dev password literal in seed_demo_data.py after this branch started editing that file. make seed still injects the compose default; a direct script run requires KEYCLOAK_ADMIN_PASSWORD. --- CHANGELOG.md | 7 +++++++ Makefile | 2 +- scripts/seed_demo_data.py | 14 ++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b65c7bc1..6d6f63d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ All notable changes to this project are documented here. Format follows `make seed`, Ada West is "Account manager" and Priya Nair is "Procurement lead" so the title is visible without a live LLM. +### Fixed + +- `scripts/seed_demo_data.py` no longer embeds the local Keycloak admin + password. `make seed` still supplies the compose default via + `KEYCLOAK_ADMIN_PASSWORD`; a direct script run requires that env var + or `--keycloak-admin-password`. + ## [0.68.0] - 2026-08-14 ### Changed diff --git a/Makefile b/Makefile index 68ee850eb..a827a53d9 100644 --- a/Makefile +++ b/Makefile @@ -23,4 +23,4 @@ smoke: # users' real subject ids, plus Valkey ticket_created events so Activity # is not empty (see scripts/seed_demo_data.py). Run after `up`. seed: - python3 scripts/seed_demo_data.py + KEYCLOAK_ADMIN_PASSWORD=$${KEYCLOAK_ADMIN_PASSWORD:-admin_dev_only} python3 scripts/seed_demo_data.py diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 098e0b505..b13eb62a2 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -13,12 +13,13 @@ HTTP goes through ``lineageweave.http_client`` (http(s) allowlist). -Usage: python3 scripts/seed_demo_data.py [--postgres-dsn ...] [--keycloak-base-url ...] [--valkey-url ...] +Usage: KEYCLOAK_ADMIN_PASSWORD=... python3 scripts/seed_demo_data.py [--postgres-dsn ...] [--keycloak-base-url ...] [--valkey-url ...] """ from __future__ import annotations import argparse +import os import sys from pathlib import Path from urllib.parse import urlencode @@ -33,8 +34,7 @@ REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" DEFAULT_KEYCLOAK_BASE_URL = "http://localhost:18080" -DEFAULT_KEYCLOAK_ADMIN_USER = "admin" -DEFAULT_KEYCLOAK_ADMIN_PASSWORD = "admin_dev_only" # nosec B105 -- throwaway local-dev-only Keycloak seed credential +DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report @@ -1172,9 +1172,15 @@ def main() -> None: parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) parser.add_argument("--keycloak-base-url", default=DEFAULT_KEYCLOAK_BASE_URL) parser.add_argument("--keycloak-admin-user", default=DEFAULT_KEYCLOAK_ADMIN_USER) - parser.add_argument("--keycloak-admin-password", default=DEFAULT_KEYCLOAK_ADMIN_PASSWORD) + parser.add_argument( + "--keycloak-admin-password", + default=os.environ.get("KEYCLOAK_ADMIN_PASSWORD"), + help="Keycloak master admin password (or KEYCLOAK_ADMIN_PASSWORD). Required.", + ) parser.add_argument("--valkey-url", default=DEFAULT_VALKEY_URL) args = parser.parse_args() + if not args.keycloak_admin_password: + parser.error("set KEYCLOAK_ADMIN_PASSWORD or pass --keycloak-admin-password") subjects = _fetch_demo_user_subjects(args.keycloak_base_url, args.keycloak_admin_user, args.keycloak_admin_password) seed(args.postgres_dsn, subjects, args.valkey_url) From cb0122af8641f0ccc462c545078317364f506538 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 12:18:28 +0900 Subject: [PATCH 006/117] feat: R&R actor can be a team, meso-level between person and org (v0.70.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real post text named a company sub-unit ("설계팀"/design team) that neither ADR 0006's prov_person nor prov_organization fits -- it's part of a company, not a person and not the company itself. actor_type_code gains prov_team, grounded in the W3C Organization Ontology's org:OrganizationalUnit (Reynolds, 2014), a different W3C vocabulary from PROV-O that exists specifically for this meso-level case. A team actor requires affiliated_organization_name in the same way a person actor does -- unlike an organization actor, a team's own name never answers "which company." Fixed a real bug the new type surfaced: the R&R badge's label text was a binary Person/Organization ternary that would have mislabeled a team as "Organization" (the CSS class name was already generic; the display text was not). Migration 0014 is purely additive (one lookup row insert), no schema change -- actor_type_code already stores an arbitrary FK'd code. --- ARCHITECTURE.md | 15 ++++ CHANGELOG.md | 15 ++++ backend/tests/test_api.py | 3 +- docs/adr/0007-team-actor-type.md | 90 +++++++++++++++++++ docs/ontology/lineageweave-kg.ttl | 15 ++++ frontend/package.json | 2 +- frontend/src/App.css | 5 ++ frontend/src/App.tsx | 8 +- lineageweave/__init__.py | 2 +- lineageweave/post_summary.py | 59 +++++++----- ...14_role_responsibility_team_actor_type.sql | 11 +++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_ontology.py | 40 ++++++--- tests/test_post_summary.py | 20 +++++ 15 files changed, 252 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0007-team-actor-type.md create mode 100644 migrations/0014_role_responsibility_team_actor_type.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b1110dba5..9d99da0ca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -736,3 +736,18 @@ people. A missing title on either side is not treated as a conflict title at all), so this only splits on an actual stated disagreement, verified by a real test that two posts naming the same name with genuinely different stated titles produce two distinct person rows. + +## Phase 9: an R&R actor can be a team, meso-level between person and organization + +Real post text named "설계팀" (design team) -- neither a person nor the +company itself, but a sub-unit of one. See +[ADR 0007](docs/adr/0007-team-actor-type.md). `actor_type_code` gains a +third value, `prov_team`, grounded in the W3C Organization Ontology's +`org:OrganizationalUnit` (Reynolds, 2014) -- a different, complementary +W3C vocabulary from PROV-O (which models "who acted," not "how a +company is internally structured"). The prompt now offers three actor +types and requires `affiliated_organization_name` for a team actor too +(not just a person): a team's own name never answers "which company," +unlike an organization actor's. `migrations/0014_role_responsibility_team_actor_type.sql` +adds the lookup row -- purely additive, no schema change, since +`actor_type_code` already stores an arbitrary FK'd code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6f63d54..505a0953e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.70.0] - 2026-08-14 + +### Added + +- R&R's `actor_type_code` gains `prov_team`, a meso-level actor type for + a named sub-unit of a company (e.g. "설계팀"/design team) -- distinct + from both a person and the company itself. Grounded in the W3C + Organization Ontology's `org:OrganizationalUnit` (Reynolds, 2014). +- A team actor now requires `affiliated_organization_name` in the same + way a person actor does -- a team's own name never answers "which + company." +- R&R badge shows a distinct "Team" label/color, not the prior binary + Person/Organization ternary (which would have mislabeled a team as + "Organization"). + ## [0.69.0] - 2026-08-14 ### Added diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3884d1711..1d7f7f16c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -144,7 +144,8 @@ def seeded_db(demo_analyst_token): "('evaluation_criterion', 'general_sentiment_negative', 'Negative stance'), " "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity'), " "('prov_agent_type', 'prov_person', 'Person'), " - "('prov_agent_type', 'prov_organization', 'Organization')" + "('prov_agent_type', 'prov_organization', 'Organization'), " + "('prov_agent_type', 'prov_team', 'Team')" ) cur.execute( "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " diff --git a/docs/adr/0007-team-actor-type.md b/docs/adr/0007-team-actor-type.md new file mode 100644 index 000000000..1da18883b --- /dev/null +++ b/docs/adr/0007-team-actor-type.md @@ -0,0 +1,90 @@ +# ADR 0007 — R&R's named actor can be a team, a meso-level unit, not just a person/organization + +**Decision status:** Accepted +**Date:** 2026-08-14 + +## Context + +ADR 0006 gave R&R's `actor_type_code` two values: `prov_person` and +`prov_organization`, grounded in W3C PROV-O's `prov:Agent` subclasses. +Real post text surfaced a third, distinct case those two do not cover: +a named sub-unit of a company -- e.g. "설계팀" (design team) -- acting +in the text. A team is not a person, and forcing it into +`prov_organization` is wrong for the same reason ADR 0006 rejected +forcing an organization into a person slot: it collapses a real, +useful distinction. A team is meso-level -- part of a company, not the +company itself, and not an individual either. + +PROV-O has no sub-organization concept to reuse here; `prov:Agent`'s +two subclasses are exhaustive for PROV-O's own purposes (an +organization's internal structure is out of PROV-O's scope). + +## Decision + +Ground the team case in the W3C Organization Ontology (Reynolds, 2014): +`org:OrganizationalUnit`, defined for exactly this -- representing the +division of an organization into sub-organizational units, linked to +its parent via `org:unitOf`/`org:subOrganizationOf`. This is a +different, complementary W3C vocabulary from PROV-O, not a conflicting +one: PROV-O models "who/what acted," ORG models "how an organization is +structured internally" -- a team acting in a post's text needs both a +`prov:Agent`-shaped role (it does something) and an +`org:OrganizationalUnit`-shaped identity (it belongs to a company). +`:RoleActorTeam` is declared `rdfs:subClassOf org:OrganizationalUnit` +for that reason, parallel to how `:RoleActorPerson`/ +`:RoleActorOrganization` subclass PROV-O's classes. + +`post_summary.py` gains `ACTOR_TYPE_TEAM = "prov_team"` +(`common_lookup_value` category `prov_agent_type`, extending ADR +0006's two existing values). The LLM prompt now offers three actor +types (person / organization / team) and explicitly requires a team +actor to also carry `affiliated_organization_name` -- unlike an +organization actor (whose own name already answers "which +organization"), a team's name alone does not identify a company, so +the field is not optional in the same "opportunistic" sense ADR 0006 +described for a person actor; a team is always someone's team, and the +prompt asks the model to infer the parent company from context when +the text supports it. + +No new `RoleResponsibility` field is needed: +`affiliated_organization_name` already exists (ADR 0006) and applies +unchanged to this actor type -- only its *meaning* extends from +"the person's employer" to "the person's or team's parent +organization," which the dataclass docstring now says explicitly. + +Persistence: `migrations/0014_role_responsibility_team_actor_type.sql` +inserts the `prov_team` lookup row -- purely additive +(`insert ... on conflict (lookup_code) do nothing`), no column or +constraint change, since `actor_type_code` already stores an arbitrary +FK'd lookup code and needs no schema change to accept a third value. + +## Consequences + +- `_VALID_ACTOR_TYPE_CODES` in `post_summary.py` now has three members; + any code elsewhere that pattern-matches strictly on the first two + (rather than treating an unrecognized/future code as "not this one") + needs review. Found and fixed one: the frontend badge's CSS class name + (`actor-type-${code}`) was already generic, but its *label text* was a + binary person/organization ternary that would have mislabeled a team + actor as "Organization" -- now a three-way check. +- A team actor is never linked to the Keyman panel (same as an + organization actor in ADR 0006) -- it has no `person_id`. +- Distinguishing "설계팀" (a team) from "Design Corp" (an organization) + is a real LLM judgment call with no hard syntactic rule; the prompt + gives the model the concept and an example, matching this repo's + existing degrade-gracefully discipline for judgment-call extraction + fields (a wrong guess is a labeling error on one row, not lost data -- + the raw `actor_name` string is preserved regardless of which type it + is filed under). + +## Related + +Extends [ADR 0006](0006-role-responsibility-agent-ontology.md), which +itself extends [ADR 0004](0004-knowledge-graph-ontology.md)'s Ontology/ +Semantic-Layer vocabulary. + +## References (APA 7th) + +Reynolds, D. (Ed.). (2014). *The organization ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/vocab-org/ + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 032773c4c..bb398a9a3 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -5,6 +5,7 @@ @prefix skos: . @prefix xsd: . @prefix prov: . +@prefix org: . ################################################################# # LineageWeave Knowledge Graph Ontology @@ -169,6 +170,14 @@ # node_type's :Person is a cataloged_person row with a stable person_id # a Keyman panel links to; an R&R actor is a free-text name with no # cataloged identity of its own (it may not even resolve to a Keyman). +# +# A third, meso-level case real data surfaced: a named sub-unit of a +# company ("설계팀" [design team]) is neither prov:Person nor the +# prov:Organization itself -- it is the company's own internal +# structure. PROV-O has no such class; the W3C Organization Ontology +# (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent +# division of a particular organization into sub-organizational units," +# linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md. ################################################################# :RoleActorPerson a owl:Class ; @@ -182,3 +191,9 @@ rdfs:label "Role actor (organization)" ; rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ; :lookupCode "prov_organization" . + +:RoleActorTeam a owl:Class ; + rdfs:subClassOf org:OrganizationalUnit ; + rdfs:label "Role actor (team)" ; + rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ; + :lookupCode "prov_team" . diff --git a/frontend/package.json b/frontend/package.json index 3ae022b9a..22b879c57 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.69.0", + "version": "0.70.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index da74389cc..b1717e10c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -269,6 +269,11 @@ color: #e65100; } +.actor-type-prov_team { + background: #e0f2f1; + color: #00695c; +} + .rr-affiliation { opacity: 0.7; font-size: 0.9rem; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 043a6542c..47be3b719 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1183,13 +1183,19 @@ function PostDetailPopup({
      {summary.roles_and_responsibilities.map((rr, i) => { const isPerson = rr.actor_type_code === "prov_person"; + const actorTypeLabel = + rr.actor_type_code === "prov_team" + ? "Team" + : isPerson + ? "Person" + : "Organization"; const person = isPerson ? keymen?.find((row) => row.person_name === rr.actor_name) : undefined; return (
    • - {isPerson ? "Person" : "Organization"} + {actorTypeLabel} {" "} {person ? (
    • ); })}
    )} + {selected && ( +
    +

    {analysisRunCaption(selected)}

    +

    + Cutoff {selected.knowledge_cutoff.slice(0, 10)} + {" · "} + Requested {selected.requested_at.slice(0, 10)} +

    +
      + {selected.source_counts.map((count) => ( +
    • + {count.count_value} {count.count_type_label.toLowerCase()} +
    • + ))} +
    +
    + )} ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 91e9f65e4..bc1c39e6d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -516,3 +516,7 @@ export interface AnalysisRun { export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { return backendFetch("/api/analysis-runs", accessToken); } + +export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index a8f40a3cc..7b561a3ab 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.79.0" +__version__ = "0.80.0" diff --git a/pyproject.toml b/pyproject.toml index 9f9ed8537..57a3973ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.79.0" +version = "0.80.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 6d9094f6a..2c009d7e1 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.79.0" +version = "0.80.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From e9bcd4858b0ce73984945722a0068201c925c9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:07:57 +0900 Subject: [PATCH 101/117] feat: show labeled analysis-run status history (v0.81.0) (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buyer gap: after #100 the detail showed cutoff and counts but not the legal lifecycle the registry already stored. GET /api/analysis-runs/{id} now returns labeled status_history (Pending → Running → Succeeded with occurrence times). The list stays latest-status only. Hidden runs still 404 and never leak events. Failure codes stay machine tokens. Synthetic Demo Corp seed only. --- ARCHITECTURE.md | 9 ++++-- .../0.81.0-analysis-run-status-history.md | 5 ++++ CHANGELOG.md | 10 +++++++ backend/app/analysis_run_ingestion.py | 30 +++++++++++++++++++ backend/app/main.py | 5 +++- backend/tests/test_api.py | 19 ++++++++++-- docs/adr/0014-authorized-analysis-run-read.md | 8 +++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 24 +++++++++++++++ frontend/src/App.tsx | 10 +++++++ frontend/src/api.ts | 9 ++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- 13 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.d/0.81.0-analysis-run-status-history.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e00b8c8bd..33d9d2c09 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -464,11 +464,14 @@ process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts) without exposing a -DSN or raw record. The payload is lookup labels plus non-negative aggregate counts -- never +labeled detail (cutoff, requested date, counts, status history) +without exposing a DSN or raw record. Status history is detail-only +and uses lookup labels plus occurrence times; a failure event keeps +its machine `failure_code` rather than an invented caption. The +payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents". +Demo Corp" with "3 documents" and Pending / Running / Succeeded times. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.81.0-analysis-run-status-history.md b/CHANGELOG.d/0.81.0-analysis-run-status-history.md new file mode 100644 index 000000000..9fa4b8706 --- /dev/null +++ b/CHANGELOG.d/0.81.0-analysis-run-status-history.md @@ -0,0 +1,5 @@ +# 0.81.0 analysis-run status history + +Detail of `GET /api/analysis-runs/{id}` shows the labeled append-only +lifecycle. The list stays latest-status only. Hidden runs 404. +Synthetic Demo Corp seed only. diff --git a/CHANGELOG.md b/CHANGELOG.md index e93ec6ef8..4c17115a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.81.0] - 2026-08-16 + +### Added + +- Analysis-run detail shows the labeled lifecycle: Pending, Running, + then Succeeded, with occurrence times from `analysis_run_status_event`. + The list stays latest-status only. Hidden runs still 404 and never + leak events. Failure codes stay machine tokens -- no invented label. + Synthetic Demo Corp seed only. + ## [0.80.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 86b75ef89..b9a09fc09 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -96,6 +96,35 @@ async def _counts_by_run( return grouped +async def _status_history( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> list[dict[str, Any]]: + """Labeled append-only lifecycle for one already-visible run.""" + rows = await conn.fetch( + """ + select status_ordinal, status_code, occurred_at, failure_code + from analysis_run_status_event + where analysis_run_id = $1::uuid + order by status_ordinal + """, + analysis_run_id, + ) + labels = await labels_for_codes(conn, [row["status_code"] for row in rows]) + history: list[dict[str, Any]] = [] + for row in rows: + item: dict[str, Any] = { + "status_ordinal": int(row["status_ordinal"]), + "status_code": row["status_code"], + "status_label": labels.get(row["status_code"], row["status_code"]), + "occurred_at": _iso(row["occurred_at"]), + } + if row["failure_code"]: + item["failure_code"] = row["failure_code"] + history.append(item) + return history + + async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], @@ -187,4 +216,5 @@ async def fetch_visible_analysis_run( detail["code_revision_sha"] = row["code_revision_sha"] if row["failure_code"]: detail["failure_code"] = row["failure_code"] + detail["status_history"] = await _status_history(conn, analysis_run_id) return detail diff --git a/backend/app/main.py b/backend/app/main.py index 81630d35b..e039a2f58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1179,7 +1179,10 @@ async def read_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """One authorized analysis-run projection, or 404 when hidden.""" + """One authorized analysis-run projection, or 404 when hidden. + + Detail adds the labeled status history. Hidden runs never leak events. + """ _require_post_read(account) try: UUID(analysis_run_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3a6bab603..3104dadd6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -453,14 +453,29 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( dumped = str(visible) assert "postgresql://" not in dumped assert "select " not in dumped.lower() + assert "status_history" not in visible detail = client.get( f"/api/analysis-runs/{seeded_db['visible_run_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}, ) assert detail.status_code == 200 - assert detail.json()["configuration_schema_version"] == "lineage-run-v1" - assert "snapshot_sha256" not in detail.json() + body = detail.json() + assert body["configuration_schema_version"] == "lineage-run-v1" + assert "snapshot_sha256" not in body + history = body["status_history"] + assert [event["status_label"] for event in history] == [ + "Pending", + "Running", + "Succeeded", + ] + assert [event["occurred_at"][:16] for event in history] == [ + "2026-01-12T12:31", + "2026-01-12T12:32", + "2026-01-12T12:33", + ] + assert all("failure_code" not in event for event in history) + assert "postgresql://" not in str(body) hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 0621614d3..c0f32beef 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -28,6 +28,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: - The payload carries lookup labels and non-negative aggregate counts. It does not carry source SQL, DSNs, raw records, image bytes, provider payloads, credentials, or another service's table names. +- `GET /api/analysis-runs/{id}` also returns the append-only labeled + `status_history`. The list does not. A failed event may include the + stored machine `failure_code`; this slice does not invent a label. - TEPP remains a versioned `AnalysisRunRequest` consumer (`lineageweave.tepp_client`). This slice does not fork TEPP arithmetic. - contextual-orchestrator remains the only LLM path. This slice does not @@ -37,8 +40,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: `make seed` writes one synthetic Demo Corp lineage run so the existing React home page can show Analysis runs without a second application. -Write/rebuild APIs, TEPP submission, and an Analysis Run Console remain -later slices. +The detail now shows the legal lifecycle the registry already stored. +Write/rebuild APIs, TEPP submission, and a fuller Analysis Run Console +remain later slices. ## References diff --git a/frontend/package.json b/frontend/package.json index ca3a1810e..aacb6f74d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.80.0", + "version": "0.81.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ba1285c40..a06e0dc31 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,26 @@ describe("App, authenticated", () => { count_value: 3, }, ], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:31:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:32:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:33:00Z", + }, + ], }), ); } @@ -1365,6 +1385,10 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + const history = screen.getByRole("list", { name: "Analysis run status history" }); + expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); + expect(history).toHaveTextContent("Running 2026-01-12 12:32"); + expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8568e9acf..49b2bf16c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1432,6 +1432,16 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))}
+ {selected.status_history && selected.status_history.length > 0 && ( +
    + {selected.status_history.map((event) => ( +
  1. + {event.status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} + {event.failure_code ? ` · ${event.failure_code}` : ""} +
  2. + ))} +
+ )} )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bc1c39e6d..5dea72c0a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -499,6 +499,14 @@ export interface AnalysisRunCount { count_value: number; } +export interface AnalysisRunStatusEvent { + status_ordinal: number; + status_code: string; + status_label: string; + occurred_at: string; + failure_code?: string; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: string; @@ -511,6 +519,7 @@ export interface AnalysisRun { knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; + status_history?: AnalysisRunStatusEvent[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 7b561a3ab..5603c60fe 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.80.0" +__version__ = "0.81.0" diff --git a/pyproject.toml b/pyproject.toml index 57a3973ab..3862da9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.80.0" +version = "0.81.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From 91d5a056261fc626db25829aa7098d57e9a9ba5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:09:38 +0900 Subject: [PATCH 102/117] feat: open visible posts from an analysis-run detail (v0.82.0) (#103) Buyer gap: after #102 the run detail showed history but no way to open a post. Detail now lists ABAC-visible titles in the run's scope. Other-corp private posts stay hidden. List payloads stay aggregates-only. Synthetic titles only. --- ARCHITECTURE.md | 3 +- .../0.82.0-analysis-run-post-clickthrough.md | 4 ++ CHANGELOG.md | 9 +++ backend/app/analysis_run_ingestion.py | 57 +++++++++++++++++++ backend/tests/test_api.py | 4 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 5 ++ frontend/src/App.tsx | 25 +++++++- frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 33d9d2c09..3f3a7cc9a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -462,7 +462,8 @@ read of the #89 registry. `GET /api/analysis-runs` and in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a -post in that group; `all_visible` is requester-only. Hidden runs 404. +post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the +run's scope so a buyer can open a post without seeing hidden rows. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md new file mode 100644 index 000000000..1802a5644 --- /dev/null +++ b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md @@ -0,0 +1,4 @@ +# 0.82.0 analysis-run post click-through + +Detail lists ABAC-visible post titles in the run scope. Hidden +other-corp private posts never appear. Synthetic titles only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c17115a1..8df6fa5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.82.0] - 2026-08-16 + +### Added + +- Analysis-run detail lists ABAC-visible posts in the run's scope. + After `make seed`, the Demo Corp lineage run opens the Demo public + post. Hidden other-corp private posts never appear. List payloads + stay aggregates-only. + ## [0.81.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index b9a09fc09..c18ddf326 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -53,6 +53,8 @@ run.code_revision_sha, scope.scope_kind_code, scope.corporate_entity_id, + scope.process_unit_id, + scope.scope_key, corp.entity_name as scope_entity_name, status.status_code, status.failure_code @@ -217,4 +219,59 @@ async def fetch_visible_analysis_run( if row["failure_code"]: detail["failure_code"] = row["failure_code"] detail["status_history"] = await _status_history(conn, analysis_run_id) + detail["visible_posts"] = await fetch_visible_scope_posts( + conn, + row["scope_kind_code"], + row["corporate_entity_id"], + row["process_unit_id"], + row["scope_key"], + affiliated_entity_ids, + ) return detail + + +async def fetch_visible_scope_posts( + conn: asyncpg.Connection, + scope_kind_code: str, + corporate_entity_id: Any, + process_unit_id: Any, + scope_key: str | None, + affiliated_entity_ids: list[str], +) -> list[dict[str, str]]: + """ABAC-visible post titles in the run's scope -- never a hidden body.""" + if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where corporate_entity_id = $1 " + "order by created_at, post_title", + corporate_entity_id, + ) + elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where process_unit_id = $1 " + "order by created_at, post_title", + process_unit_id, + ) + elif scope_kind_code == "analysis_scope_thread_group" and scope_key: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where thread_group_key = $1 " + "order by created_at, post_title", + scope_key, + ) + elif scope_kind_code == "analysis_scope_all_visible": + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post order by created_at, post_title" + ) + else: + return [] + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + posts: list[dict[str, str]] = [] + for row in rows: + visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated + if not visible: + continue + posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]}) + return posts diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3104dadd6..cfc2a5559 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -475,7 +475,11 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( "2026-01-12T12:33", ] assert all("failure_code" not in event for event in history) + titles = {post["post_title"] for post in body["visible_posts"]} + assert "Own-corp private post" in titles + assert "Other-corp private post" not in titles assert "postgresql://" not in str(body) + assert "visible_posts" not in visible hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/frontend/package.json b/frontend/package.json index aacb6f74d..5d3f2e2b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.81.0", + "version": "0.82.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a06e0dc31..4fb1b5649 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,7 @@ describe("App, authenticated", () => { count_value: 3, }, ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], status_history: [ { status_ordinal: 1, @@ -1389,7 +1390,11 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49b2bf16c..1f350928d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1353,7 +1353,13 @@ function analysisRunCaption(run: AnalysisRun): string { .join(" · "); } -function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { +function AnalysisRunsPanel({ + accessToken, + onSelectPost, +}: { + accessToken: string; + onSelectPost: (postId: string) => void; +}) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); @@ -1442,6 +1448,21 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))} )} + {selected.visible_posts && selected.visible_posts.length > 0 && ( +
    + {selected.visible_posts.map((post) => ( +
  • + +
  • + ))} +
+ )} )} @@ -1736,7 +1757,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5dea72c0a..3dacb054c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -520,6 +520,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; + visible_posts?: { post_id: string; post_title: string }[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5603c60fe..b1f0c97b1 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.81.0" +__version__ = "0.82.0" diff --git a/pyproject.toml b/pyproject.toml index 3862da9a0..9238d87a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.81.0" +version = "0.82.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 2c009d7e1..f3307cb32 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.80.0" +version = "0.82.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From fdab35e74845b22ef4249efd5438a28b73c40402 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:19:40 +0000 Subject: [PATCH 103/117] docs(adr): keep registry ADR 0013 after #74 reused the number PR #91 landed an adaptive-orchestration ADR 0013 on the #74 base after this slice already used 0013 for the normalized analysis-run registry. Renumber the adaptive record to 0015 so ADR numbers stay unique. Co-authored-by: Seongho Bae --- ...ault.md => 0015-adaptive-contextual-orchestrator-default.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0013-adaptive-contextual-orchestrator-default.md => 0015-adaptive-contextual-orchestrator-default.md} (96%) diff --git a/docs/adr/0013-adaptive-contextual-orchestrator-default.md b/docs/adr/0015-adaptive-contextual-orchestrator-default.md similarity index 96% rename from docs/adr/0013-adaptive-contextual-orchestrator-default.md rename to docs/adr/0015-adaptive-contextual-orchestrator-default.md index ee0402075..433432fbb 100644 --- a/docs/adr/0013-adaptive-contextual-orchestrator-default.md +++ b/docs/adr/0015-adaptive-contextual-orchestrator-default.md @@ -1,4 +1,4 @@ -# ADR-0013: Adaptive contextual-orchestrator mode is the default +# ADR-0015: Adaptive contextual-orchestrator mode is the default - Status: Accepted - Date: 2026-08-16 From 955d0b068d6f18a3697a6ddfa18a1da690ea2204 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:43:46 +0000 Subject: [PATCH 104/117] docs(changelog): point adaptive-orchestration note at ADR 0015 The #74 changelog fold still called that decision ADR 0013. This stack keeps the analysis-run registry as ADR 0013, so the adaptive record is 0015. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df6fa5cc..6ac2df4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ All notable changes to this project are documented here. Format follows and LLM-as-a-Judge consumers now request contextual-orchestrator `auto` mode so the orchestration plane can meet the quality requirement and then minimize known execution cost. Explicit checked `verify` paths remain unchanged - (ADR 0013). + (ADR 0015). ## [0.77.0] - 2026-08-14 From 88a1a0f8b925206083884f5350f7bbab41433fa7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:14:14 +0000 Subject: [PATCH 105/117] fix: keep Keyman evidence and honor analysis-run cutoff (v0.83.0) Migration 0016 no longer deletes overlapping Keyman mention_context. Analysis-run detail lists only posts known at knowledge_cutoff. Keyman org enrichment finishes before the write transaction. Replace remaining real organization names with synthetic AGP examples. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 4 +- ...-analysis-run-cutoff-and-keyman-upgrade.md | 2 + CHANGELOG.md | 16 +++ backend/app/analysis_run_ingestion.py | 18 ++- backend/app/entity_relationship_ingestion.py | 5 +- backend/app/keyman_ingestion.py | 73 ++++++------ backend/app/main.py | 40 +++---- backend/tests/test_api.py | 25 ++++- ...08-organization-abbreviation-resolution.md | 4 +- docs/adr/0009-cross-post-actor-identity.md | 6 +- .../0010-corporate-hierarchy-auto-creation.md | 20 ++-- ...016-analysis-run-knowledge-cutoff-posts.md | 50 +++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 4 +- docs/ontology/lineageweave-kg.ttl | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 1 + frontend/src/App.tsx | 2 +- lineageweave/__init__.py | 2 +- lineageweave/organization_name_resolution.py | 6 +- .../0015_organization_name_resolution.sql | 4 +- migrations/0016_cross_post_actor_identity.sql | 16 ++- pyproject.toml | 2 +- scripts/seed_demo_data.py | 13 ++- tests/test_ingestion_transaction_contracts.py | 106 ++++++++++++++++++ tests/test_organization_name_resolution.py | 26 ++--- tests/test_person_mention_projection.py | 91 ++++++++++++--- uv.lock | 2 +- 27 files changed, 412 insertions(+), 132 deletions(-) create mode 100644 CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md create mode 100644 docs/adr/0016-analysis-run-knowledge-cutoff-posts.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f3a7cc9a..bedeba285 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -463,7 +463,9 @@ in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the -run's scope so a buyer can open a post without seeing hidden rows. +run's scope whose `created_at` is at or before `knowledge_cutoff` +(ADR 0016) so a buyer can open a post the run was allowed to know +without seeing later live rows or hidden bodies. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md new file mode 100644 index 000000000..f2de7cfad --- /dev/null +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md @@ -0,0 +1,2 @@ +Analysis-run detail applies knowledge_cutoff to visible posts. Migration +0016 no longer deletes overlapping Keyman mention_context. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac2df4ec..d0edb5d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.83.0] - 2026-08-16 + +### Fixed + +- Analysis-run detail now lists only ABAC-visible posts whose + `created_at` is at or before that run's `knowledge_cutoff`. After + `make seed`, open the Demo Corp lineage run: Demo public post is + there; a later own-corp follow-up is not. The live post list is + unchanged. Click a listed title to inspect what that cutoff + reconstructed (ADR 0016). +- Upgrading through `0016_cross_post_actor_identity.sql` copies R&R + person names into `post_summary_person_mention` and leaves Keyman + `post_person_mention.mention_context` in place. Re-run Keyman only + when you want a new Keyman set -- a later summary no longer erases + the stolen row. + ## [0.82.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index c18ddf326..e96c2b7c4 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -226,6 +226,7 @@ async def fetch_visible_analysis_run( row["process_unit_id"], row["scope_key"], affiliated_entity_ids, + row["knowledge_cutoff"], ) return detail @@ -237,33 +238,46 @@ async def fetch_visible_scope_posts( process_unit_id: Any, scope_key: str | None, affiliated_entity_ids: list[str], + knowledge_cutoff: Any, ) -> list[dict[str, str]]: - """ABAC-visible post titles in the run's scope -- never a hidden body.""" + """ABAC-visible post titles known at the run cutoff -- never a hidden body. + + ``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019; + ADR 0013/0016). A later live post must not appear inside an earlier run. + """ if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " "from source_post where corporate_entity_id = $1 " + "and created_at <= $2 " "order by created_at, post_title", corporate_entity_id, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " "from source_post where process_unit_id = $1 " + "and created_at <= $2 " "order by created_at, post_title", process_unit_id, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_thread_group" and scope_key: rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " "from source_post where thread_group_key = $1 " + "and created_at <= $2 " "order by created_at, post_title", scope_key, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_all_visible": rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " - "from source_post order by created_at, post_title" + "from source_post where created_at <= $1 " + "order by created_at, post_title", + knowledge_cutoff, ) else: return [] diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index 30fd62361..091e58f40 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping, Sequence from typing import Any @@ -38,7 +39,9 @@ async def ingest_post_entity_relationships( if not organization_names: return [] - relationships = client.classify(post_title, post_body, organization_names) + relationships = await asyncio.to_thread( + client.classify, post_title, post_body, organization_names + ) for relationship in relationships: await conn.execute( diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index e477b3a1c..906442ba4 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -185,9 +185,13 @@ async def ingest_post_keymen( real ones get the exact same behavior as before ADR 0008/0010 (raw affiliation names, unresolved). - The post's prior Keyman mention set is replaced atomically after a successful - extraction. ``persist_graph=False`` lets a larger caller defer graph - reconciliation until the end of its own transaction. + Organization resolution and hierarchy creation finish before the Keyman + write transaction. Callers must not wrap this function in an outer + transaction: that would turn ``pg_advisory_xact_lock`` into a savepoint + and hold the creation lock across later LLM work. The post's prior + Keyman mention set is replaced atomically after enrichment. + ``persist_graph=False`` lets a larger caller persist edges in its own + short write transaction after this function returns. Raises whatever `client.extract` raises (e.g. a `NullKeymanExtractionClient` would raise `RuntimeError`) -- callers should check `client.available` @@ -198,20 +202,9 @@ async def ingest_post_keymen( hierarchy_inference_client = hierarchy_inference_client or NullCorporateHierarchyInferenceClient() mentions = await asyncio.to_thread(client.extract, post_title, post_body) candidates = await _load_corporate_entity_candidates(conn) - normalized_mentions: list[PersonMention] = [] - await conn.execute( - "delete from post_person_mention where post_id = $1", post_id - ) - + resolved_by_mention: list[tuple[PersonMention, list[tuple[str, str, str | None]]]] = [] for mention in mentions: - person_id = await _upsert_person(conn, mention) - await conn.execute( - "insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing", - post_id, - person_id, - ) - - resolved_names: list[str] = [] + resolved_orgs: list[tuple[str, str, str | None]] = [] for organization_name in mention.affiliated_organization_names: resolved_name = await resolve_organization_name( conn, @@ -228,24 +221,40 @@ async def ingest_post_keymen( verification_client, candidates, ) - await _upsert_affiliation( - conn, + resolved_orgs.append((organization_name, resolved_name, corporate_entity_id)) + resolved_by_mention.append((mention, resolved_orgs)) + + normalized_mentions: list[PersonMention] = [] + async with conn.transaction(): + await conn.execute( + "delete from post_person_mention where post_id = $1", post_id + ) + for mention, resolved_orgs in resolved_by_mention: + person_id = await _upsert_person(conn, mention) + await conn.execute( + "insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing", + post_id, person_id, - organization_name, - resolved_name, - corporate_entity_id, - mention.job_title, ) - if resolved_name not in resolved_names: - resolved_names.append(resolved_name) - normalized_mentions.append( - replace( - mention, - affiliated_organization_names=tuple(resolved_names), + resolved_names: list[str] = [] + for organization_name, resolved_name, corporate_entity_id in resolved_orgs: + await _upsert_affiliation( + conn, + person_id, + organization_name, + resolved_name, + corporate_entity_id, + mention.job_title, + ) + if resolved_name not in resolved_names: + resolved_names.append(resolved_name) + normalized_mentions.append( + replace( + mention, + affiliated_organization_names=tuple(resolved_names), + ) ) - ) - - if persist_graph: - await persist_edges_for_post(conn, post_id) + if persist_graph: + await persist_edges_for_post(conn, post_id) return normalized_mentions diff --git a/backend/app/main.py b/backend/app/main.py index e039a2f58..e77b173bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -588,27 +588,27 @@ async def extract_post_keymen( # literal text either blows the token budget or is silently # ignored (see lineageweave/post_content_normalization.py). post_body = normalize_post_body(raw_body, vision_client=_vision_client()).text + mentions = await ingest_post_keymen( + conn, + keyman_client, + post_id, + post["post_title"], + post_body, + resolution_client=_organization_name_resolution_client(), + verification_client=_relation_verification_client(), + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + persist_graph=False, + ) + organization_names = sorted( + {name for mention in mentions for name in mention.affiliated_organization_names} + ) + # relationship_client is gated by the same settings check as + # keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY), + # so reaching here means it is available too. + relationships = await ingest_post_entity_relationships( + conn, relationship_client, post_id, post["post_title"], post_body, organization_names + ) async with conn.transaction(): - mentions = await ingest_post_keymen( - conn, - keyman_client, - post_id, - post["post_title"], - post_body, - resolution_client=_organization_name_resolution_client(), - verification_client=_relation_verification_client(), - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - persist_graph=False, - ) - organization_names = sorted( - {name for mention in mentions for name in mention.affiliated_organization_names} - ) - # relationship_client is gated by the same settings check as - # keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY), - # so reaching here means it is available too. - relationships = await ingest_post_entity_relationships( - conn, relationship_client, post_id, post["post_title"], post_body, organization_names - ) await persist_edges_for_post(conn, post_id) return { "post_id": str(post["post_id"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cfc2a5559..df1dfb4a1 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -296,11 +296,17 @@ def _seed_analysis_run( (account_id, role_id), ) - def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: str = "body") -> str: + def _insert_post( + title: str, + corporate_entity_id, + visibility_code: str, + body: str = "body", + created_at: str = "2026-01-10T12:00:00Z", + ) -> str: cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " - "values (%s, %s, %s, %s, 'voc', %s) returning post_id", - (account_id, corporate_entity_id, title, body, visibility_code), + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at) " + "values (%s, %s, %s, %s, 'voc', %s, %s) returning post_id", + (account_id, corporate_entity_id, title, body, visibility_code, created_at), ) return str(cur.fetchone()[0]) @@ -313,6 +319,13 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st "The weather in Gwangju was irrelevant.", ) other_private_post_id = _insert_post("Other-corp private post", other_corp_id, "private") + late_own_private_post_id = _insert_post( + "Late own-corp private post", + own_corp_id, + "private", + "A follow-up written after the January 2026 run cutoff.", + created_at="2026-01-20T12:00:00Z", + ) cur.execute( "insert into cataloged_person (person_name, person_side_code) values " @@ -398,6 +411,7 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st "own_corp_id": str(own_corp_id), "other_corp_id": str(other_corp_id), "own_private_post_id": own_private_post_id, + "late_own_private_post_id": late_own_private_post_id, "other_private_post_id": other_private_post_id, "our_person_id": our_person_id, "counterpart_person_id": counterpart_person_id, @@ -477,6 +491,7 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( assert all("failure_code" not in event for event in history) titles = {post["post_title"] for post in body["visible_posts"]} assert "Own-corp private post" in titles + assert "Late own-corp private post" not in titles assert "Other-corp private post" not in titles assert "postgresql://" not in str(body) assert "visible_posts" not in visible @@ -503,7 +518,7 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 titles = {post["post_title"] for post in response.json()} - assert titles == {"Public post", "Own-corp private post"} + assert titles == {"Public post", "Own-corp private post", "Late own-corp private post"} public = next(post for post in response.json() if post["post_title"] == "Public post") assert public["voc_type_label"] == "Voice of Customer" assert public["visibility_label"] == "Public" diff --git a/docs/adr/0008-organization-abbreviation-resolution.md b/docs/adr/0008-organization-abbreviation-resolution.md index 0ade7d363..72b121253 100644 --- a/docs/adr/0008-organization-abbreviation-resolution.md +++ b/docs/adr/0008-organization-abbreviation-resolution.md @@ -7,8 +7,8 @@ Real post text names organizations by abbreviated or slang forms a human reader immediately recognizes but a string-matching pipeline -cannot -- e.g. "AGP," a common Korean contraction of "Aurora Grid Power" -(Korea Hydro & Nuclear Power). `lineageweave.corporate_hierarchy_resolution` +cannot -- e.g. "AGP," a synthetic contraction of "Aurora Grid Power". +`lineageweave.corporate_hierarchy_resolution` already resolves near-matches (a trailing legal suffix, a minor abbreviation) via character-sequence similarity (`difflib.SequenceMatcher`, grounded in Bhattacharya & Getoor, 2007's diff --git a/docs/adr/0009-cross-post-actor-identity.md b/docs/adr/0009-cross-post-actor-identity.md index c577d998a..7bdbfa091 100644 --- a/docs/adr/0009-cross-post-actor-identity.md +++ b/docs/adr/0009-cross-post-actor-identity.md @@ -54,7 +54,11 @@ Person evidence sources remain separate: Keyman extraction replaces `post_summary_person_mention`. `combined_post_person_mention` is a read-only union used for lineage and KG derivation. This prevents a new summary from deleting Keyman evidence and prevents removed R&R actors -from surviving as stale Keymen. +from surviving as stale Keymen. Migration 0016 copies matching R&R +actor names into `post_summary_person_mention` and must not delete +overlapping Keyman rows -- `mention_context` has no R&R column, and a +later summary replacement would otherwise erase the only remaining +person evidence. Each resolved actor gets a real Knowledge Graph mention edge (new diff --git a/docs/adr/0010-corporate-hierarchy-auto-creation.md b/docs/adr/0010-corporate-hierarchy-auto-creation.md index 84fc23c86..d034fe9b2 100644 --- a/docs/adr/0010-corporate-hierarchy-auto-creation.md +++ b/docs/adr/0010-corporate-hierarchy-auto-creation.md @@ -9,18 +9,14 @@ matching only ever locates an *already-cataloged* `corporate_entity` row -- it has no path to create one. This was fine while the only `corporate_entity` catalog was synthetic demo fixtures with a handful -of names extraction would naturally already know. Real Milestone 2 data -exposed the actual gap: `corporate_entity` for the unseen dataset holds -only the employer's own 2-row hierarchy (its own group/subsidiary -structure); every counterparty, customer, partner, or competitor -organization named in real posts is, by definition, something outside -that hierarchy. A direct count confirmed the consequence: **0 of 4,154 -`person_affiliation` rows and 0 of 9,852 R&R organization-actor -mentions ever resolved to a real `corporate_entity`** -- the standing -"통합 고객사 계열 tree AI" (integrated customer affiliate tree) -requirement, present in this product's brief since Milestone 1 -(the Samsung -> Samsung Electronics Korea -> ... example), was never -actually populated for real extraction, silently. +of names extraction would naturally already know. A synthetic batch +where the catalog holds only the employer's own two-row hierarchy +exposes the same gap: every counterparty named in a post is, by +definition, outside that catalog. Similarity matching then resolves +**0 affiliation rows and 0 R&R organization-actor mentions** -- the +standing integrated customer-affiliate tree requirement (Harbor Group +-> Harbor Devices Korea -> ... in the synthetic brief) stays empty +until a verified creation path exists. ## Decision diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md new file mode 100644 index 000000000..d6ac70db8 --- /dev/null +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -0,0 +1,50 @@ +# ADR 0016 — Analysis-run visible posts honor the run knowledge cutoff + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0013 stores `analysis_run.knowledge_cutoff` as the analysis clock: +what that run was allowed to know. The registry trigger already refuses +a cutoff earlier than `analysis_source_snapshot.maximum_available_time`. +The home-page detail, however, listed every ABAC-visible title in the +run's scope from live `source_post` rows. Fixture and seed posts that +defaulted to `created_at = now()` therefore appeared inside a January +2026 run, including a later own-corp follow-up the buyer would treat as +part of that reconstruction. + +W3C Time Ontology in OWL (Hobbs & Pan, 2017) and ISO 8601-1:2019 keep +distinct clocks from collapsing. A knowledge cutoff is not "posts the +account can see today." + +## Decision + +`fetch_visible_scope_posts` filters `created_at <= knowledge_cutoff` on +every scope branch (corporate entity, process unit, thread group, and +all-visible). ABAC visibility is applied after that temporal gate. +Click-through still opens the live post body -- post versioning is a +later slice -- but the run list itself must not advertise a post the +run was not allowed to know. + +Seed and API fixtures backdate in-cutoff posts. A late own-corp private +post remains on the live post list and stays out of the January 2026 +run. + +## Consequences + +- After `make seed`, the Demo Corp lineage run lists Demo public post + and other in-cutoff Demo Corp titles. The later fixture account-review + post (2026-02-10) does not appear. +- Open the run, then open a listed post, to inspect what that cutoff + actually reconstructed. +- Post-body versioning at the cutoff remains future work. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index b439cb9ca..a1dc73957 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -8,7 +8,7 @@ | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. | @@ -68,7 +68,7 @@ provenance, retention, and immutable evidence rather than blanket masking. | Claim | Falsifiable test | |---|---| | One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | -| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. | +| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. A late own-corp post stays out of `visible_posts`. | | Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | | Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. | | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 8a41a40ea..04e8d1b61 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -237,8 +237,8 @@ ################################################################# # organization_name_resolution (raw/canonical organization-name pairs) # -# ADR 0008: an abbreviated/slang organization mention (e.g. "한수원") -# is resolved to its full canonical name ("한국수력원자력") and +# ADR 0008: an abbreviated/slang organization mention (e.g. "AGP") +# is resolved to its full canonical name ("Aurora Grid Power") and # cross-verified via external search before being trusted. This is not # a new KG node/edge type -- no new :lookupCode term is declared here, # since organization_name_resolution's columns are not a diff --git a/frontend/package.json b/frontend/package.json index 5d3f2e2b8..d1e24268f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.82.0", + "version": "0.83.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4fb1b5649..a3da5de6b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1390,6 +1390,7 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f350928d..f866bb302 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1449,7 +1449,7 @@ function AnalysisRunsPanel({ )} {selected.visible_posts && selected.visible_posts.length > 0 && ( -
    +
      {selected.visible_posts.map((post) => (
    • ); @@ -1448,20 +1494,25 @@ function AnalysisRunsPanel({ ))} )} - {selected.visible_posts && selected.visible_posts.length > 0 && ( -
        - {selected.visible_posts.map((post) => ( -
      • - -
      • - ))} -
      + {selected.visible_posts && selected.visible_posts.length > 0 ? ( + <> + {corpusHint &&

      {corpusHint}

      } +
        + {selected.visible_posts.map((post) => ( +
      • + +
      • + ))} +
      + + ) : ( +

      {analysisRunEmptyPostsHint(selected)}

      )}
)} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5bd7638d6..e89edfd07 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.83.0" +__version__ = "0.84.0" diff --git a/pyproject.toml b/pyproject.toml index f7b33f6ce..ed229d426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index cb7c74f87..f6f575ccc 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import hashlib import os import sys from pathlib import Path @@ -30,6 +31,7 @@ import psycopg2 from lineageweave.http_client import get_json_list, post_form +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -37,6 +39,12 @@ DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP). +DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1" +DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1" +DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02" +DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02" + # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report # member click opens. Activity seed uses the same titles so Valkey matches. FIXTURE_TICKET_SPECS = ( @@ -333,6 +341,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_tepp_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1203,36 +1216,57 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces _persist_seed_period_report(cur, "process_unit", high_key, w03, week3[high_key]) -def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: - """Insert one Demo-Corp lineage run so Analysis runs is not empty. +def demo_source_snapshot_sha256() -> str: + """Return the reusable Demo Corp snapshot digest (never a source row).""" + return hashlib.sha256(DEMO_SOURCE_SNAPSHOT_MATERIAL).hexdigest() - Aggregates only: three synthetic documents, one thread. The digest is - a hash of a fixed demo contract string -- never a source row or DSN. - """ - import hashlib - digest = hashlib.sha256(b"lineageweave-synthetic-demo-snapshot-v1").hexdigest() +def _ensure_demo_source_snapshot(cur): + """Return the shared Demo Corp capture, inserting it on first seed. + + Lineage and TEPP runs share this snapshot (ADR 0013: one capture, + many runs). The digest is a hash of a fixed demo contract string -- + never a source row or DSN. + """ + digest = demo_source_snapshot_sha256() cur.execute( "select analysis_source_snapshot_id from analysis_source_snapshot " "where snapshot_sha256 = %s", (digest,), ) snapshot_row = cur.fetchone() - if snapshot_row is None: - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'demo-source-contract-v1', - '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') - returning analysis_source_snapshot_id - """, - (digest,), - ) - snapshot_id = cur.fetchone()[0] - else: - snapshot_id = snapshot_row[0] + if snapshot_row is not None: + return snapshot_row[0] + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, %s, + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest, DEMO_SOURCE_CONTRACT_VERSION), + ) + return cur.fetchone()[0] + + +def _ensure_demo_source_counts(cur, snapshot_id) -> None: + """Insert demo counts only when the snapshot still has none. + + ``enforce_analysis_source_count_freeze`` runs BEFORE INSERT. After + the first run points at the snapshot, a later ``INSERT ... ON + CONFLICT DO NOTHING`` still raises ``analysis_source_count_frozen_after_run`` + and rolls back the whole ``seed()`` transaction. Skip when counts + already exist so ``make seed`` can be re-run. + """ + cur.execute( + "select 1 from analysis_source_count " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return cur.execute( """ insert into analysis_source_count @@ -1242,17 +1276,27 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - (%s, 'analysis_count_thread', 1), (%s, 'analysis_count_lineage_node', 5), (%s, 'analysis_count_lineage_edge', 4) - on conflict do nothing """, (snapshot_id, snapshot_id, snapshot_id, snapshot_id), ) + + +def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp lineage run so Analysis runs is not empty. + + Aggregates only: three synthetic documents, one thread. Reuses the + shared Demo Corp snapshot so a later TEPP run can attach to the + same capture. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-lineage-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_LINEAGE_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1263,12 +1307,18 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'demo-lineage-seed-2026-w02', + values (%s, 'analysis_run_lineage', %s, %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "b" * 64, "c" * 40), + ( + snapshot_id, + DEMO_LINEAGE_IDEMPOTENCY_KEY, + requested_by_account_id, + "b" * 64, + "c" * 40, + ), ) run_id = cur.fetchone()[0] else: @@ -1298,6 +1348,103 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) +def tepp_seed_request() -> AnalysisRunRequest: + """Build the Demo Corp TEPP request against the shared snapshot digest.""" + return AnalysisRunRequest( + idempotency_key=DEMO_TEPP_IDEMPOTENCY_KEY, + tenant_workspace_id="demo-workspace", + snapshot_id=demo_source_snapshot_sha256(), + knowledge_cutoff="2026-01-12T12:00:00Z", + model_contract_version="tepp-analysis-run-v1", + output_profile="calibrated_event_measurement", + ) + + +def tepp_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. A live + envelope is also not a persistable measurement in this seed, so the + run is not stamped Succeeded. + """ + request = tepp_seed_request() + try: + (client or TeppClient()).submit_analysis_run(request) + except TeppNotAvailable: + return "analysis_status_failed", "tepp_not_available" + return "analysis_status_failed", "tepp_result_not_persisted" + + +def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. + + Uses :func:`tepp_seed_outcome` against the shared lineage snapshot. + Default transport is unavailable, so the run ends Failed / + ``tepp_not_available`` -- never a fake theta. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = %s + """, + (requested_by_account_id, DEMO_TEPP_IDEMPOTENCY_KEY), + ) + run_row = cur.fetchone() + if run_row is None: + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_tepp', %s, + %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, + '2026-01-12T12:34:00Z') + returning analysis_run_id + """, + ( + snapshot_id, + DEMO_TEPP_IDEMPOTENCY_KEY, + requested_by_account_id, + "d" * 64, + "e" * 40, + ), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + final_status, failure_code = tepp_seed_outcome() + events = [ + (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None), + (2, "analysis_status_running", "2026-01-12T12:36:00Z", None), + (3, final_status, "2026-01-12T12:37:00Z", failure_code), + ] + for ordinal, status, occurred, fail in events: + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values (%s, %s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred, fail), + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py new file mode 100644 index 000000000..c865c475c --- /dev/null +++ b/tests/test_seed_tepp_run.py @@ -0,0 +1,85 @@ +"""Seeded TEPP analysis runs go through tepp_client, never a local model.""" + +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from scripts.seed_demo_data import ( + _ensure_demo_source_counts, + demo_source_snapshot_sha256, + tepp_seed_outcome, + tepp_seed_request, +) + + +class _RecordingUnavailableClient(TeppClient): + """Default-path stand-in that records the request then drops the channel.""" + + def __init__(self) -> None: + super().__init__() + self.submitted: list[AnalysisRunRequest] = [] + + def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, object]: + self.submitted.append(request) + raise TeppNotAvailable("TEPP has no live HTTP endpoint yet.") + + +class _AcceptingClient(TeppClient): + """Transport that returns an envelope without a persistable measurement.""" + + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + +class _CountCursor: + """Minimal cursor for proving re-seed skips a frozen count insert.""" + + def __init__(self, existing_counts: bool) -> None: + self.existing_counts = existing_counts + self.statements: list[str] = [] + + def execute(self, sql: str, _params=None) -> None: + self.statements.append(" ".join(sql.split())) + + def fetchone(self): + if self.existing_counts and "from analysis_source_count" in self.statements[-1]: + return (1,) + return None + + +def test_tepp_seed_request_targets_the_shared_demo_snapshot() -> None: + request = tepp_seed_request() + assert request.snapshot_id == demo_source_snapshot_sha256() + assert request.idempotency_key == "demo-tepp-seed-2026-w02" + assert request.model_contract_version == "tepp-analysis-run-v1" + assert request.output_profile == "calibrated_event_measurement" + + +def test_tepp_seed_outcome_calls_client_and_does_not_invent_a_score() -> None: + client = _RecordingUnavailableClient() + status, failure = tepp_seed_outcome(client) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + assert client.submitted == [tepp_seed_request()] + + +def test_tepp_seed_outcome_default_client_is_unavailable_not_a_fake_score() -> None: + status, failure = tepp_seed_outcome() + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + + +def test_tepp_seed_outcome_does_not_treat_an_empty_envelope_as_success() -> None: + status, failure = tepp_seed_outcome(_AcceptingClient()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_ensure_demo_source_counts_skips_insert_when_counts_exist() -> None: + cursor = _CountCursor(existing_counts=True) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any("from analysis_source_count" in sql for sql in cursor.statements) + assert not any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: + cursor = _CountCursor(existing_counts=False) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) diff --git a/uv.lock b/uv.lock index 06408c2a9..411243f56 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From dea0b3afe9faca9071fbccb8940c530ef0d01a4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:39:15 +0900 Subject: [PATCH 107/117] fix(ui): show analysis-run digest prefixes on detail (#121) The #89 review asked for 12-character code and config prefixes so an operator can match the approved revision. Full digests stay on the API only. Do not merge until this review item is checked. Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 10 ++++++++++ frontend/src/App.tsx | 13 +++++++++++++ frontend/src/api.ts | 2 ++ 3 files changed, 25 insertions(+) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e2a30c684..65763b7fa 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -235,6 +235,9 @@ describe("App, authenticated", () => { }, ], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + code_revision_sha: "abcdef0123456789deadbeefcafebabe", + configuration_sha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", status_history: [ { status_ordinal: 1, @@ -1454,6 +1457,13 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Code abcdef012345"); + expect(digests).toHaveTextContent("Config 0123456789ab"); + expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); + expect(digests).not.toHaveTextContent( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); const history = screen.getByRole("list", { name: "Analysis run status history" }); expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50602c687..9d947ee47 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1477,6 +1477,19 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

+ {(selected.code_revision_sha || selected.configuration_sha256) && ( +

+ {selected.code_revision_sha + ? `Code ${selected.code_revision_sha.slice(0, 12)}` + : ""} + {selected.code_revision_sha && selected.configuration_sha256 + ? " · " + : ""} + {selected.configuration_sha256 + ? `Config ${selected.configuration_sha256.slice(0, 12)}` + : ""} +

+ )}
    {selected.source_counts.map((count) => (
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3dacb054c..740529cba 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -521,6 +521,8 @@ export interface AnalysisRun { source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; + code_revision_sha?: string; + configuration_sha256?: string; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { From e9c42babc051b8fc48e411b2cdf4ed919d776d00 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:49:31 +0900 Subject: [PATCH 108/117] fix: keep failed-run next actions kind-specific (#124) * feat: seed a TEPP analysis run through tepp_client (v0.84.0) Buyer gap: home Analysis runs only showed lineage reconstruction. make seed now records a Demo Corp TEPP measurement via tepp_client. The default transport is unavailable, so the row is Failed / tepp_not_available -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. * fix: fail-closed TEPP seed on the shared Demo Corp snapshot #111 still marked a live unused envelope Succeeded, named a different capture than the registry row, and re-inserted frozen counts. Seed now reuses the lineage snapshot (ADR 0013), skips count inserts after the first run, and keeps missing or unused TEPP Failed. The home list tells the operator to open the run and connect TEPP; detail history keeps tepp_not_available. Co-authored-by: Seongho Bae * fix: keep failed-run next actions kind-specific A failed lineage row must not tell the operator to connect TEPP. Stacked PRs now run the same GitHub Checks as PRs to main. Co-authored-by: Seongho Bae * docs: keep TEPP next-action copy off failed lineage rows Co-authored-by: Seongho Bae * fix: keep TEPP corpus hint off a succeeded measurement A calibrated TEPP row must not tell the operator to replace Failed. Co-authored-by: Seongho Bae --------- Co-authored-by: Seongho Bae Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 1 - ARCHITECTURE.md | 5 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 +- CHANGELOG.md | 4 +- CLAUDE.md | 4 +- docs/adr/0014-authorized-analysis-run-read.md | 9 +-- frontend/src/App.test.tsx | 63 ++++++++++++++++--- frontend/src/App.tsx | 29 ++++++--- 8 files changed, 91 insertions(+), 30 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36e24332b..e78d36254 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b662b00b1..2492ee50d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,9 +475,10 @@ labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed -list rows add a next-action line (open the run, then connect the +TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a -calibrated negative result. The +calibrated negative result. A failed lineage row tells the operator +to retry reconstruction, not to connect TEPP. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index 080cc8240..c96531899 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,6 +1,6 @@ # 0.84.0 TEPP analysis-run seed Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo -Corp snapshot. The home list shows Failed and the next action; detail -history keeps `tepp_not_available`. Missing transport is not a fake -measurement. +Corp snapshot. The home list shows Failed and a kind-specific next +action; detail history keeps `tepp_not_available`. Missing transport +is not a fake measurement. A failed lineage row does not mention TEPP. diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f4878b4..c36b2666d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ All notable changes to this project are documented here. Format follows keeps `tepp_not_available` -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. `make seed` skips snapshot-count inserts once counts exist so a re-run does not hit - the freeze trigger. + the freeze trigger. A failed lineage row tells the operator to retry + reconstruction; only a failed TEPP row mentions the measurement + service. Stacked PRs now run the same GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md index 3af72ad45..0a2950e91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,4 +11,6 @@ transport or an unused accepted envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open the Failed row, then connect a live TEPP transport. +(ADR 0014). Open a Failed TEPP row, then connect a live TEPP +transport. A failed lineage row retries reconstruction -- it does not +mention TEPP. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index dea201bfc..841dfb383 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -43,10 +43,11 @@ run on the same snapshot so the existing React home page can show both kinds without a second application. The TEPP run is Failed / `tepp_not_available` when the default transport is missing -- the list keeps that machine code off the caption (this decision) and instead -tells the operator to open the run, then connect the measurement -service. The detail now shows the legal lifecycle the registry already -stored. Write/rebuild APIs, a live TEPP transport, and a fuller -Analysis Run Console remain later slices. +tells the operator to open the TEPP run, then connect the measurement +service. A failed lineage row tells the operator to retry +reconstruction, not to connect TEPP. The detail now shows the legal +lifecycle the registry already stored. Write/rebuild APIs, a live TEPP +transport, and a fuller Analysis Run Console remain later slices. ## References diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65763b7fa..d8d82c8d7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,6 +59,8 @@ describe("App, authenticated", () => { chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; + failedLineageRun?: boolean; + succeededTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -178,8 +180,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -205,10 +209,14 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", occurred_at: "2026-01-12T12:37:00Z", - failure_code: "tepp_not_available", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), }, ], }), @@ -272,8 +280,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -291,8 +301,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -1489,6 +1501,39 @@ describe("App, authenticated", () => { expect(teppHistory).not.toHaveTextContent("Succeeded"); }); + it("does not tell a failed lineage run to connect the measurement service", async () => { + stubBackend({ failedLineageRun: true }); + render(); + + const list = await screen.findByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + expect(lineageButton).not.toHaveTextContent("measurement service"); + }); + + it("does not tell a succeeded TEPP run to replace Failed", async () => { + stubBackend({ succeededTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + }); + it("shows the calibrated period-report mean theta on the home page", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d947ee47..65af9596c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1356,14 +1356,22 @@ function analysisRunCaption(run: AnalysisRun): string { /** * Next action for a failed run on the home list. * - * The machine `failure_code` stays on detail history (ADR 0014). The - * list tells the operator to open the run, then reconnect the service. + * The machine `failure_code` stays on detail history (ADR 0014). Copy + * is kind-specific so a failed lineage reconstruction is not mistaken + * for a missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_failed") { - return "Open this run to see why it failed, then connect the measurement service and re-run."; + if (run.status_code !== "analysis_status_failed") { + return null; + } + switch (run.run_kind_code) { + case "analysis_run_tepp": + return "Open this run to see why it failed, then connect the measurement service and re-run."; + case "analysis_run_lineage": + return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + default: + return "Open this run to see why it failed, then retry after the blocking service is connected."; } - return null; } /** @@ -1389,10 +1397,13 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + if (run.status_code === "analysis_status_failed") { + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + } + return "These posts are the cutoff corpus this TEPP run measured."; } function AnalysisRunsPanel({ From 44912a642f997830620718b2e69106457e73c3f3 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:54:37 +0000 Subject: [PATCH 109/117] fix(ui): keep analysis-run digests audible and warn on live posts (v0.84.1) (#127) * fix(ui): keep analysis-run digests audible and warn on live posts aria-label on the digest paragraph hid the prefixes from assistive technology. Move the label to a group, keep prefixes as visible text, and put the full digest on hover. Tell the operator that a cutoff title opens the live body so they compare it with the run clock. Co-authored-by: Seongho Bae * docs: mark analysis-run seed pointer as v0.84.1 Co-authored-by: Seongho Bae --------- Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 6 +- .../0.84.1-analysis-run-digest-a11y.md | 5 ++ CHANGELOG.md | 13 ++++ CLAUDE.md | 5 +- ...016-analysis-run-knowledge-cutoff-posts.md | 20 ++++- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +- frontend/package.json | 2 +- frontend/src/App.css | 21 ++++- frontend/src/App.test.tsx | 31 +++++++- frontend/src/App.tsx | 76 +++++++++++++++---- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 13 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2492ee50d..063b7a196 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -471,8 +471,10 @@ revision and configuration digest prefixes. `tepp_client` on that same snapshot; the default transport is unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts, status history) -without exposing a DSN or raw record. Status history is detail-only +labeled detail (cutoff, requested date, 12-character digest prefixes +with full digests on hover, counts, status history) +without exposing a DSN or raw record. Opening a cutoff title warns +that the live body may have changed after the run. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the diff --git a/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md new file mode 100644 index 000000000..213eb5451 --- /dev/null +++ b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md @@ -0,0 +1,5 @@ +# 0.84.1 Analysis-run digest a11y and live-body warning + +Detail prefixes stay audible and hoverable. Open a cutoff title only +after reading that the live body may have changed since the run. +The list stays aggregates-only. diff --git a/CHANGELOG.md b/CHANGELOG.md index c36b2666d..22b372c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.84.1] - 2026-08-16 + +### Fixed + +- Analysis-run detail keeps 12-character digest prefixes as visible + text (so assistive technology hears `Code` / `Config` values) and + puts the full digest on hover. Open the Demo Corp lineage run, hover + a prefix, and match it to the API payload. The home list still hides + digests even when the list JSON includes them. +- Opening a cutoff title now says the live body may have changed after + that run. Compare the opened post with the cutoff date before you + treat it as reconstructed evidence (ADR 0016). + ## [0.84.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0a2950e91..71e671038 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. -## Analysis-run seed (v0.84.0) +## Analysis-run seed (v0.84.1) `make seed` writes a Demo Corp lineage run and a TEPP run on the same snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing @@ -14,3 +14,6 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. +Digest prefixes stay audible; hover a prefix to read the full digest. +Opening a cutoff title shows the live post -- compare it with the +cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index d6ac70db8..f9c82a86d 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -25,7 +25,15 @@ every scope branch (corporate entity, process unit, thread group, and all-visible). ABAC visibility is applied after that temporal gate. Click-through still opens the live post body -- post versioning is a later slice -- but the run list itself must not advertise a post the -run was not allowed to know. +run was not allowed to know. The detail must say that next action +plainly: compare the opened body with this cutoff before treating it +as reconstructed evidence. + +Reproducibility digests on the same detail use a labeled group whose +accessible name does not replace the visible prefixes (W3C Accessible +Name and Description Computation 1.1). Full digests stay on `title` +for hover verification and on the API payload; the home list stays +aggregates-only. Seed and API fixtures backdate in-cutoff posts. A late own-corp private post remains on the live post list and stays out of the January 2026 @@ -36,8 +44,10 @@ run. - After `make seed`, the Demo Corp lineage run lists Demo public post and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. -- Open the run, then open a listed post, to inspect what that cutoff - actually reconstructed. +- Open the run, read the live-body warning, then open a listed post + and compare it with the cutoff date. +- Hover a digest prefix to read the full code or configuration digest + when you need to match the API payload. - Post-body versioning at the cutoff remains future work. ## References @@ -48,3 +58,7 @@ rules* (confirmed 2024; Amendment 1:2022). World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index a1dc73957..b41b31c17 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -8,7 +8,8 @@ | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | +| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. | @@ -98,5 +99,8 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C Recommendation). https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ + World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index c21ed209f..8ce5f334b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index dfd0f2e81..8f38b4dd0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -85,9 +85,26 @@ cursor: pointer; } +:root { + --lw-opacity-meta: 0.7; + --lw-font-size-meta: 0.85rem; +} + .post-meta { - opacity: 0.7; - font-size: 0.85rem; + opacity: var(--lw-opacity-meta); + font-size: var(--lw-font-size-meta); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } .post-body { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d8d82c8d7..c77d965b5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -293,6 +293,9 @@ describe("App, authenticated", () => { count_value: 3, }, ], + code_revision_sha: "abcdef0123456789deadbeefcafebabe", + configuration_sha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, { analysis_run_id: "run-demo-tepp", @@ -1460,6 +1463,12 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); + expect(list).not.toHaveTextContent("Code abcdef012345"); + expect(list).not.toHaveTextContent("Config 0123456789ab"); + expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); + expect(list).not.toHaveTextContent( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); await userEvent.click( screen.getByRole("button", { @@ -1470,21 +1479,39 @@ describe("App, authenticated", () => { expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Hover a prefix to read the full digest for verification."); expect(digests).toHaveTextContent("Code abcdef012345"); expect(digests).toHaveTextContent("Config 0123456789ab"); expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); expect(digests).not.toHaveTextContent( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); + expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345"); + expect( + screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + ).toHaveTextContent("Config 0123456789ab"); const history = screen.getByRole("list", { name: "Analysis run status history" }); expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); + expect( + screen.getByText( + "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post (may have changed after cutoff): Public post", + }), + ).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post (may have changed after cutoff): Public post", + }), + ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); await userEvent.click( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65af9596c..948035430 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1406,6 +1406,62 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { return "These posts are the cutoff corpus this TEPP run measured."; } +/** Git-style prefix. The full digest stays on `title` for verification. */ +const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12; + +function analysisRunDigestPrefix(digest: string): string { + return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); +} + +/** + * Next action when a cutoff title opens the live post (ADR 0016). + * + * Post-body versioning is a later slice. Until then the operator must + * compare the opened body with this run's cutoff instead of treating + * today's text as reconstructed evidence. + */ +function analysisRunLivePostWarning(cutoffIso: string): string { + const cutoffDate = cutoffIso.slice(0, 10); + return ( + `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` + + "before you treat the body as reconstructed evidence — it may have changed after this run." + ); +} + +function analysisRunLivePostButtonLabel(postTitle: string): string { + return `Open live post (may have changed after cutoff): ${postTitle}`; +} + +function AnalysisRunReproducibilityDigests({ + codeRevisionSha, + configurationSha256, +}: { + codeRevisionSha?: string; + configurationSha256?: string; +}) { + if (!codeRevisionSha && !configurationSha256) { + return null; + } + return ( +
    +

    + + Hover a prefix to read the full digest for verification.{" "} + + {codeRevisionSha ? ( + {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} + ) : null} + {codeRevisionSha && configurationSha256 ? " · " : null} + {configurationSha256 ? ( + + {`Config ${analysisRunDigestPrefix(configurationSha256)}`} + + ) : null} +

    +
    + ); +} + function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1488,19 +1544,10 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

    - {(selected.code_revision_sha || selected.configuration_sha256) && ( -

    - {selected.code_revision_sha - ? `Code ${selected.code_revision_sha.slice(0, 12)}` - : ""} - {selected.code_revision_sha && selected.configuration_sha256 - ? " · " - : ""} - {selected.configuration_sha256 - ? `Config ${selected.configuration_sha256.slice(0, 12)}` - : ""} -

    - )} +
      {selected.source_counts.map((count) => (
    • @@ -1521,12 +1568,13 @@ function AnalysisRunsPanel({ {selected.visible_posts && selected.visible_posts.length > 0 ? ( <> {corpusHint &&

      {corpusHint}

      } +

      {analysisRunLivePostWarning(selected.knowledge_cutoff)}

        {selected.visible_posts.map((post) => (
      • {error &&

        {error}

        } {runs.length === 0 ? (

        - No analysis runs visible to this account yet -- try `make seed`. + No analysis runs visible to this account yet. Request a lineage + reconstruction, or ask an administrator to run make seed.

        ) : (
          diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 740529cba..e35bcf3ed 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -532,3 +532,21 @@ export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); } + +export interface CreateAnalysisRunRequest { + run_kind_code?: string; + scope_kind_code?: string; + corporate_entity_id?: string; + knowledge_cutoff?: string; + idempotency_key: string; +} + +export function createAnalysisRun( + accessToken: string, + request: CreateAnalysisRunRequest, +): Promise { + return backendFetch("/api/analysis-runs", accessToken, { + method: "POST", + body: JSON.stringify(request), + }); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 65fa5d182..5e05ef4ff 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.84.1" +__version__ = "0.85.0" diff --git a/pyproject.toml b/pyproject.toml index b27655c2b..8750ae3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.84.1" +version = "0.85.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py new file mode 100644 index 000000000..4e24a4228 --- /dev/null +++ b/tests/test_analysis_run_create.py @@ -0,0 +1,134 @@ +"""Authorized analysis-run create hashes the cutoff bag, never a score.""" + +from datetime import datetime, timezone + +from backend.app.analysis_run_ingestion import ( + AnalysisRunCreateError, + _resolve_corporate_entity_id, + plan_analysis_run_capture, +) +import pytest + + +_CUTOFF = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc) +_EARLIER = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc) + + +def test_capture_digest_is_stable_for_the_same_authorized_bag() -> None: + first = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-b", "post-a"], + thread_keys=["thread-a", "thread-a"], + latest_post_created_at=_EARLIER, + ) + second = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a", "post-b"], + thread_keys=["thread-a", "thread-a"], + latest_post_created_at=_EARLIER, + ) + assert first.snapshot_sha256 == second.snapshot_sha256 + assert first.configuration_sha256 == second.configuration_sha256 + assert first.document_count == 2 + assert first.thread_count == 1 + assert first.maximum_available_time == _EARLIER + assert "theta" not in first.snapshot_sha256 + assert first.configuration_schema_version == "lineage-run-v1" + + +def test_later_cutoff_or_other_kind_does_not_reuse_the_wrong_digest() -> None: + lineage = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + ) + later = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=datetime(2026, 1, 13, 12, 0, tzinfo=timezone.utc), + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + ) + tepp = plan_analysis_run_capture( + run_kind_code="analysis_run_tepp", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + ) + assert lineage.snapshot_sha256 != later.snapshot_sha256 + assert lineage.snapshot_sha256 == tepp.snapshot_sha256 + assert lineage.configuration_sha256 != tepp.configuration_sha256 + assert tepp.configuration_schema_version == "tepp-run-v1" + + +def test_omitted_cutoff_keeps_the_same_client_key_stable() -> None: + first = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + cutoff_explicit=False, + ) + later_clock = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=datetime(2026, 1, 13, 12, 0, tzinfo=timezone.utc), + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + cutoff_explicit=False, + ) + assert first.configuration_sha256 == later_clock.configuration_sha256 + assert first.snapshot_sha256 == later_clock.snapshot_sha256 + + +def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None: + capture = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=[], + thread_keys=[], + latest_post_created_at=None, + ) + assert capture.document_count == 0 + assert capture.thread_count == 0 + assert capture.maximum_available_time == _CUTOFF + + +def test_create_rejects_an_unaffiliated_or_ambiguous_corporate_entity() -> None: + with pytest.raises(AnalysisRunCreateError) as hidden: + _resolve_corporate_entity_id("corp-other", ["corp-1"]) + assert hidden.value.status_code == 404 + with pytest.raises(AnalysisRunCreateError) as ambiguous: + _resolve_corporate_entity_id(None, ["corp-1", "corp-2"]) + assert ambiguous.value.status_code == 422 + assert _resolve_corporate_entity_id(None, ["corp-1"]) == "corp-1" diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py index c865c475c..b25908cbe 100644 --- a/tests/test_seed_tepp_run.py +++ b/tests/test_seed_tepp_run.py @@ -3,6 +3,7 @@ from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable from scripts.seed_demo_data import ( _ensure_demo_source_counts, + _seed_demo_tepp_run, demo_source_snapshot_sha256, tepp_seed_outcome, tepp_seed_request, @@ -83,3 +84,49 @@ def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: cursor = _CountCursor(existing_counts=False) _ensure_demo_source_counts(cursor, "snapshot-1") assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +class _TeppSeedCursor: + """Drive `_seed_demo_tepp_run` without a live database.""" + + def __init__(self) -> None: + self.statements: list[str] = [] + self.params: list[object] = [] + + def execute(self, sql: str, params=None) -> None: + self.statements.append(" ".join(sql.split())) + self.params.append(params) + + def fetchone(self): + last = self.statements[-1] + if last.lstrip().startswith("select") and "from analysis_source_snapshot" in last: + return None + if "insert into analysis_source_snapshot" in last: + return ("snapshot-demo",) + if last.lstrip().startswith("select") and "from analysis_source_count" in last: + return None + if last.lstrip().startswith("select") and "from analysis_run" in last: + return None + if "insert into analysis_run" in last: + return ("run-demo-tepp",) + return None + + +def test_seed_demo_tepp_run_inserts_failed_tepp_not_available() -> None: + cursor = _TeppSeedCursor() + _seed_demo_tepp_run(cursor, "account-1", "corp-1") + run_inserts = [sql for sql in cursor.statements if "insert into analysis_run" in sql] + assert run_inserts, "seed must insert the TEPP analysis_run row" + assert any("analysis_run_tepp" in sql for sql in run_inserts) + status_params = [ + params + for sql, params in zip(cursor.statements, cursor.params, strict=True) + if "insert into analysis_run_status_event" in sql + ] + assert any( + params is not None and "analysis_status_failed" in params and "tepp_not_available" in params + for params in status_params + ) + assert not any( + params is not None and "analysis_status_succeeded" in params for params in status_params + ) diff --git a/uv.lock b/uv.lock index 156b0813d..20f9a8793 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.84.1" +version = "0.85.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 3c17fd3abbec94b6c2464bd6b369bba736f844b6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:02:24 +0900 Subject: [PATCH 111/117] feat: walk team and organization related nodes (v0.86.0) (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related-node RWR now loads team and organization mention edges, so a team-only follow-up is no longer an island. R&R team names become buttons. Thread-group run lists honor knowledge_cutoff. ADR 0018 — #125 already used ADR 0017 for POST /api/analysis-runs. Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 18 +- .../0.86.0-related-nodes-team-org-walk.md | 2 + CHANGELOG.md | 18 ++ backend/app/analysis_run_ingestion.py | 1 + backend/app/knowledge_graph.py | 142 +++++++++++- backend/app/main.py | 31 +++ backend/app/post_summary_ingestion.py | 52 ++++- backend/tests/test_api.py | 156 +++++++++++++ ...016-analysis-run-knowledge-cutoff-posts.md | 3 + docs/adr/0018-related-nodes-team-org-walk.md | 67 ++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 19 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 58 +++++ frontend/src/App.tsx | 217 +++++++++++++----- frontend/src/api.ts | 17 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_person_mention_projection.py | 88 ++++++- uv.lock | 2 +- 19 files changed, 806 insertions(+), 91 deletions(-) create mode 100644 CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md create mode 100644 docs/adr/0018-related-nodes-team-org-walk.md create mode 100644 docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da35cc779..aa557bd7a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -256,11 +256,14 @@ pattern and then hide the action button so it cannot 503 again. `find_linked_post_ids` first expands to every post sharing a mentioned person before calling `backend/app/knowledge_graph.py::load_visible_subgraph` -- that function -only loads edges among an *already-known* post set (its other caller, -`related_for_person`, pre-resolves the full set itself), it does not -discover new posts on its own; a real bug from calling it with only the -single starting post was caught while building this and is now -regression-tested (`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`). +only loads edges among an *already-known* post set (its other callers, +`related_for_person` / `related_for_entity` / `related_for_team`, +pre-resolve the full set themselves), it does not discover new posts on +its own; a real bug from calling it with only the single starting post +was caught while building this and is now regression-tested +(`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`). +Person, team, and organization mention channels load independently +(ADR 0018): a team-only or organization-only post still walks. ### Frontend (`frontend/`) @@ -276,8 +279,9 @@ summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel affiliate tree (resolved ancestors plus unresolved org roots), Keyman + counterparty panels (a Keyman click loads RWR related nodes; a related corporate-entity node, a resolved Keyman affiliation, -or a classified name that resolves to a cataloged org continues -the same walk via `GET /api/corporate-entities/{id}/related`; +a classified name that resolves to a cataloged org, or an R&R team +continues the same walk via `GET /api/corporate-entities/{id}/related` +or `GET /api/teams/{id}/related`; `post_admin` can extract), and an in-popup chat whose cited sources open a sliding evidence panel (`EvidencePanel`, CSS diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md new file mode 100644 index 000000000..4efa8100f --- /dev/null +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -0,0 +1,2 @@ +Related-node walks include team and organization mention edges. Click an +R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. diff --git a/CHANGELOG.md b/CHANGELOG.md index fec2c509b..434c4d63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.0] - 2026-08-16 + +### Added + +- Related-node walks now include team and organization mention edges. + After `make seed` and a summary that names 설계팀 on two posts, open + either post, click the R&R team, and open the sibling post (ADR 0018). + A team-only follow-up is no longer an island. +- `GET /api/teams/{team_id}/related` starts the same RWR walk Keyman + and corporate-entity related already use. Related team chips are + buttons. + +### Fixed + +- Thread-group analysis-run *lists* now require an in-cutoff visible + post. A later public post in that thread group no longer surfaces a + January run the account was not allowed to know. + ## [0.85.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 6a1449818..d26eb6f6e 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -52,6 +52,7 @@ and exists ( select 1 from source_post p where p.thread_group_key = scope.scope_key + and p.created_at <= run.knowledge_cutoff and ( p.visibility_code = 'public' or p.corporate_entity_id = any($2::uuid[]) diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 4035134c0..ce7289bb5 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -18,9 +18,13 @@ EDGE_AFFILIATION, EDGE_CO_MENTION, EDGE_MENTION, + EDGE_MENTION_ORGANIZATION, + EDGE_MENTION_TEAM, + EDGE_TEAM_AFFILIATION, NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, + NODE_TEAM, KnowledgeGraphEdgeSpec, adjacency_from_edges, knowledge_graph_edges_for_post, @@ -235,6 +239,16 @@ async def corporate_entity_exists(conn: asyncpg.Connection, entity_id: str) -> b return row is not None +async def team_exists(conn: asyncpg.Connection, team_id: str) -> bool: + """True when ``team_id`` is a UUID that exists in ``cataloged_team``.""" + try: + UUID(team_id) + except ValueError: + return False + row = await conn.fetchrow("select 1 from cataloged_team where team_id = $1", team_id) + return row is not None + + async def visible_mention_post_ids( conn: asyncpg.Connection, person_id: str, @@ -258,27 +272,57 @@ async def visible_affiliation_post_ids( entity_id: str, can_see_post, ) -> list[str]: - """Visible posts whose Keyman or R&R people affiliate with an entity.""" + """Visible posts that mention an entity via a person or a direct org mention.""" rows = await conn.fetch( """ select distinct post.post_id, post.visibility_code, post.corporate_entity_id, post.created_at - from person_affiliation affiliation - join combined_post_person_mention mention - on mention.person_id = affiliation.person_id - join source_post post on post.post_id = mention.post_id - where affiliation.affiliated_corporate_entity_id = $1 + from source_post post + where post.post_id in ( + select mention.post_id + from person_affiliation affiliation + join combined_post_person_mention mention + on mention.person_id = affiliation.person_id + where affiliation.affiliated_corporate_entity_id = $1 + union + select org_mention.post_id + from post_organization_mention org_mention + where org_mention.corporate_entity_id = $1 + ) order by post.created_at, post.post_id """, entity_id, ) return [str(row["post_id"]) for row in rows if can_see_post(row)] + +async def visible_team_mention_post_ids( + conn: asyncpg.Connection, + team_id: str, + can_see_post, +) -> list[str]: + """Visible post ids supported by a cataloged team mention.""" + rows = await conn.fetch( + """ + select post.post_id, post.visibility_code, post.corporate_entity_id + from post_team_mention mention + join source_post post on post.post_id = mention.post_id + where mention.team_id = $1 + order by post.created_at, post.post_id + """, + team_id, + ) + return [str(row["post_id"]) for row in rows if can_see_post(row)] + async def load_visible_subgraph( conn: asyncpg.Connection, visible_post_ids: list[str], ) -> list[KnowledgeGraphEdgeSpec]: - """Edges supported by at least one post the account may already see.""" + """Edges supported by at least one post the account may already see. + + Person, team, and organization mention channels are independent. A + team-only or organization-only post must still walk (ADR 0018). + """ if not visible_post_ids: return [] person_rows = await conn.fetch( @@ -287,7 +331,19 @@ async def load_visible_subgraph( visible_post_ids, ) person_ids = [row["person_id"] for row in person_rows] - if not person_ids: + team_rows = await conn.fetch( + "select distinct team_id from post_team_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + team_ids = [row["team_id"] for row in team_rows] + organization_rows = await conn.fetch( + "select distinct corporate_entity_id from post_organization_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + organization_ids = [row["corporate_entity_id"] for row in organization_rows] + if not person_ids and not team_ids and not organization_ids: return [] rows = await conn.fetch( """ @@ -326,6 +382,48 @@ async def load_visible_subgraph( and edge.target_node_id = any($2::uuid[])) ) ) + or ( + edge.edge_type_code = $8 + and ( + (edge.source_node_type_code = $4 + and edge.source_node_id = any($1::uuid[])) + or + (edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[])) + or + (edge.source_node_type_code = $9 + and edge.source_node_id = any($10::uuid[])) + or + (edge.target_node_type_code = $9 + and edge.target_node_id = any($10::uuid[])) + ) + ) + or ( + edge.edge_type_code = $11 + and ( + (edge.source_node_type_code = $9 + and edge.source_node_id = any($10::uuid[])) + or + (edge.target_node_type_code = $9 + and edge.target_node_id = any($10::uuid[])) + ) + ) + or ( + edge.edge_type_code = $12 + and ( + (edge.source_node_type_code = $4 + and edge.source_node_id = any($1::uuid[])) + or + (edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[])) + or + (edge.source_node_type_code = $13 + and edge.source_node_id = any($14::uuid[])) + or + (edge.target_node_type_code = $13 + and edge.target_node_id = any($14::uuid[])) + ) + ) """, visible_post_ids, person_ids, @@ -334,6 +432,13 @@ async def load_visible_subgraph( EDGE_CO_MENTION, NODE_PERSON, EDGE_AFFILIATION, + EDGE_MENTION_TEAM, + NODE_TEAM, + team_ids, + EDGE_TEAM_AFFILIATION, + EDGE_MENTION_ORGANIZATION, + NODE_CORPORATE_ENTITY, + organization_ids, ) return [edge_spec_from_row(row) for row in rows] @@ -349,6 +454,7 @@ async def hydrate_related_nodes( person_ids: list[str] = [] post_ids: list[str] = [] corp_ids: list[str] = [] + team_ids: list[str] = [] parsed: list[tuple[str, str, float]] = [] for key, score in related: node_type_code, node_id = parse_node_key(key) @@ -359,6 +465,8 @@ async def hydrate_related_nodes( post_ids.append(node_id) elif node_type_code == NODE_CORPORATE_ENTITY: corp_ids.append(node_id) + elif node_type_code == NODE_TEAM: + team_ids.append(node_id) people = { str(row["person_id"]): row @@ -381,6 +489,13 @@ async def hydrate_related_nodes( corp_ids, ) } if corp_ids else {} + teams = { + str(row["team_id"]): row + for row in await conn.fetch( + "select team_id, team_name from cataloged_team where team_id = any($1::uuid[])", + team_ids, + ) + } if team_ids else {} side_labels = await labels_for_codes( conn, [row["person_side_code"] for row in people.values()] @@ -403,6 +518,8 @@ async def hydrate_related_nodes( item["label"] = posts[node_id]["post_title"] elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps: item["label"] = corps[node_id]["entity_name"] + elif node_type_code == NODE_TEAM and node_id in teams: + item["label"] = teams[node_id]["team_name"] else: continue payload.append(item) @@ -439,3 +556,12 @@ async def related_for_entity( ) -> list[dict[str, Any]]: """Run RWR from ``entity_id`` over the account's visible subgraph.""" return await related_for_start(conn, NODE_CORPORATE_ENTITY, entity_id, visible_post_ids) + + +async def related_for_team( + conn: asyncpg.Connection, + team_id: str, + visible_post_ids: list[str], +) -> list[dict[str, Any]]: + """Run RWR from ``team_id`` over the account's visible subgraph.""" + return await related_for_start(conn, NODE_TEAM, team_id, visible_post_ids) diff --git a/backend/app/main.py b/backend/app/main.py index de06a1167..adb7a20a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -116,8 +116,11 @@ persist_edges_for_post, related_for_entity, related_for_person, + related_for_team, + team_exists, visible_affiliation_post_ids, visible_mention_post_ids, + visible_team_mention_post_ids, ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph from backend.app.post_chat_ingestion import ( @@ -473,6 +476,34 @@ async def read_related_corporate_entity( } +@app.get("/api/teams/{team_id}/related") +async def read_related_team( + team_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RWR-ranked related nodes from one cataloged team, hiding unseen posts.""" + _require_post_read(account) + async with pool.acquire() as conn: + if not await team_exists(conn, team_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found") + visible_post_ids = await visible_team_mention_post_ids( + conn, team_id, lambda row: _can_see_post(account, row) + ) + if not visible_post_ids: + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") + team = await conn.fetchrow( + "select team_id, team_name from cataloged_team where team_id = $1", + team_id, + ) + related = await related_for_team(conn, team_id, visible_post_ids) + return { + "team_id": str(team["team_id"]), + "team_name": team["team_name"], + "related": related, + } + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index c79d036d2..36d4fadae 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -34,6 +34,7 @@ NullCorporateHierarchyInferenceClient, ) from lineageweave.fixtures import fixture_thread_cast +from lineageweave.knowledge_graph import NODE_CORPORATE_ENTITY, NODE_TEAM from lineageweave.ontology import ontology_annotations from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, @@ -68,24 +69,57 @@ async def fetch_persisted_summary( post_id, ) roles = await conn.fetch( - "select actor_name, responsibility, actor_type_code, affiliated_organization_name " - "from post_summary_role where post_id = $1 order by actor_name", + """ + select role.actor_name, role.responsibility, role.actor_type_code, + role.affiliated_organization_name, + team_mention.team_id, + org_mention.corporate_entity_id + from post_summary_role role + left join cataloged_team team + on role.actor_type_code = 'prov_team' + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name + left join post_team_mention team_mention + on team_mention.post_id = role.post_id + and team_mention.team_id = team.team_id + left join corporate_entity org + on role.actor_type_code = 'prov_organization' + and org.entity_name = role.actor_name + left join post_organization_mention org_mention + on org_mention.post_id = role.post_id + and org_mention.corporate_entity_id = org.corporate_entity_id + where role.post_id = $1 + order by role.actor_name + """, post_id, ) - return { - "post_id": post_id, - "korean_summary": header["korean_summary"], - "key_events": [row["event_text"] for row in events], - "roles_and_responsibilities": [ + payload_roles: list[dict[str, Any]] = [] + for row in roles: + catalog_node_id = None + catalog_node_type_code = None + if row["team_id"] is not None: + catalog_node_id = str(row["team_id"]) + catalog_node_type_code = NODE_TEAM + elif row["corporate_entity_id"] is not None: + catalog_node_id = str(row["corporate_entity_id"]) + catalog_node_type_code = NODE_CORPORATE_ENTITY + payload_roles.append( { "actor_name": row["actor_name"], "responsibility": row["responsibility"], "actor_type_code": row["actor_type_code"], "affiliated_organization_name": row["affiliated_organization_name"], + "catalog_node_id": catalog_node_id, + "catalog_node_type_code": catalog_node_type_code, **ontology_annotations(row["actor_type_code"]), } - for row in roles - ], + ) + return { + "post_id": post_id, + "korean_summary": header["korean_summary"], + "key_events": [row["event_text"] for row in events], + "roles_and_responsibilities": payload_roles, } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e62ff0fa1..bff7f7c64 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -22,6 +22,7 @@ import redis from lineageweave.http_client import HttpClientError, get_json, post_form +from lineageweave.knowledge_graph import knowledge_graph_edges_for_post _POSTGRES_ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -1450,6 +1451,161 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: assert mentioning_post_count == 2, "both posts must link to the single cataloged team" assert team_mention_edge_count == 2, "each post's mention must become a real KG edge" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute("select team_id from cataloged_team where team_name = '설계팀'") + team_id = str(cur.fetchone()[0]) + finally: + admin_conn.close() + + related = client.get( + f"/api/teams/{team_id}/related", + headers=headers, + ) + assert related.status_code == 200, related.text + related_ids = {node["node_id"] for node in related.json()["related"]} + assert set(post_ids) <= related_ids + summaries = [ + client.get(f"/api/posts/{post_id}/summary", headers=headers).json() + for post_id in post_ids + ] + for body in summaries: + role = body["roles_and_responsibilities"][0] + assert role["catalog_node_id"] == team_id + assert role["catalog_node_type_code"] == "node_team" + + +def test_organization_mention_only_posts_appear_in_entity_related( + client, demo_analyst_token, seeded_db +) -> None: + """An org mentioned with no affiliated person must still start a related walk.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + ("Org-only mention", "Test Corp was named without a person.", seeded_db["own_private_post_id"]), + ) + org_only_post_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (org_only_post_id, seeded_db["own_corp_id"]), + ) + for edge in knowledge_graph_edges_for_post( + org_only_post_id, + [], + organization_corporate_entity_ids=[seeded_db["own_corp_id"]], + ): + cur.execute( + "insert into knowledge_graph_edge (" + "source_node_type_code, source_node_id, target_node_type_code, " + "target_node_id, edge_type_code, edge_weight" + ") values (%s, %s, %s, %s, %s, %s) " + "on conflict do nothing", + ( + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.edge_weight, + ), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + related_ids = {node["node_id"] for node in response.json()["related"]} + assert org_only_post_id in related_ids + + +def test_thread_group_run_list_honors_knowledge_cutoff( + client, demo_analyst_token, seeded_db +) -> None: + """A later public post must not surface a previously hidden thread-group run.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, thread_group_key, created_at) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public', %s, %s " + "from source_post where post_id = %s", + ( + "Late thread-group post", + "Written after the January cutoff.", + "late-thread-group", + "2026-01-20T12:00:00Z", + seeded_db["own_private_post_id"], + ), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("f" * 64,), + ) + snapshot_id = cur.fetchone()[0] + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', %s, + (select user_account_id from user_account + where email_address = 'other.analyst@example.test'), + '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, "hidden-late-thread", "b" * 64, "c" * 40), + ) + run_id = str(cur.fetchone()[0]) + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, scope_key) + values (%s, 'analysis_scope_thread_group', 'late-thread-group') + """, + (run_id,), + ) + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z') + """, + (run_id,), + ) + finally: + admin_conn.close() + + listed = client.get( + "/api/analysis-runs", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert listed.status_code == 200 + ids = {run["analysis_run_id"] for run in listed.json()["analysis_runs"]} + assert run_id not in ids + assert seeded_db["visible_run_id"] in ids + def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity( client, demo_analyst_token, seeded_db, monkeypatch diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index f9c82a86d..089443374 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -49,6 +49,9 @@ run. - Hover a digest prefix to read the full code or configuration digest when you need to match the API payload. - Post-body versioning at the cutoff remains future work. +- Thread-group *run list* visibility now uses the same cutoff + (ADR 0018). A later public post cannot surface a previously hidden + thread-group run. ## References diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md new file mode 100644 index 000000000..17e4236a6 --- /dev/null +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -0,0 +1,67 @@ +# ADR 0018 — Related-node walks include team and organization mention edges + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0009 persists `edge_mention_team`, `edge_team_affiliation`, and +`edge_mention_organization` so a cataloged team or organization can +become a cross-post Knowledge Graph clue. The buyer-visible related-node +walk (`load_visible_subgraph` + Tong et al., 2006 random walk with +restart) still loaded only person mention, co-mention, and affiliation +edges, and returned an empty graph when a visible post had no people. +A team-only follow-up therefore never appeared as a related node, and +clicking an R&R team name had no catalog id to start a walk. + +The same temporal honesty ADR 0016 applied to run *detail* posts was +still missing from thread-group *run list* visibility: a later public +post in that thread group could surface a run the account was not +allowed to know at `knowledge_cutoff`. + +ADR 0017 already records an authorized Pending analysis-run write. +This decision is the related-node walk, not that create path. + +## Decision + +`load_visible_subgraph` loads person, team, and organization mention +channels independently. Empty person evidence is not a reason to drop +team or organization edges. `hydrate_related_nodes` labels +`cataloged_team` rows. `GET /api/teams/{team_id}/related` starts the +same RWR walk Keyman and corporate-entity related already use. +`visible_affiliation_post_ids` unions direct `post_organization_mention` +rows with person-affiliation posts so an org-only mention can start a +walk. + +The summary payload exposes `catalog_node_id` / `catalog_node_type_code` +when the R&R actor resolved to a team or organization mention on that +post. The popup turns that name into a related-node button. + +Thread-group run list visibility requires at least one ABAC-visible +`source_post` whose `created_at` is at or before `knowledge_cutoff`. + +## Consequences + +- Open a post whose R&R names 설계팀, then click the team. Sibling posts + that mention the same cataloged team appear as related nodes. +- Click a related team chip the same way you already click a person or + organization chip. +- A later public post in a thread group no longer lists a January run + that could not have known that post. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with +restart and its applications. *Proceedings of the Sixth International +Conference on Data Mining (ICDM'06)*, 613–622. +https://doi.org/10.1109/ICDM.2006.70 + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md new file mode 100644 index 000000000..4ecb8fe82 --- /dev/null +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -0,0 +1,19 @@ +# Related-node team and organization walk — doctoring + +These are the standards and papers that ground ADR 0018. Cite them in +APA 7th when you extend the walk or the catalog identity layer. + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with +restart and its applications. *Proceedings of the Sixth International +Conference on Data Mining (ICDM'06)*, 613–622. +https://doi.org/10.1109/ICDM.2006.70 + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index c8f67bc8d..dac241738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.85.0", + "version": "0.86.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dae8e1674..934f150e4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -649,6 +649,14 @@ describe("App, authenticated", () => { actor_type_code: "prov_organization", affiliated_organization_name: null, }, + { + actor_name: "설계팀", + responsibility: "도면 검토", + actor_type_code: "prov_team", + affiliated_organization_name: "Demo Corp", + catalog_node_id: "team-1", + catalog_node_type_code: "node_team", + }, ], }), ); @@ -750,6 +758,32 @@ describe("App, authenticated", () => { label: "Demo Corp", relevance: 0.2, }, + { + node_id: "team-1", + node_type_code: "node_team", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Team", + ontology_label: "Team", + label: "설계팀", + relevance: 0.15, + }, + ], + }), + ); + } + if (url.endsWith("/api/teams/team-1/related")) { + return Promise.resolve( + jsonResponse({ + team_id: "team-1", + team_name: "설계팀", + related: [ + { + node_id: "post-2", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Linked post", + relevance: 0.6, + }, ], }), ); @@ -1201,6 +1235,30 @@ describe("App, authenticated", () => { ); }); + it("opens related nodes from an R&R team", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "R&R team: 설계팀" })); + await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); + expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( + "Linked post", + ); + }); + + it("opens related nodes from a related team chip", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); + await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: "Related nodes for 설계팀" })); + await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); + expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( + "Linked post", + ); + }); + it("opens related nodes from a related corporate entity", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ddce1ac..83fb88860 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, @@ -30,6 +30,7 @@ import { fetchPosts, fetchRelatedEntity, fetchRelatedKeymen, + fetchRelatedTeam, rebuildLineage, rebuildPeriodReports, updateTicketStatus, @@ -54,6 +55,7 @@ import { type PostLineage, type PostSummary, type RelatedNode, + type RelatedNodeType, type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; @@ -470,6 +472,15 @@ function VocEvidenceSection({ } const NODE_PERSON = "node_person"; +const NODE_POST = "node_post"; +const NODE_CORPORATE_ENTITY = "node_corporate_entity"; +const NODE_TEAM = "node_team"; + +const KNOWN_RELATED_NODE_TYPES = [NODE_PERSON, NODE_POST, NODE_CORPORATE_ENTITY, NODE_TEAM] as const; + +function isKnownRelatedNodeType(code: string): code is RelatedNodeType { + return (KNOWN_RELATED_NODE_TYPES as readonly string[]).includes(code); +} function relatedNodeCaption(node: RelatedNode): string { const name = node.label ?? node.node_id; @@ -482,9 +493,6 @@ function relatedNodeCaption(node: RelatedNode): string { return `${name} (${node.ontology_label ?? node.node_type_code})`; } -const NODE_POST = "node_post"; -const NODE_CORPORATE_ENTITY = "node_corporate_entity"; - const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -539,6 +547,7 @@ function KeymanPanel({ onSelectPost, focusPerson, focusEntity, + focusTeam, }: { postId: string; accessToken: string; @@ -548,6 +557,7 @@ function KeymanPanel({ onSelectPost?: (postId: string) => void; focusPerson?: { personId: string; personName: string } | null; focusEntity?: { entityId: string; entityName: string } | null; + focusTeam?: { teamId: string; teamName: string } | null; }) { const [related, setRelated] = useState(null); const [selectedName, setSelectedName] = useState(null); @@ -585,6 +595,18 @@ function KeymanPanel({ } } + async function handleSelectTeam(teamId: string, teamName: string) { + const requestId = ++relatedRequest.current; + setSelectedName(teamName); + setRelated(null); + try { + const result = await fetchRelatedTeam(accessToken, teamId); + if (requestId === relatedRequest.current) setRelated(result.related); + } catch { + if (requestId === relatedRequest.current) setRelated([]); + } + } + useEffect(() => { if (!focusPerson) return; const requestId = ++relatedRequest.current; @@ -613,6 +635,20 @@ function KeymanPanel({ }); }, [accessToken, focusEntity]); + useEffect(() => { + if (!focusTeam) return; + const requestId = ++relatedRequest.current; + setSelectedName(focusTeam.teamName); + setRelated(null); + fetchRelatedTeam(accessToken, focusTeam.teamId) + .then((result) => { + if (requestId === relatedRequest.current) setRelated(result.related); + }) + .catch(() => { + if (requestId === relatedRequest.current) setRelated([]); + }); + }, [accessToken, focusTeam]); + async function handleExtract() { setExtracting(true); setError(null); @@ -700,48 +736,67 @@ function KeymanPanel({
            {related.map((node) => { const caption = relatedNodeCaption(node); - if (node.node_type_code === NODE_POST && onSelectPost) { - return ( -
          • - -
          • - ); + const key = `${node.node_type_code}:${node.node_id}`; + if (!isKnownRelatedNodeType(node.node_type_code)) { + return
          • {caption}
          • ; } - if (node.node_type_code === NODE_PERSON) { - return ( -
          • - -
          • - ); - } - if (node.node_type_code === NODE_CORPORATE_ENTITY) { - return ( -
          • - -
          • - ); + switch (node.node_type_code) { + case NODE_POST: + if (!onSelectPost) { + return
          • {caption}
          • ; + } + return ( +
          • + +
          • + ); + case NODE_PERSON: + return ( +
          • + +
          • + ); + case NODE_CORPORATE_ENTITY: + return ( +
          • + +
          • + ); + case NODE_TEAM: + return ( +
          • + +
          • + ); + default: { + const _exhaustive: never = node.node_type_code; + return
          • {_exhaustive}
          • ; + } } - return ( -
          • {caption}
          • - ); })}
          )} @@ -1128,6 +1183,7 @@ function PostDetailPopup({ const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); + const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); function reloadKeymen() { fetchPostKeymen(accessToken, postId).then((r) => setKeymen(r.keymen)).catch(() => setKeymen([])); @@ -1156,6 +1212,7 @@ function PostDetailPopup({ setEvaluation(null); setFocusPerson(null); setFocusEntity(null); + setFocusTeam(null); fetchPost(accessToken, postId).then(setPost).catch((err) => setError(String(err))); fetchPostEvaluation(accessToken, postId) .then((r) => setEvaluation(r.responses)) @@ -1220,28 +1277,61 @@ function PostDetailPopup({ const person = isPerson ? keymen?.find((row) => row.person_name === rr.actor_name) : undefined; + const catalogId = rr.catalog_node_id; + const catalogType = rr.catalog_node_type_code; + let actorName: ReactNode = {rr.actor_name}; + if (person) { + actorName = ( + + ); + } else if (catalogType === NODE_TEAM && catalogId) { + actorName = ( + + ); + } else if (catalogType === NODE_CORPORATE_ENTITY && catalogId) { + actorName = ( + + ); + } return (
        • {actorTypeLabel} {" "} - {person ? ( - - ) : ( - {rr.actor_name} - )} + {actorName} {rr.affiliated_organization_name && ( ({rr.affiliated_organization_name}) )} @@ -1271,6 +1361,7 @@ function PostDetailPopup({ affiliateTrees={affiliateTrees} onSelectPerson={(personId, personName) => { setFocusEntity(null); + setFocusTeam(null); setFocusPerson({ personId, personName }); }} /> @@ -1299,10 +1390,12 @@ function PostDetailPopup({ node={node} onSelectPerson={(personId, personName) => { setFocusEntity(null); + setFocusTeam(null); setFocusPerson({ personId, personName }); }} onSelectEntity={(entityId, entityName) => { setFocusPerson(null); + setFocusTeam(null); setFocusEntity({ entityId, entityName }); }} /> @@ -1320,6 +1413,7 @@ function PostDetailPopup({ onSelectPost={onSelectPost} focusPerson={focusPerson} focusEntity={focusEntity} + focusTeam={focusTeam} /> {counterparties && counterparties.length > 0 && ( @@ -1331,6 +1425,7 @@ function PostDetailPopup({ onVerified={reloadCounterparties} onSelectEntity={(entityId, entityName) => { setFocusPerson(null); + setFocusTeam(null); setFocusEntity({ entityId, entityName }); }} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e35bcf3ed..f9bd4068e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -73,9 +73,15 @@ export interface VocEvidence { counterparties: VocEvidenceCounterparty[]; } +export type RelatedNodeType = + | "node_person" + | "node_post" + | "node_corporate_entity" + | "node_team"; + export interface RelatedNode { node_id: string; - node_type_code: string; + node_type_code: RelatedNodeType | string; relevance: number; label?: string; person_side_code?: string; @@ -89,6 +95,8 @@ export interface PostRoleResponsibility { responsibility: string; actor_type_code: string; affiliated_organization_name: string | null; + catalog_node_id?: string | null; + catalog_node_type_code?: string | null; } export interface PostAiSummary { @@ -284,6 +292,13 @@ export function fetchRelatedEntity( return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken); } +export function fetchRelatedTeam( + accessToken: string, + teamId: string, +): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> { + return backendFetch(`/api/teams/${teamId}/related`, accessToken); +} + export function extractPostKeymen( accessToken: string, postId: string, diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5e05ef4ff..5f70c6064 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.85.0" +__version__ = "0.86.0" diff --git a/pyproject.toml b/pyproject.toml index 8750ae3e3..0393b774a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.85.0" +version = "0.86.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 8aad4f6a8..5353c2a91 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -21,13 +21,21 @@ from backend.app.keyman_ingestion import ingest_post_keymen from backend.app.knowledge_graph import ( + hydrate_related_nodes, load_visible_subgraph, persist_edges_for_post, + related_for_start, visible_mention_post_ids, ) from backend.app.post_summary_ingestion import persist_post_summary from lineageweave.keyman_extraction import OUR_SIDE, PersonMention -from lineageweave.knowledge_graph import EDGE_MENTION, NODE_PERSON, NODE_POST +from lineageweave.knowledge_graph import ( + EDGE_MENTION, + EDGE_MENTION_TEAM, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) from lineageweave.post_summary import PostSummary, RoleResponsibility _ADMIN_DSN = os.environ.get( @@ -365,3 +373,81 @@ def test_cross_post_identity_upgrade_keeps_keyman_mention_context( assert keyman_row is not None assert keyman_row[0] == "Keyman extracted this mention from the synthetic body" assert summary_count == 1 + + +async def _exercise_team_only_related_walk( + database_dsn: str, + first_post_id: str, +) -> None: + """A team mentioned on two posts must walk even when one post has no people.""" + + connection = await asyncpg.connect(database_dsn) + try: + author_id, corporate_entity_id = await connection.fetchrow( + "select author_account_id, corporate_entity_id from source_post where post_id = $1", + first_post_id, + ) + second_post_id = str( + await connection.fetchval( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code) + values ($1, $2, 'Team-only follow-up', '설계팀이 도면을 재검토했다.', + 'voc', 'public') + returning post_id + """, + author_id, + corporate_entity_id, + ) + ) + team_id = str( + await connection.fetchval( + """ + insert into cataloged_team (team_name, affiliated_organization_name) + values ('설계팀', 'Synthetic Corp') + returning team_id + """ + ) + ) + await connection.execute( + """ + insert into post_team_mention (post_id, team_id) + values ($1, $2), ($3, $2) + """, + first_post_id, + team_id, + second_post_id, + ) + async with connection.transaction(): + await persist_edges_for_post(connection, first_post_id) + await persist_edges_for_post(connection, second_post_id) + + team_only_edges = await load_visible_subgraph(connection, [second_post_id]) + assert any( + edge.edge_type_code == EDGE_MENTION_TEAM + and edge.source_node_id == team_id + and edge.target_node_id == second_post_id + for edge in team_only_edges + ), "a team-only post must still load its mention edge" + + related = await related_for_start( + connection, NODE_TEAM, team_id, [first_post_id, second_post_id] + ) + related_ids = {node["node_id"] for node in related} + assert first_post_id in related_ids + assert second_post_id in related_ids + hydrated = await hydrate_related_nodes( + connection, [(f"{NODE_TEAM}:{team_id}", 1.0)] + ) + assert hydrated[0]["label"] == "설계팀" + assert hydrated[0]["node_type_code"] == NODE_TEAM + finally: + await connection.close() + + +def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: + """ADR 0018: team mention edges must participate in the visible RWR walk.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index 20f9a8793..b302b1250 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.85.0" +version = "0.86.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From ca9bd82bfe7f7a0924e27a77a4f82ce16e3bf68d Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:08:19 +0000 Subject: [PATCH 112/117] fix: pin failed-run next actions to registered kinds Failed period-report rows now tell the operator to rebuild the report. Next-action tests pin reconstruction, measurement, and report copy to the row. A pending TEPP corpus must not claim a calibrated result. --- ARCHITECTURE.md | 4 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 2 + .../0.86.0-related-nodes-team-org-walk.md | 2 + CHANGELOG.md | 8 +- CLAUDE.md | 3 +- docs/adr/0014-authorized-analysis-run-read.md | 5 +- frontend/src/App.test.tsx | 191 +++++++++++++++--- frontend/src/App.tsx | 66 ++++-- frontend/src/api.ts | 20 +- 9 files changed, 245 insertions(+), 56 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa557bd7a..26c817cde 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -489,7 +489,9 @@ its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator -to retry reconstruction, not to connect TEPP. The +to retry reconstruction, not to connect TEPP. A failed period-report +row tells the operator to rebuild the report. A pending TEPP row +does not claim a calibrated measurement. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index c96531899..df127a9d4 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -4,3 +4,5 @@ Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo Corp snapshot. The home list shows Failed and a kind-specific next action; detail history keeps `tepp_not_available`. Missing transport is not a fake measurement. A failed lineage row does not mention TEPP. +A failed period-report row rebuilds the report. A pending TEPP row +does not claim a calibrated measurement. diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md index 4efa8100f..4989a054f 100644 --- a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -1,2 +1,4 @@ Related-node walks include team and organization mention edges. Click an R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. +Failed period-report rows rebuild the report; a pending TEPP corpus +does not claim a calibrated measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 434c4d63b..6bf4dc62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to this project are documented here. Format follows - Thread-group analysis-run *lists* now require an in-cutoff visible post. A later public post in that thread group no longer surfaces a January run the account was not allowed to know. +- Failed period-report rows tell the operator to rebuild the report. + Next-action copy is pinned to the registered run kinds. A pending + TEPP corpus does not claim a calibrated measurement. ## [0.85.0] - 2026-08-16 @@ -62,7 +65,10 @@ All notable changes to this project are documented here. Format follows snapshot-count inserts once counts exist so a re-run does not hit the freeze trigger. A failed lineage row tells the operator to retry reconstruction; only a failed TEPP row mentions the measurement - service. Stacked PRs now run the same GitHub Checks as PRs to main. + service. A failed period-report row tells the operator to rebuild + the report from a current snapshot. A pending TEPP row does not + claim a calibrated measurement. Stacked PRs now run the same + GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md index 2f89bada7..a11127584 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,8 @@ theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not -mention TEPP. +mention TEPP. A failed period-report row rebuilds the report. A +pending TEPP row does not claim a calibrated measurement. Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 9188187fb..29c074c8f 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -45,7 +45,10 @@ kinds without a second application. The TEPP run is Failed / keeps that machine code off the caption (this decision) and instead tells the operator to open the TEPP run, then connect the measurement service. A failed lineage row tells the operator to retry -reconstruction, not to connect TEPP. The detail now shows the legal +reconstruction, not to connect TEPP. A failed period-report row +tells the operator to rebuild the report from a current snapshot. +A pending or running TEPP row must not claim a calibrated +measurement. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 934f150e4..8618fcfbf 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -60,7 +60,9 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + failedReportRun?: boolean; succeededTeppRun?: boolean; + pendingTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -171,21 +173,19 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } - if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + if (url.endsWith("/api/analysis-runs/run-demo-report")) { return Promise.resolve( jsonResponse({ - analysis_run_id: "run-demo-tepp", - run_kind_code: "analysis_run_tepp", - run_kind_label: "TEPP measurement", + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report", + run_kind_label: "Period report", scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + status_code: "analysis_status_failed", + status_label: "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:34:00Z", + requested_at: "2026-01-12T12:38:00Z", source_counts: [ { count_type_code: "analysis_count_document", @@ -193,32 +193,90 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + visible_posts: [], status_history: [ { status_ordinal: 1, status_code: "analysis_status_pending", status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", + occurred_at: "2026-01-12T12:39:00Z", }, { status_ordinal: 2, - status_code: "analysis_status_running", - status_label: "Running", - occurred_at: "2026-01-12T12:36:00Z", + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", }, + ], + }), + ); + } + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + const teppStatus = options?.succeededTeppRun + ? "analysis_status_succeeded" + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed"; + const teppLabel = options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed"; + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: teppStatus, + status_label: teppLabel, + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ { - status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", - occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, }, ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: options?.pendingTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -331,8 +389,14 @@ describe("App, authenticated", () => { scope_entity_name: "Demo Corp", status_code: options?.succeededTeppRun ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed", + status_label: options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -343,6 +407,29 @@ describe("App, authenticated", () => { }, ], }, + ...(options?.failedReportRun + ? [ + { + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report" as const, + run_kind_label: "Period report", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed" as const, + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:38:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, + ] + : []), ], }), ); @@ -1615,18 +1702,58 @@ describe("App, authenticated", () => { stubBackend({ failedLineageRun: true }); render(); - const list = await screen.findByRole("list", { name: "Analysis runs" }); - expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); - expect(list).toHaveTextContent( + await screen.findByRole("list", { name: "Analysis runs" }); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + const teppButton = screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }); + expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", ); - expect(list).toHaveTextContent( + expect(lineageButton).not.toHaveTextContent("measurement service"); + expect(teppButton).toHaveTextContent( "Open this run to see why it failed, then connect the measurement service and re-run.", ); - const lineageButton = screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + expect(teppButton).not.toHaveTextContent("reconstruction"); + }); + + it("does not tell a failed period report to connect the measurement service", async () => { + stubBackend({ failedReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Failed · Demo Corp", }); - expect(lineageButton).not.toHaveTextContent("measurement service"); + expect(reportButton).toHaveTextContent( + "Open this run to see why it failed, then rebuild the period report from a current snapshot.", + ); + expect(reportButton).not.toHaveTextContent("measurement service"); + expect(reportButton).not.toHaveTextContent("reconstruction"); + + await userEvent.click(reportButton); + expect( + await screen.findByText( + "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", + ), + ).toBeInTheDocument(); + }); + + it("does not tell a pending TEPP run that it already measured", async () => { + stubBackend({ pendingTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83fb88860..35a080aa2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1468,8 +1468,12 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to see why it failed, then connect the measurement service and re-run."; case "analysis_run_lineage": return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; - default: - return "Open this run to see why it failed, then retry after the blocking service is connected."; + case "analysis_run_report": + return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } } } @@ -1477,32 +1481,60 @@ function analysisRunNextAction(run: AnalysisRun): string | null { * Empty-corpus copy that tells the operator what to do next. */ function analysisRunEmptyPostsHint(run: AnalysisRun): string { - if (run.run_kind_code === "analysis_run_tepp") { - return ( - "No posts were available at this cutoff for TEPP to measure. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); + switch (run.run_kind_code) { + case "analysis_run_tepp": + return ( + "No posts were available at this cutoff for TEPP to measure. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + case "analysis_run_lineage": + return ( + "No posts were available at this cutoff for reconstruction. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + case "analysis_run_report": + return ( + "No posts were available at this cutoff for the period report. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } } - return ( - "No posts were available at this cutoff. Open a later run, or ask an " + - "administrator to capture a newer snapshot." - ); } /** * Corpus copy for a TEPP run that already has cutoff posts. * * Those titles are the measurement bag, not a reconstruction result. + * Pending or running must not claim a calibrated measurement. */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - if (run.status_code === "analysis_status_failed") { - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + switch (run.status_code) { + case "analysis_status_failed": + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + case "analysis_status_succeeded": + return "These posts are the cutoff corpus this TEPP run measured."; + case "analysis_status_pending": + case "analysis_status_running": + return "These posts are the cutoff corpus TEPP will measure once this run finishes."; + case "analysis_status_cancelled": + return ( + "These posts are the cutoff corpus this TEPP run would have measured. " + + "The run was cancelled before a calibrated result." + ); + case null: + return "These posts are the cutoff corpus attached to this TEPP run."; + default: { + const unexpected: never = run.status_code; + return unexpected; + } } - return "These posts are the cutoff corpus this TEPP run measured."; } /** Git-style prefix. The full digest stays on `title` for verification. */ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9bd4068e..3385d5179 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -514,9 +514,23 @@ export interface AnalysisRunCount { count_value: number; } +/** Registry kinds from `analysis_run.run_kind_code` (migration 0018). */ +export type AnalysisRunKindCode = + | "analysis_run_lineage" + | "analysis_run_report" + | "analysis_run_tepp"; + +/** Registry statuses from `analysis_run_status_event.status_code`. */ +export type AnalysisRunStatusCode = + | "analysis_status_pending" + | "analysis_status_running" + | "analysis_status_succeeded" + | "analysis_status_failed" + | "analysis_status_cancelled"; + export interface AnalysisRunStatusEvent { status_ordinal: number; - status_code: string; + status_code: AnalysisRunStatusCode; status_label: string; occurred_at: string; failure_code?: string; @@ -524,12 +538,12 @@ export interface AnalysisRunStatusEvent { export interface AnalysisRun { analysis_run_id: string; - run_kind_code: string; + run_kind_code: AnalysisRunKindCode; run_kind_label: string; scope_kind_code: string; scope_kind_label: string; scope_entity_name?: string; - status_code: string | null; + status_code: AnalysisRunStatusCode | null; status_label: string | null; knowledge_cutoff: string; requested_at: string; From 19a613125e2bb20bfe8f5d8139c20b06d1f40ce6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:11:43 +0000 Subject: [PATCH 113/117] feat(ui): show embedded post images instead of raw base64 Open a post or evidence panel and see each data-URI picture in document order. The popup no longer dumps the base64 wall. Remote http(s) image URLs stay unloaded. Extract Keyman or Ask still runs OCR on those images. Rebased onto live #74 head ca9bd82 after #128 squash-merged. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 11 +++++ docs/image-content-schema.md | 26 ++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 27 ++++++++++ frontend/src/App.test.tsx | 20 +++++++- frontend/src/App.tsx | 5 +- frontend/src/PostBody.tsx | 33 +++++++++++++ frontend/src/index.css | 5 ++ frontend/src/postBodyDisplay.test.ts | 74 ++++++++++++++++++++++++++++ frontend/src/postBodyDisplay.ts | 72 +++++++++++++++++++++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 14 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 frontend/src/PostBody.tsx create mode 100644 frontend/src/postBodyDisplay.test.ts create mode 100644 frontend/src/postBodyDisplay.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26c817cde..a8d082f0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf4dc62a..fd8717513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.1] - 2026-08-16 + +### Changed + +- Opening a post or its evidence panel now shows each embedded + `data:image` picture in document order, with the surrounding sentences + as text. The raw base64 string is no longer dumped into the popup. + Remote `http(s)` image URLs stay unloaded. After `make seed`, a post + whose body includes a data-URI image shows the picture; Extract Keyman + or Ask still runs OCR on that image for search. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md index f023d56a0..4dae03dcf 100644 --- a/docs/image-content-schema.md +++ b/docs/image-content-schema.md @@ -82,6 +82,18 @@ picture sat relative to the surrounding paragraphs." | `chunk_position` | `integer not null` | 0-based index among ALL of this document's chunks (text and image together) -- matches `Chunk.index` from `chunk_by_dom` | | primary key | `(source_document_id, chunk_position)` | one image slot per position per document | +## Viewer contract (before persistence exists) + +The demo popup does not yet read these tables. It splits the live +`post_body` the same way `extract_base64_images` does: each +`data:image/...;base64,...` payload becomes an `` at its original +character offset, and the surrounding HTML is shown as text. A buyer who +opens the post sees the picture that sat between the paragraphs, not the +base64 wall. Remote `src="https://..."` tags are stripped, never fetched. +OCR, caption, and tag search still require the vision client on extract / +Ask (Li et al., 2023; Radford et al., 2021) and, in a real deployment, +the tables below. + ## Query shapes this supports - **"Find images whose extracted text or tags match a search query, then @@ -105,3 +117,17 @@ picture sat relative to the surrounding paragraphs." ON CONFLICT DO NOTHING` before the provider call, or a short-lived lease row) to close that race; this schema documents the storage guarantee, not that concurrency control. + +## References + +Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z., +& Wei, F. (2023). TrOCR: Transformer-based optical character recognition +with pre-trained models. *Proceedings of the AAAI Conference on Artificial +Intelligence, 37*(11), 13094–13102. https://doi.org/10.1609/aaai.v37i11.26538 + +Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., +Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, I. +(2021). Learning transferable visual models from natural language +supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th +International Conference on Machine Learning* (pp. 8748–8763). PMLR. +https://proceedings.mlr.press/v139/radford21a.html diff --git a/frontend/package.json b/frontend/package.json index dac241738..803acd91b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.0", + "version": "0.86.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 8f38b4dd0..b3fab25d1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -108,9 +108,36 @@ } .post-body { + display: flex; + flex-direction: column; + gap: var(--post-body-gap); +} + +.post-body-text { + margin: 0; white-space: pre-wrap; } +.post-embedded-image { + margin: 0; + padding: var(--post-image-padding); + border: 1px solid var(--post-image-border); + border-radius: var(--post-image-radius); + background: var(--post-image-bg); +} + +.post-embedded-image img { + display: block; + max-width: 100%; + height: auto; +} + +.post-embedded-image figcaption { + margin-top: 0.4rem; + font-size: 0.85rem; + color: var(--text); +} + .popup-placeholder { margin-top: 1.5rem; padding: 1rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8618fcfbf..65d25259b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -63,6 +63,7 @@ describe("App, authenticated", () => { failedReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + postBody?: string; }) { const statusLabel: Record = { open: "Open", @@ -668,7 +669,7 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", post_title: "Public post", - post_body: "The full body text.", + post_body: options?.postBody ?? "The full body text.", voc_type_code: "voc", voc_type_label: "Voice of Customer", visibility_code: "public", @@ -1088,6 +1089,23 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("shows an embedded invoice image instead of the raw base64 string", async () => { + const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + stubBackend({ + postBody: `

          Quote attached.

          Please confirm.

          `, + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); + expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); + expect(screen.getByText("Quote attached.")).toBeInTheDocument(); + expect(screen.getByText("Please confirm.")).toBeInTheDocument(); + expect(screen.getByText(/Extract Keyman or ask a question/)).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument(); + }); + it("fetches and renders the post list, then opens a detail popup on click", async () => { const fetchMock = stubBackend(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35a080aa2..a511f713d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,7 @@ import { type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; +import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -119,7 +120,7 @@ function EvidencePanel({ {post && ( <>

          {post.post_title}

          -

          {post.post_body}

          + )} @@ -1245,7 +1246,7 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

          -

          {post.post_body}

          +

          요약 (Summary)

          diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx new file mode 100644 index 000000000..3ff77b537 --- /dev/null +++ b/frontend/src/PostBody.tsx @@ -0,0 +1,33 @@ +import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; + +function renderSegment(segment: PostBodySegment, index: number) { + switch (segment.kind) { + case "text": + return ( +

          + {segment.text} +

          + ); + case "image": + return ( +
          + {`Embedded +
          + Image from this post. Extract Keyman or ask a question to read text + inside it. +
          +
          + ); + default: { + const _exhaustive: never = segment; + throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function PostBody({ body }: { body: string }) { + return
          {splitPostBody(body).map(renderSegment)}
          ; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb331302..53f4db2ac 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,6 +8,11 @@ --accent-bg: rgba(170, 59, 255, 0.1); --accent-border: rgba(170, 59, 255, 0.5); --social-bg: rgba(244, 243, 236, 0.5); + --post-body-gap: 0.75rem; + --post-image-padding: 0.75rem; + --post-image-radius: 8px; + --post-image-border: var(--border); + --post-image-bg: var(--code-bg); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts new file mode 100644 index 000000000..f3092cea6 --- /dev/null +++ b/frontend/src/postBodyDisplay.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { splitPostBody } from "./postBodyDisplay"; + +/** 1x1 transparent PNG — the same synthetic fixture the Python vision tests use. */ +const TINY_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +describe("splitPostBody", () => { + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { + expect(splitPostBody("The full body text.")).toEqual([ + { kind: "text", text: "The full body text." }, + ]); + }); + + it("keeps comparison operators that look like broken HTML", () => { + expect(splitPostBody("qty < 50 and price > 10")).toEqual([ + { kind: "text", text: "qty < 50 and price > 10" }, + ]); + }); + + it("renders a data-URI image as its own segment and never leaks the raw base64 into text", () => { + const html = + `

          Quote attached.

          Please confirm.

          `; + const segments = splitPostBody(html); + + expect(segments).toEqual([ + { kind: "text", text: "Quote attached." }, + { + kind: "image", + src: `data:image/png;base64,${TINY_PNG_B64}`, + mimeType: "image/png", + position: html.indexOf(" { + const html = + `

          between

          ` + + ``; + const segments = splitPostBody(html); + expect(segments.map((segment) => segment.kind)).toEqual(["image", "text", "image"]); + expect(segments[1]).toEqual({ kind: "text", text: "between" }); + expect(segments[0]?.kind === "image" && segments[0].position).toBe(0); + expect(segments[2]?.kind === "image" && segments[2].position).toBeGreaterThan(0); + }); + + it("tells the operator to re-export when the base64 payload is not decodable", () => { + const html = ''; + expect(splitPostBody(html)).toEqual([ + { + kind: "text", + text: "Embedded image could not be decoded. Re-export the source post and open it again.", + }, + ]); + }); + + it("does not turn a remote http img into a loaded image", () => { + const html = '

          See

          end

          '; + const segments = splitPostBody(html); + expect(segments.every((segment) => segment.kind === "text")).toBe(true); + expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain( + "See", + ); + expect(JSON.stringify(segments)).not.toContain("https://example.test"); + }); +}); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts new file mode 100644 index 000000000..c6ea29fdd --- /dev/null +++ b/frontend/src/postBodyDisplay.ts @@ -0,0 +1,72 @@ +/** + * Split a raw `post_body` into text and in-place data-URI images. + * + * The popup used to dump the source string, so a buyer who opened a post + * with an embedded invoice saw a base64 wall instead of the picture. + * Only `data:image/...;base64,...` payloads are turned into images — + * remote `http(s)` img tags are stripped, never fetched. + */ + +export type PostBodySegment = + | { kind: "text"; text: string } + | { kind: "image"; src: string; mimeType: string; position: number }; + +const DATA_URI_IMG = + /]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi; + +const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; + +const UNDECODEABLE_IMAGE = + "Embedded image could not be decoded. Re-export the source post and open it again."; + +function stripHtmlTags(text: string): string { + return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim(); +} + +function isDecodableBase64(raw: string): boolean { + if (raw.length === 0) { + return false; + } + try { + atob(raw); + return true; + } catch { + return false; + } +} + +function pushText(segments: PostBodySegment[], raw: string): void { + const text = stripHtmlTags(raw); + if (text) { + segments.push({ kind: "text", text }); + } +} + +export function splitPostBody(body: string): PostBodySegment[] { + const segments: PostBodySegment[] = []; + const pattern = new RegExp(DATA_URI_IMG.source, "gi"); + let lastIndex = 0; + let match = pattern.exec(body); + while (match !== null) { + pushText(segments, body.slice(lastIndex, match.index)); + const mimeType = match[1]; + const rawB64 = match[2].replace(/\s+/g, ""); + if (isDecodableBase64(rawB64)) { + segments.push({ + kind: "image", + src: `data:${mimeType};base64,${rawB64}`, + mimeType, + position: match.index, + }); + } else { + segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + } + lastIndex = match.index + match[0].length; + match = pattern.exec(body); + } + pushText(segments, body.slice(lastIndex)); + if (segments.length === 0) { + return [{ kind: "text", text: body }]; + } + return segments; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5f70c6064..48bf6e481 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.0" +__version__ = "0.86.1" diff --git a/pyproject.toml b/pyproject.toml index 0393b774a..eb2e26318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index b302b1250..c759df267 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 7f2d4bc011202ec8f2433c18ae10b1ae62aff2f6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:13:33 +0900 Subject: [PATCH 114/117] feat(ui): show embedded post images instead of raw base64 (v0.86.1) Open a post or evidence panel and see each data-URI picture in document order. The popup no longer dumps the base64 wall. Remote http(s) image URLs stay unloaded. Extract Keyman or Ask still runs OCR on those images. Rebased onto live #74 head ca9bd82 after #128 squash-merged. --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 11 +++++ docs/image-content-schema.md | 26 ++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 27 ++++++++++ frontend/src/App.test.tsx | 20 +++++++- frontend/src/App.tsx | 5 +- frontend/src/PostBody.tsx | 33 +++++++++++++ frontend/src/index.css | 5 ++ frontend/src/postBodyDisplay.test.ts | 74 ++++++++++++++++++++++++++++ frontend/src/postBodyDisplay.ts | 72 +++++++++++++++++++++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 14 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 frontend/src/PostBody.tsx create mode 100644 frontend/src/postBodyDisplay.test.ts create mode 100644 frontend/src/postBodyDisplay.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26c817cde..a8d082f0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf4dc62a..fd8717513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.1] - 2026-08-16 + +### Changed + +- Opening a post or its evidence panel now shows each embedded + `data:image` picture in document order, with the surrounding sentences + as text. The raw base64 string is no longer dumped into the popup. + Remote `http(s)` image URLs stay unloaded. After `make seed`, a post + whose body includes a data-URI image shows the picture; Extract Keyman + or Ask still runs OCR on that image for search. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md index f023d56a0..4dae03dcf 100644 --- a/docs/image-content-schema.md +++ b/docs/image-content-schema.md @@ -82,6 +82,18 @@ picture sat relative to the surrounding paragraphs." | `chunk_position` | `integer not null` | 0-based index among ALL of this document's chunks (text and image together) -- matches `Chunk.index` from `chunk_by_dom` | | primary key | `(source_document_id, chunk_position)` | one image slot per position per document | +## Viewer contract (before persistence exists) + +The demo popup does not yet read these tables. It splits the live +`post_body` the same way `extract_base64_images` does: each +`data:image/...;base64,...` payload becomes an `` at its original +character offset, and the surrounding HTML is shown as text. A buyer who +opens the post sees the picture that sat between the paragraphs, not the +base64 wall. Remote `src="https://..."` tags are stripped, never fetched. +OCR, caption, and tag search still require the vision client on extract / +Ask (Li et al., 2023; Radford et al., 2021) and, in a real deployment, +the tables below. + ## Query shapes this supports - **"Find images whose extracted text or tags match a search query, then @@ -105,3 +117,17 @@ picture sat relative to the surrounding paragraphs." ON CONFLICT DO NOTHING` before the provider call, or a short-lived lease row) to close that race; this schema documents the storage guarantee, not that concurrency control. + +## References + +Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z., +& Wei, F. (2023). TrOCR: Transformer-based optical character recognition +with pre-trained models. *Proceedings of the AAAI Conference on Artificial +Intelligence, 37*(11), 13094–13102. https://doi.org/10.1609/aaai.v37i11.26538 + +Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., +Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, I. +(2021). Learning transferable visual models from natural language +supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th +International Conference on Machine Learning* (pp. 8748–8763). PMLR. +https://proceedings.mlr.press/v139/radford21a.html diff --git a/frontend/package.json b/frontend/package.json index dac241738..803acd91b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.0", + "version": "0.86.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 8f38b4dd0..b3fab25d1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -108,9 +108,36 @@ } .post-body { + display: flex; + flex-direction: column; + gap: var(--post-body-gap); +} + +.post-body-text { + margin: 0; white-space: pre-wrap; } +.post-embedded-image { + margin: 0; + padding: var(--post-image-padding); + border: 1px solid var(--post-image-border); + border-radius: var(--post-image-radius); + background: var(--post-image-bg); +} + +.post-embedded-image img { + display: block; + max-width: 100%; + height: auto; +} + +.post-embedded-image figcaption { + margin-top: 0.4rem; + font-size: 0.85rem; + color: var(--text); +} + .popup-placeholder { margin-top: 1.5rem; padding: 1rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8618fcfbf..65d25259b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -63,6 +63,7 @@ describe("App, authenticated", () => { failedReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + postBody?: string; }) { const statusLabel: Record = { open: "Open", @@ -668,7 +669,7 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", post_title: "Public post", - post_body: "The full body text.", + post_body: options?.postBody ?? "The full body text.", voc_type_code: "voc", voc_type_label: "Voice of Customer", visibility_code: "public", @@ -1088,6 +1089,23 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("shows an embedded invoice image instead of the raw base64 string", async () => { + const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + stubBackend({ + postBody: `

          Quote attached.

          Please confirm.

          `, + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); + expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); + expect(screen.getByText("Quote attached.")).toBeInTheDocument(); + expect(screen.getByText("Please confirm.")).toBeInTheDocument(); + expect(screen.getByText(/Extract Keyman or ask a question/)).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument(); + }); + it("fetches and renders the post list, then opens a detail popup on click", async () => { const fetchMock = stubBackend(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35a080aa2..a511f713d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,7 @@ import { type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; +import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -119,7 +120,7 @@ function EvidencePanel({ {post && ( <>

          {post.post_title}

          -

          {post.post_body}

          + )} @@ -1245,7 +1246,7 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

          -

          {post.post_body}

          +

          요약 (Summary)

          diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx new file mode 100644 index 000000000..3ff77b537 --- /dev/null +++ b/frontend/src/PostBody.tsx @@ -0,0 +1,33 @@ +import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; + +function renderSegment(segment: PostBodySegment, index: number) { + switch (segment.kind) { + case "text": + return ( +

          + {segment.text} +

          + ); + case "image": + return ( +
          + {`Embedded +
          + Image from this post. Extract Keyman or ask a question to read text + inside it. +
          +
          + ); + default: { + const _exhaustive: never = segment; + throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function PostBody({ body }: { body: string }) { + return
          {splitPostBody(body).map(renderSegment)}
          ; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb331302..53f4db2ac 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,6 +8,11 @@ --accent-bg: rgba(170, 59, 255, 0.1); --accent-border: rgba(170, 59, 255, 0.5); --social-bg: rgba(244, 243, 236, 0.5); + --post-body-gap: 0.75rem; + --post-image-padding: 0.75rem; + --post-image-radius: 8px; + --post-image-border: var(--border); + --post-image-bg: var(--code-bg); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts new file mode 100644 index 000000000..f3092cea6 --- /dev/null +++ b/frontend/src/postBodyDisplay.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { splitPostBody } from "./postBodyDisplay"; + +/** 1x1 transparent PNG — the same synthetic fixture the Python vision tests use. */ +const TINY_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +describe("splitPostBody", () => { + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { + expect(splitPostBody("The full body text.")).toEqual([ + { kind: "text", text: "The full body text." }, + ]); + }); + + it("keeps comparison operators that look like broken HTML", () => { + expect(splitPostBody("qty < 50 and price > 10")).toEqual([ + { kind: "text", text: "qty < 50 and price > 10" }, + ]); + }); + + it("renders a data-URI image as its own segment and never leaks the raw base64 into text", () => { + const html = + `

          Quote attached.

          Please confirm.

          `; + const segments = splitPostBody(html); + + expect(segments).toEqual([ + { kind: "text", text: "Quote attached." }, + { + kind: "image", + src: `data:image/png;base64,${TINY_PNG_B64}`, + mimeType: "image/png", + position: html.indexOf(" { + const html = + `

          between

          ` + + ``; + const segments = splitPostBody(html); + expect(segments.map((segment) => segment.kind)).toEqual(["image", "text", "image"]); + expect(segments[1]).toEqual({ kind: "text", text: "between" }); + expect(segments[0]?.kind === "image" && segments[0].position).toBe(0); + expect(segments[2]?.kind === "image" && segments[2].position).toBeGreaterThan(0); + }); + + it("tells the operator to re-export when the base64 payload is not decodable", () => { + const html = ''; + expect(splitPostBody(html)).toEqual([ + { + kind: "text", + text: "Embedded image could not be decoded. Re-export the source post and open it again.", + }, + ]); + }); + + it("does not turn a remote http img into a loaded image", () => { + const html = '

          See

          end

          '; + const segments = splitPostBody(html); + expect(segments.every((segment) => segment.kind === "text")).toBe(true); + expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain( + "See", + ); + expect(JSON.stringify(segments)).not.toContain("https://example.test"); + }); +}); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts new file mode 100644 index 000000000..c6ea29fdd --- /dev/null +++ b/frontend/src/postBodyDisplay.ts @@ -0,0 +1,72 @@ +/** + * Split a raw `post_body` into text and in-place data-URI images. + * + * The popup used to dump the source string, so a buyer who opened a post + * with an embedded invoice saw a base64 wall instead of the picture. + * Only `data:image/...;base64,...` payloads are turned into images — + * remote `http(s)` img tags are stripped, never fetched. + */ + +export type PostBodySegment = + | { kind: "text"; text: string } + | { kind: "image"; src: string; mimeType: string; position: number }; + +const DATA_URI_IMG = + /]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi; + +const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; + +const UNDECODEABLE_IMAGE = + "Embedded image could not be decoded. Re-export the source post and open it again."; + +function stripHtmlTags(text: string): string { + return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim(); +} + +function isDecodableBase64(raw: string): boolean { + if (raw.length === 0) { + return false; + } + try { + atob(raw); + return true; + } catch { + return false; + } +} + +function pushText(segments: PostBodySegment[], raw: string): void { + const text = stripHtmlTags(raw); + if (text) { + segments.push({ kind: "text", text }); + } +} + +export function splitPostBody(body: string): PostBodySegment[] { + const segments: PostBodySegment[] = []; + const pattern = new RegExp(DATA_URI_IMG.source, "gi"); + let lastIndex = 0; + let match = pattern.exec(body); + while (match !== null) { + pushText(segments, body.slice(lastIndex, match.index)); + const mimeType = match[1]; + const rawB64 = match[2].replace(/\s+/g, ""); + if (isDecodableBase64(rawB64)) { + segments.push({ + kind: "image", + src: `data:${mimeType};base64,${rawB64}`, + mimeType, + position: match.index, + }); + } else { + segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + } + lastIndex = match.index + match[0].length; + match = pattern.exec(body); + } + pushText(segments, body.slice(lastIndex)); + if (segments.length === 0) { + return [{ kind: "text", text: body }]; + } + return segments; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5f70c6064..48bf6e481 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.0" +__version__ = "0.86.1" diff --git a/pyproject.toml b/pyproject.toml index 0393b774a..eb2e26318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index b302b1250..c759df267 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 6f79ecb6090ef9b73d64b1ea63c5207d8bfa07ed Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:18:46 +0900 Subject: [PATCH 115/117] fix: bind R&R catalog ids without homonym joins (v0.86.2) (#141) Persist cataloged_team_id and cataloged_corporate_entity_id on post_summary_role (ADR 0019). fetch_persisted_summary reads those columns and does not join corporate_entity by entity_name. Team related matches person/entity 403/404. Rebased onto live #74 head 7f2d4bc after #140 took v0.86.1. --- ARCHITECTURE.md | 5 +- CHANGELOG.d/0.86.2-role-catalog-identity.md | 3 + CHANGELOG.md | 10 ++ backend/app/post_summary_ingestion.py | 99 +++++++++---------- backend/tests/test_api.py | 97 ++++++++++++++++++ docker/postgres-init/Dockerfile | 1 + docs/adr/0018-related-nodes-team-org-walk.md | 5 +- docs/adr/0019-role-catalog-identity.md | 65 ++++++++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 12 +++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- migrations/0001_initial_schema.sql | 9 ++ migrations/0019_role_catalog_identity.sql | 46 +++++++++ .../rollback/0019_role_catalog_identity.sql | 8 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_ingestion_transaction_contracts.py | 38 +++++++ tests/test_person_mention_projection.py | 95 +++++++++++++++++- uv.lock | 2 +- 19 files changed, 442 insertions(+), 60 deletions(-) create mode 100644 CHANGELOG.d/0.86.2-role-catalog-identity.md create mode 100644 docs/adr/0019-role-catalog-identity.md create mode 100644 migrations/0019_role_catalog_identity.sql create mode 100644 migrations/rollback/0019_role_catalog_identity.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a8d082f0b..b66c7cde7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -849,8 +849,9 @@ new table needed. `lineageweave/knowledge_graph.py`'s `knowledge_graph_edges_for_post` extended with three new edge kinds (`edge_mention_team`, `edge_team_affiliation`, `edge_mention_organization`); `backend/app/post_summary_ingestion.py`'s `persist_post_summary` now -resolves each R&R actor's identity and calls the same -`persist_edges_for_post` Keyman ingestion already uses. A person R&R +resolves each R&R actor's identity, stores that id on +`post_summary_role` (ADR 0019 — `entity_name` is not unique), and calls +the same `persist_edges_for_post` Keyman ingestion already uses. A person R&R actor is opportunistically joined to an existing `cataloged_person` row by name (never originated by R&R itself -- documented gap in the ADR: `cataloged_person` needs `person_side_code`, which R&R's prompt does diff --git a/CHANGELOG.d/0.86.2-role-catalog-identity.md b/CHANGELOG.d/0.86.2-role-catalog-identity.md new file mode 100644 index 000000000..a56b3d47e --- /dev/null +++ b/CHANGELOG.d/0.86.2-role-catalog-identity.md @@ -0,0 +1,3 @@ +R&R organization buttons walk the catalog id stored on the role row. A +shared display name no longer attaches a homonym. Team related matches +person/entity 403/404. diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8717513..13aeb02f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.2] - 2026-08-16 + +### Fixed + +- An R&R organization button now walks the catalog id stored on that + role row (ADR 0019). Two catalog orgs can share a display name; open + the post, click the name, and you stay on the resolved org — not a + homonym. `GET /api/teams/{id}/related` matches person/entity authz: + another corp's private-only team is 403; an unknown UUID is 404. + ## [0.86.1] - 2026-08-16 ### Changed diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 36d4fadae..3febf9b21 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -1,10 +1,11 @@ """Persist and load the popup's Korean summary / key events / R&R. -ADR 0009: an R&R actor is not just per-post free text -- when it is a -team or organization, it is resolved to a shared catalog identity -(``cataloged_team`` / ``corporate_entity``) and a Knowledge Graph -mention edge is written, so the same "설계팀" or organization named -across two posts becomes one linkable node, not two unrelated strings. +ADR 0009 / 0019: an R&R actor is not just per-post free text -- when it +is a team or organization, it is resolved to a shared catalog identity +(``cataloged_team`` / ``corporate_entity``) stored on the role row and +a Knowledge Graph mention edge is written, so the same "설계팀" or +organization named across two posts becomes one linkable node. Fetch +never reconstructs that id by ``entity_name``; that column is not unique. A person actor is opportunistically joined to an *existing* ``cataloged_person`` row by name when Keyman extraction has already cataloged that name. The R&R evidence is written to @@ -57,7 +58,12 @@ async def fetch_persisted_summary( conn: asyncpg.Connection, post_id: str ) -> dict[str, Any] | None: - """Return the stored summary payload, or None when none has been written.""" + """Return the stored summary payload, or None when none has been written. + + ``catalog_node_id`` comes from the role row's catalog foreign keys + (ADR 0019). This function does not join ``corporate_entity`` by + ``entity_name``. + """ header = await conn.fetchrow( "select korean_summary from post_summary_result where post_id = $1", post_id, @@ -72,23 +78,9 @@ async def fetch_persisted_summary( """ select role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, - team_mention.team_id, - org_mention.corporate_entity_id + role.cataloged_team_id, + role.cataloged_corporate_entity_id from post_summary_role role - left join cataloged_team team - on role.actor_type_code = 'prov_team' - and team.team_name = role.actor_name - and team.affiliated_organization_name - is not distinct from role.affiliated_organization_name - left join post_team_mention team_mention - on team_mention.post_id = role.post_id - and team_mention.team_id = team.team_id - left join corporate_entity org - on role.actor_type_code = 'prov_organization' - and org.entity_name = role.actor_name - left join post_organization_mention org_mention - on org_mention.post_id = role.post_id - and org_mention.corporate_entity_id = org.corporate_entity_id where role.post_id = $1 order by role.actor_name """, @@ -98,11 +90,11 @@ async def fetch_persisted_summary( for row in roles: catalog_node_id = None catalog_node_type_code = None - if row["team_id"] is not None: - catalog_node_id = str(row["team_id"]) + if row["cataloged_team_id"] is not None: + catalog_node_id = str(row["cataloged_team_id"]) catalog_node_type_code = NODE_TEAM - elif row["corporate_entity_id"] is not None: - catalog_node_id = str(row["corporate_entity_id"]) + elif row["cataloged_corporate_entity_id"] is not None: + catalog_node_id = str(row["cataloged_corporate_entity_id"]) catalog_node_type_code = NODE_CORPORATE_ENTITY payload_roles.append( { @@ -218,44 +210,51 @@ async def _replace_summary_projection( ordinal, event_text, ) - for role in summary.roles_and_responsibilities: + # ADR 0009 / 0019: resolve catalog identity before writing the role + # row so fetch never reconstructs it by a non-unique name. + for role_index, role in enumerate(summary.roles_and_responsibilities): + cataloged_team_id = None + cataloged_corporate_entity_id = None + if role.actor_type_code == ACTOR_TYPE_TEAM: + cataloged_team_id = await upsert_team( + conn, + role.actor_name, + role.affiliated_organization_name, + candidates, + ) + elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: + cataloged_corporate_entity_id = resolved_organization_ids.get( + role_index + ) await conn.execute( "insert into post_summary_role " "(post_id, actor_name, responsibility, actor_type_code, " - "affiliated_organization_name) values ($1, $2, $3, $4, $5)", + "affiliated_organization_name, cataloged_team_id, " + "cataloged_corporate_entity_id) values " + "($1, $2, $3, $4, $5, $6, $7)", post_id, role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, + cataloged_team_id, + cataloged_corporate_entity_id, ) - - # ADR 0009: cross-post identity resolution for team/organization/person - # actors -- see module docstring. - for role_index, role in enumerate(summary.roles_and_responsibilities): - if role.actor_type_code == ACTOR_TYPE_TEAM: - team_id = await upsert_team( - conn, - role.actor_name, - role.affiliated_organization_name, - candidates, - ) + if cataloged_team_id is not None: await conn.execute( "insert into post_team_mention (post_id, team_id) values ($1, $2) " "on conflict do nothing", post_id, - team_id, + cataloged_team_id, + ) + elif cataloged_corporate_entity_id is not None: + await conn.execute( + "insert into post_organization_mention " + "(post_id, corporate_entity_id) values ($1, $2) " + "on conflict do nothing", + post_id, + cataloged_corporate_entity_id, ) - elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: - corporate_entity_id = resolved_organization_ids.get(role_index) - if corporate_entity_id is not None: - await conn.execute( - "insert into post_organization_mention " - "(post_id, corporate_entity_id) values ($1, $2) " - "on conflict do nothing", - post_id, - corporate_entity_id, - ) elif role.actor_type_code == ACTOR_TYPE_PERSON: person_row = await conn.fetchrow( "select person_id from cataloged_person where person_name = $1 limit 1", diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bff7f7c64..21c71bc9a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1201,6 +1201,44 @@ def test_unknown_corporate_entity_related_is_not_found( assert response.status_code == 404 +def test_team_only_on_other_corp_private_post_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + """A team mentioned only on another corp's private post must 403.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into cataloged_team (team_name, affiliated_organization_name) " + "values ('비공개 설계팀', 'Other Corp') returning team_id" + ) + team_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_team_mention (post_id, team_id) values (%s, %s)", + (seeded_db["other_private_post_id"], team_id), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/teams/{team_id}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_unknown_team_related_is_not_found(client, demo_analyst_token) -> None: + """An unknown team UUID must 404, matching person and entity related.""" + + response = client.get( + f"/api/teams/{uuid.uuid4()}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 404 + + def test_keyman_only_on_other_corp_private_post_is_forbidden(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/keymen/{seeded_db['hidden_person_id']}/related", @@ -1528,6 +1566,65 @@ def test_organization_mention_only_posts_appear_in_entity_related( assert org_only_post_id in related_ids +def test_private_other_corp_organization_mention_does_not_leak( + client, demo_analyst_token, seeded_db +) -> None: + """The org-mention UNION must still apply ABAC per post.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s) on conflict do nothing", + (seeded_db["other_private_post_id"], seeded_db["own_corp_id"]), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s) on conflict do nothing", + (seeded_db["other_private_post_id"], seeded_db["other_corp_id"]), + ) + for entity_id in (seeded_db["own_corp_id"], seeded_db["other_corp_id"]): + for edge in knowledge_graph_edges_for_post( + seeded_db["other_private_post_id"], + [], + organization_corporate_entity_ids=[entity_id], + ): + cur.execute( + "insert into knowledge_graph_edge (" + "source_node_type_code, source_node_id, target_node_type_code, " + "target_node_id, edge_type_code, edge_weight" + ") values (%s, %s, %s, %s, %s, %s) " + "on conflict do nothing", + ( + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.edge_weight, + ), + ) + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + own = client.get( + f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", + headers=headers, + ) + assert own.status_code == 200, own.text + own_ids = {node["node_id"] for node in own.json()["related"]} + assert seeded_db["other_private_post_id"] not in own_ids + + hidden = client.get( + f"/api/corporate-entities/{seeded_db['other_corp_id']}/related", + headers=headers, + ) + assert hidden.status_code == 403 + + def test_thread_group_run_list_honors_knowledge_cutoff( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index d95e2c917..e394d376e 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -24,6 +24,7 @@ COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb. COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql +COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index 17e4236a6..ae0a1c331 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -34,8 +34,9 @@ rows with person-affiliation posts so an org-only mention can start a walk. The summary payload exposes `catalog_node_id` / `catalog_node_type_code` -when the R&R actor resolved to a team or organization mention on that -post. The popup turns that name into a related-node button. +from the catalog foreign keys stored on `post_summary_role` (ADR 0019). +The popup turns that name into a related-node button. Do not reconstruct +the id by `corporate_entity.entity_name`. Thread-group run list visibility requires at least one ABAC-visible `source_post` whose `created_at` is at or before `knowledge_cutoff`. diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md new file mode 100644 index 000000000..32b5d0a09 --- /dev/null +++ b/docs/adr/0019-role-catalog-identity.md @@ -0,0 +1,65 @@ +# ADR 0019 — R&R catalog identity lives on the role row + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0009 writes `post_team_mention` and `post_organization_mention` so a +cataloged team or organization can start a related-node walk. ADR 0018 +exposes `catalog_node_id` on the summary payload by joining those +mentions back to `post_summary_role` through `actor_name`. + +`corporate_entity.entity_name` is not unique. Two catalog rows can share +a display name (different `corporate_entity_code`, different parents). +A fetch join on name therefore: + +- attaches a homonym that this post never resolved, or +- duplicates the role when more than one same-named row exists. + +Mention tables are post-scoped, not role-scoped. They cannot reconstruct +which catalog id was chosen for a specific R&R row. That reconstruction +is a transitive dependency on a non-key attribute, so it is not third +normal form (Codd, 1970; Date, 2019). + +Team identity is already unique on +`(team_name, affiliated_organization_name)`. Organization identity is +not. + +## Decision + +`post_summary_role` stores the resolved catalog foreign keys +(`cataloged_team_id`, `cataloged_corporate_entity_id`) written during +`persist_post_summary`. `fetch_persisted_summary` reads those columns. +It does not join `corporate_entity` by `entity_name`. + +Migration `0019_role_catalog_identity.sql` backfills existing rows from +a post-scoped mention only when the name match is unique on that post. +Two same-named mentions stay unbound rather than guessing. + +## Consequences + +- Open a post whose R&R names an organization that shares a display + name with another catalog row. The button walks the resolved id, not + the homonym. +- Clicking that name still uses `GET /api/corporate-entities/{id}/related` + or `GET /api/teams/{id}/related`. Authz stays person/entity-parity: + a team mentioned only on another corp's private post is 403; an + unknown UUID is 404. + +## References + +Codd, E. F. (1970). A relational model of data for large shared data +banks. *Communications of the ACM, 13*(6), 377–387. +https://doi.org/10.1145/362384.362685 + +Date, C. J. (2019). *Database design and relational theory: Normal forms +and all that jazz* (2nd ed.). Apress. +https://doi.org/10.1007/978-1-4842-5540-7 + +International Organization for Standardization. (2023). *ISO/IEC +11179-1:2023: Information technology—Metadata registries (MDR)—Part 1: +Framework*. + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md index 4ecb8fe82..fc7ded98f 100644 --- a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -17,3 +17,15 @@ rules* (confirmed 2024; Amendment 1:2022). World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ + +Codd, E. F. (1970). A relational model of data for large shared data +banks. *Communications of the ACM, 13*(6), 377–387. +https://doi.org/10.1145/362384.362685 + +Date, C. J. (2019). *Database design and relational theory: Normal forms +and all that jazz* (2nd ed.). Apress. +https://doi.org/10.1007/978-1-4842-5540-7 + +International Organization for Standardization. (2023). *ISO/IEC +11179-1:2023: Information technology—Metadata registries (MDR)—Part 1: +Framework*. diff --git a/frontend/package.json b/frontend/package.json index 803acd91b..fb52f7948 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.1", + "version": "0.86.2", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 48bf6e481..efe84890b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.1" +__version__ = "0.86.2" diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index e2877f447..a94b05a0c 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -411,6 +411,15 @@ create table post_organization_mention ( primary key (post_id, corporate_entity_id) ); +-- ADR 0019: store the resolved catalog id on the role row itself. +-- corporate_entity.entity_name is not unique, and mention tables are +-- post-scoped, so reconstructing identity by name is not 3NF. +alter table post_summary_role + add column cataloged_team_id uuid references cataloged_team (team_id); +alter table post_summary_role + add column cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + -- --------------------------------------------------------------------- -- Knowledge graph: person/company/post nodes, typed edges. The type -- codes (which kind of node, which kind of edge) are real enums and DO diff --git a/migrations/0019_role_catalog_identity.sql b/migrations/0019_role_catalog_identity.sql new file mode 100644 index 000000000..2881be9b3 --- /dev/null +++ b/migrations/0019_role_catalog_identity.sql @@ -0,0 +1,46 @@ +-- ADR 0019: bind each R&R role to the catalog row resolved for that +-- role. corporate_entity.entity_name is not unique, so a fetch join on +-- name can attach a homonym or duplicate the role. Mention tables are +-- post-scoped, not role-scoped, and cannot reconstruct that binding. + +alter table post_summary_role + add column if not exists cataloged_team_id uuid + references cataloged_team (team_id); + +alter table post_summary_role + add column if not exists cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + +-- Teams already have a unique (team_name, affiliated_organization_name) +-- key. Backfill only when that pair was mentioned on the same post. +update post_summary_role as role + set cataloged_team_id = team.team_id + from cataloged_team as team + join post_team_mention as mention + on mention.team_id = team.team_id + where role.actor_type_code = 'prov_team' + and role.cataloged_team_id is null + and mention.post_id = role.post_id + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name; + +-- Organizations: copy a mention only when exactly one mentioned org on +-- that post has this role's actor_name. Two same-named mentions stay +-- unbound rather than guessing. +update post_summary_role role + set cataloged_corporate_entity_id = matched.corporate_entity_id + from ( + select mention.post_id, + org.entity_name, + min(org.corporate_entity_id) as corporate_entity_id + from post_organization_mention mention + join corporate_entity org + on org.corporate_entity_id = mention.corporate_entity_id + group by mention.post_id, org.entity_name + having count(*) = 1 + ) matched + where role.actor_type_code = 'prov_organization' + and role.cataloged_corporate_entity_id is null + and role.post_id = matched.post_id + and role.actor_name = matched.entity_name; diff --git a/migrations/rollback/0019_role_catalog_identity.sql b/migrations/rollback/0019_role_catalog_identity.sql new file mode 100644 index 000000000..5efafed8b --- /dev/null +++ b/migrations/rollback/0019_role_catalog_identity.sql @@ -0,0 +1,8 @@ +-- Drop role-scoped catalog identity columns added by 0019. +-- Mention tables remain; only the role-row binding is removed. + +alter table post_summary_role + drop column if exists cataloged_team_id; + +alter table post_summary_role + drop column if exists cataloged_corporate_entity_id; diff --git a/pyproject.toml b/pyproject.toml index eb2e26318..6e41c7ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f6f575ccc..9d246445d 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -120,6 +120,7 @@ def seed( cur.execute((migrations / "0015_organization_name_resolution.sql").read_text()) cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) + cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 641a43cc6..d2994e2c4 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -209,12 +209,16 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: if "from post_summary_event" in compact: return [{"event_text": "검토 완료"}] if "from post_summary_role" in compact: + assert "entity_name" not in compact + assert "cataloged_corporate_entity_id" in compact return [ { "actor_name": "Synthetic Design Team", "responsibility": "도면 검토", "actor_type_code": ACTOR_TYPE_TEAM, "affiliated_organization_name": "Synthetic Energy", + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, } ] raise AssertionError(f"unexpected fetch query: {compact}") @@ -354,6 +358,14 @@ async def persist_edges(conn, post_id) -> list[Any]: and event[0] == "execute" and "insert into post_organization_mention" in event[1] ) + role_insert = next( + event[1] + for event in events + if isinstance(event, tuple) + and event[0] == "execute" + and "insert into post_summary_role" in event[1] + ) + assert "cataloged_corporate_entity_id" in role_insert assert resolve_index < enter_index < mention_index < exit_index @@ -469,3 +481,29 @@ def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None: assert "strips balanced outer Markdown emphasis from field values" in content assert "while still accepting emphasized field labels" in content assert "preserves Markdown emphasis in field values" not in content + + +def test_role_catalog_identity_is_stored_on_the_role_row() -> None: + """ADR 0019: fetch must not reconstruct organization identity by name.""" + root = Path(__file__).resolve().parents[1] + fetch_source = ( + root / "backend" / "app" / "post_summary_ingestion.py" + ).read_text(encoding="utf-8") + initial = (root / "migrations" / "0001_initial_schema.sql").read_text( + encoding="utf-8" + ) + upgrade = (root / "migrations" / "0019_role_catalog_identity.sql").read_text( + encoding="utf-8" + ) + dockerfile = ( + root / "docker" / "postgres-init" / "Dockerfile" + ).read_text(encoding="utf-8") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + fetch_sql = fetch_source.split("async def fetch_persisted_summary", 1)[1] + fetch_sql = fetch_sql.split("async def persist_post_summary", 1)[0] + assert "org.entity_name = role.actor_name" not in fetch_sql + assert "cataloged_corporate_entity_id" in fetch_sql + assert "cataloged_team_id" in initial + assert "cataloged_corporate_entity_id" in upgrade + assert "0019_role_catalog_identity.sql" in dockerfile + assert "ADR 0019" in changelog diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 5353c2a91..81e63a75f 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -27,16 +27,25 @@ related_for_start, visible_mention_post_ids, ) -from backend.app.post_summary_ingestion import persist_post_summary +from backend.app import post_summary_ingestion as summary_ingestion +from backend.app.post_summary_ingestion import ( + fetch_persisted_summary, + persist_post_summary, +) from lineageweave.keyman_extraction import OUR_SIDE, PersonMention from lineageweave.knowledge_graph import ( EDGE_MENTION, EDGE_MENTION_TEAM, + NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, NODE_TEAM, ) -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + PostSummary, + RoleResponsibility, +) _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -451,3 +460,85 @@ def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: database_dsn, post_id, _summary_person_id = projection_database.split("|") asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) + + +async def _exercise_homonym_organization_role_binding( + database_dsn: str, + post_id: str, +) -> None: + """A same-named catalog org that this post did not resolve must stay off the role.""" + + connection = await asyncpg.connect(database_dsn) + try: + mentioned_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-MENTIONED', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + other_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-OTHER', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + + async def resolve_mentioned_organization(*_args, **_kwargs) -> str: + return mentioned_id + + original = summary_ingestion.get_or_create_corporate_entity + summary_ingestion.get_or_create_corporate_entity = resolve_mentioned_organization + try: + payload = await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="동명이인 조직이 일정만 확정했다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Homonym Energy", + responsibility="납품 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + ), + ), + ) + finally: + summary_ingestion.get_or_create_corporate_entity = original + + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + assert roles[0]["catalog_node_id"] == mentioned_id + assert roles[0]["catalog_node_type_code"] == NODE_CORPORATE_ENTITY + fetched = await fetch_persisted_summary(connection, post_id) + assert fetched is not None + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] == mentioned_id + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] != other_id + mention_ids = [ + str(row["corporate_entity_id"]) + for row in await connection.fetch( + "select corporate_entity_id from post_organization_mention " + "where post_id = $1", + post_id, + ) + ] + assert mention_ids == [mentioned_id] + finally: + await connection.close() + + +def test_homonym_organization_role_binds_the_resolved_catalog_id( + projection_database: str, +) -> None: + """ADR 0019: two catalog orgs can share a display name; the role keeps one id.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_homonym_organization_role_binding(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index c759df267..e1d2860cf 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 39ed6eb460bb6cbb8d4374ac173d21b9938515e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:25:24 +0900 Subject: [PATCH 116/117] fix(ui): pin pending next-action copy to registered kinds (#148) Pending lineage detail now repeats that reconstruction has not started. Pending TEPP rows no longer reuse the reconstruction sentence. Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 3 +- .../0.86.0-related-nodes-team-org-walk.md | 3 +- CHANGELOG.md | 6 ++ CLAUDE.md | 3 +- docs/adr/0014-authorized-analysis-run-read.md | 3 +- frontend/src/App.test.tsx | 7 ++- frontend/src/App.tsx | 56 +++++++++++++------ 7 files changed, 59 insertions(+), 22 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b66c7cde7..3a4d0ac4a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,7 +491,8 @@ measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report. A pending TEPP row -does not claim a calibrated measurement. The +does not claim a calibrated measurement. A pending lineage row +says reconstruction has not started yet. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md index 4989a054f..6e4605d5a 100644 --- a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -1,4 +1,5 @@ Related-node walks include team and organization mention edges. Click an R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. Failed period-report rows rebuild the report; a pending TEPP corpus -does not claim a calibrated measurement. +does not claim a calibrated measurement. A pending lineage row says +reconstruction has not started yet. diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aeb02f9..d30ae14aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ All notable changes to this project are documented here. Format follows whose body includes a data-URI image shows the picture; Extract Keyman or Ask still runs OCR on that image for search. +### Fixed + +- Opening a Pending lineage run repeats that reconstruction has not + started. Pending next-action copy is pinned to the registered run + kinds, so a Pending TEPP row does not say reconstruction. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a11127584..c5a1828f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,8 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. +pending TEPP row does not claim a calibrated measurement. A pending +lineage row says reconstruction has not started yet. Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 29c074c8f..500c2bc2a 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -48,7 +48,8 @@ service. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report from a current snapshot. A pending or running TEPP row must not claim a calibrated -measurement. The detail now shows the legal +measurement. A pending lineage row says reconstruction has not +started yet. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65d25259b..60d06c8de 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1772,6 +1772,7 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -1799,7 +1800,11 @@ describe("App, authenticated", () => { expect( await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); - expect(screen.getByText(/has not started yet/)).toBeInTheDocument(); + expect( + screen.getByText( + "Open this run to confirm which posts it will use. Reconstruction has not started yet.", + ), + ).toBeInTheDocument(); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a511f713d..d589a3644 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1451,28 +1451,48 @@ function analysisRunCaption(run: AnalysisRun): string { } /** - * Next action for a failed run on the home list. + * Next action for a pending or failed run on the home list and detail. * * The machine `failure_code` stays on detail history (ADR 0014). Copy - * is kind-specific so a failed lineage reconstruction is not mistaken - * for a missing TEPP transport. + * is pinned to registered kinds so a pending TEPP row is not mistaken + * for reconstruction, and a failed lineage row is not mistaken for a + * missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_pending") { - return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; - } - if (run.status_code !== "analysis_status_failed") { - return null; - } - switch (run.run_kind_code) { - case "analysis_run_tepp": - return "Open this run to see why it failed, then connect the measurement service and re-run."; - case "analysis_run_lineage": - return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; - case "analysis_run_report": - return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + switch (run.status_code) { + case "analysis_status_pending": + switch (run.run_kind_code) { + case "analysis_run_lineage": + return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; + case "analysis_run_tepp": + return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; + case "analysis_run_report": + return "Open this run to confirm which posts the period report will use. The report has not been built yet."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } + } + case "analysis_status_failed": + switch (run.run_kind_code) { + case "analysis_run_tepp": + return "Open this run to see why it failed, then connect the measurement service and re-run."; + case "analysis_run_lineage": + return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + case "analysis_run_report": + return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } + } + case "analysis_status_running": + case "analysis_status_succeeded": + case "analysis_status_cancelled": + case null: + return null; default: { - const unexpected: never = run.run_kind_code; + const unexpected: never = run.status_code; return unexpected; } } @@ -1648,6 +1668,7 @@ function AnalysisRunsPanel({ if (runs === null) return

          Loading analysis runs...

          ; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + const selectedNextAction = selected ? analysisRunNextAction(selected) : null; return (
          @@ -1699,6 +1720,7 @@ function AnalysisRunsPanel({ {selected && (

          {analysisRunCaption(selected)}

          + {selectedNextAction &&

          {selectedNextAction}

          }

          Cutoff {selected.knowledge_cutoff.slice(0, 10)} {" · "} From d32c396274b22079761dedeb530941ffb3564046 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:26:19 +0000 Subject: [PATCH 117/117] fix(ui): never dump raw post bodies for unmatched images Tag-only remote, charset, unquoted, or undecodable data-URIs no longer reintroduce the base64 wall. This screen does not claim Extract/Ask OCR. Re-export the source with the picture embedded, then open the post again. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 2 +- CHANGELOG.d/0.86.2-embedded-image-fallback.md | 2 + CHANGELOG.md | 15 +- docs/doctoring/IMAGE_CONTENT_REFERENCES.md | 30 ++++ docs/image-content-schema.md | 15 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 30 +++- frontend/src/PostBody.test.tsx | 18 +++ frontend/src/PostBody.tsx | 40 +++-- frontend/src/postBodyDisplay.test.ts | 44 +++++- frontend/src/postBodyDisplay.ts | 137 ++++++++++++++---- lineageweave/__init__.py | 2 +- lineageweave/image_content.py | 32 +++- pyproject.toml | 2 +- tests/test_image_content.py | 12 ++ uv.lock | 2 +- 16 files changed, 324 insertions(+), 61 deletions(-) create mode 100644 CHANGELOG.d/0.86.2-embedded-image-fallback.md create mode 100644 docs/doctoring/IMAGE_CONTENT_REFERENCES.md create mode 100644 frontend/src/PostBody.test.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a8d082f0b..84c1e8a42 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string. Tag-only, charset, unquoted, or undecodable bodies never fall back to the raw source. GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | diff --git a/CHANGELOG.d/0.86.2-embedded-image-fallback.md b/CHANGELOG.d/0.86.2-embedded-image-fallback.md new file mode 100644 index 000000000..93a9b2224 --- /dev/null +++ b/CHANGELOG.d/0.86.2-embedded-image-fallback.md @@ -0,0 +1,2 @@ +Tag-only or charset/unquoted data-URI bodies no longer dump raw source. +Re-export with the picture embedded. This screen does not OCR the picture. diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8717513..d6815e580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.2] - 2026-08-16 + +### Fixed + +- Opening a post whose body is only a remote `http(s)` image, a + `charset=` data-URI, an unquoted `src`, or undecodable base64 no + longer dumps the raw source string. Re-export the source with the + picture embedded, then open the post again. A valid 1x1 PNG still + renders as a picture. This screen does not read text inside the + picture; Extract Keyman and Ask stay on their own channels and stay + silent when the vision client is Null. + ## [0.86.1] - 2026-08-16 ### Changed @@ -12,8 +24,7 @@ All notable changes to this project are documented here. Format follows `data:image` picture in document order, with the surrounding sentences as text. The raw base64 string is no longer dumped into the popup. Remote `http(s)` image URLs stay unloaded. After `make seed`, a post - whose body includes a data-URI image shows the picture; Extract Keyman - or Ask still runs OCR on that image for search. + whose body includes a data-URI image shows the picture. ## [0.86.0] - 2026-08-16 diff --git a/docs/doctoring/IMAGE_CONTENT_REFERENCES.md b/docs/doctoring/IMAGE_CONTENT_REFERENCES.md new file mode 100644 index 000000000..3aff83a9a --- /dev/null +++ b/docs/doctoring/IMAGE_CONTENT_REFERENCES.md @@ -0,0 +1,30 @@ +# Embedded image content — doctoring + +These are the standards and papers that ground +`docs/image-content-schema.md`, `lineageweave/image_content.py`, and the +product popup in `frontend/src/PostBody.tsx`. Cite them in APA 7th when +you extend OCR, captioning, tagging, or position-preserving storage. + +Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, +Z., & Wei, F. (2023). TrOCR: Transformer-based optical character +recognition with pre-trained models. *Proceedings of the AAAI Conference +on Artificial Intelligence, 37*(11), 13094–13102. +https://doi.org/10.1609/aaai.v37i11.26538 + +Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., +Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, +I. (2021). Learning transferable visual models from natural language +supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th +International Conference on Machine Learning* (pp. 8748–8763). PMLR. +https://proceedings.mlr.press/v139/radford21a.html + +Masinter, L. (1998). *The "data" URL scheme* (RFC 2397). Internet +Engineering Task Force. https://doi.org/10.17487/RFC2397 + +Crockford, D. (2008). *The application/json media type for JavaScript +Object Notation (JSON)* (RFC 4627; see also RFC 8259). Internet +Engineering Task Force. https://doi.org/10.17487/RFC8259 + +World Wide Web Consortium. (2014). *HTML5: A vocabulary and associated +APIs for HTML and XHTML* (W3C Recommendation). +https://www.w3.org/TR/html5/ diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md index 4dae03dcf..57ac3e5a1 100644 --- a/docs/image-content-schema.md +++ b/docs/image-content-schema.md @@ -87,12 +87,17 @@ picture sat relative to the surrounding paragraphs." The demo popup does not yet read these tables. It splits the live `post_body` the same way `extract_base64_images` does: each `data:image/...;base64,...` payload becomes an `` at its original -character offset, and the surrounding HTML is shown as text. A buyer who +character offset, and the surrounding HTML is shown as text. Quoted or +unquoted `src` and optional data-URI parameters such as `charset=utf-8` +are accepted so a real export still shows the picture. A buyer who opens the post sees the picture that sat between the paragraphs, not the -base64 wall. Remote `src="https://..."` tags are stripped, never fetched. -OCR, caption, and tag search still require the vision client on extract / -Ask (Li et al., 2023; Radford et al., 2021) and, in a real deployment, -the tables below. +base64 wall. Remote `src="https://..."` tags are stripped, never fetched; +a remote-only body tells the operator to re-export with the picture +embedded. Undecodable payloads say the same. This screen does not read +text inside the picture. OCR, caption, and tag search still require the +vision client on extract / Ask (Li et al., 2023; Radford et al., 2021) +and, in a real deployment, the tables below. See +`docs/doctoring/IMAGE_CONTENT_REFERENCES.md`. ## Query shapes this supports diff --git a/frontend/package.json b/frontend/package.json index 803acd91b..fb52f7948 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.1", + "version": "0.86.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65d25259b..ec6a68f00 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -64,6 +64,7 @@ describe("App, authenticated", () => { succeededTeppRun?: boolean; pendingTeppRun?: boolean; postBody?: string; + evidencePostBody?: string; }) { const statusLabel: Record = { open: "Open", @@ -683,7 +684,7 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-2", post_title: "Linked post", - post_body: "The evidence panel should show exactly this text.", + post_body: options?.evidencePostBody ?? "The evidence panel should show exactly this text.", voc_type_code: "voc", visibility_code: "public", created_at: "2026-01-02T00:00:00Z", @@ -1102,7 +1103,32 @@ describe("App, authenticated", () => { expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); expect(screen.getByText("Quote attached.")).toBeInTheDocument(); expect(screen.getByText("Please confirm.")).toBeInTheDocument(); - expect(screen.getByText(/Extract Keyman or ask a question/)).toBeInTheDocument(); + expect(screen.getByText(/Text inside the picture is not read on this screen/)).toBeInTheDocument(); + expect(screen.queryByText(/Extract Keyman or ask a question/)).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument(); + }); + + it("shows an embedded image in the evidence panel without dumping base64", async () => { + const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + stubBackend({ + evidencePostBody: `

          Source quote.

          `, + }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByPlaceholderText(/what happened/i)).toBeInTheDocument()); + await userEvent.type(screen.getByPlaceholderText(/what happened/i), "What happened?"); + await userEvent.click(screen.getByRole("button", { name: /^ask$/i })); + await waitFor(() => + expect(screen.getByText("Here is what happened, drawing on the linked post.")).toBeInTheDocument(), + ); + const evidenceChips = screen.getAllByRole("button", { name: "Open evidence: Linked post" }); + await userEvent.click(evidenceChips[evidenceChips.length - 1]); + + const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); + expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); + expect(screen.getByText("Source quote.")).toBeInTheDocument(); expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument(); }); diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx new file mode 100644 index 000000000..3c7781670 --- /dev/null +++ b/frontend/src/PostBody.test.tsx @@ -0,0 +1,18 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PostBody } from "./PostBody"; +import { IMAGE_NOT_READ_HERE, UNDECODEABLE_IMAGE } from "./postBodyDisplay"; + +const TINY_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +describe("PostBody", () => { + it("replaces a picture the browser cannot paint with the re-export next action", () => { + render(`} />); + const image = screen.getByRole("img", { name: /embedded image at character offset/i }); + expect(screen.getByText(IMAGE_NOT_READ_HERE)).toBeInTheDocument(); + fireEvent.error(image); + expect(screen.getByText(UNDECODEABLE_IMAGE)).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index 3ff77b537..9b87512c3 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -1,4 +1,27 @@ -import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; +import { useState } from "react"; +import { + IMAGE_NOT_READ_HERE, + UNDECODEABLE_IMAGE, + splitPostBody, + type PostBodySegment, +} from "./postBodyDisplay"; + +function EmbeddedPostImage({ src, position }: { src: string; position: number }) { + const [failed, setFailed] = useState(false); + if (failed) { + return

          {UNDECODEABLE_IMAGE}

          ; + } + return ( +
          + {`Embedded setFailed(true)} + /> +
          {IMAGE_NOT_READ_HERE}
          +
          + ); +} function renderSegment(segment: PostBodySegment, index: number) { switch (segment.kind) { @@ -10,16 +33,11 @@ function renderSegment(segment: PostBodySegment, index: number) { ); case "image": return ( -
          - {`Embedded -
          - Image from this post. Extract Keyman or ask a question to read text - inside it. -
          -
          + ); default: { const _exhaustive: never = segment; diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index f3092cea6..c3cd32730 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { splitPostBody } from "./postBodyDisplay"; +import { + REMOTE_IMAGE_SKIPPED, + UNDECODEABLE_IMAGE, + splitPostBody, +} from "./postBodyDisplay"; /** 1x1 transparent PNG — the same synthetic fixture the Python vision tests use. */ const TINY_PNG_B64 = @@ -52,16 +56,46 @@ describe("splitPostBody", () => { expect(segments[2]?.kind === "image" && segments[2].position).toBeGreaterThan(0); }); + it("accepts charset parameters and unquoted or single-quoted src", () => { + const charset = ``; + const unquoted = ``; + const single = ``; + + for (const html of [charset, unquoted, single]) { + const segments = splitPostBody(html); + expect(segments).toEqual([ + { + kind: "image", + src: `data:image/png;base64,${TINY_PNG_B64}`, + mimeType: "image/png", + position: 0, + }, + ]); + } + }); + it("tells the operator to re-export when the base64 payload is not decodable", () => { const html = ''; expect(splitPostBody(html)).toEqual([ { kind: "text", - text: "Embedded image could not be decoded. Re-export the source post and open it again.", + text: UNDECODEABLE_IMAGE, }, ]); }); + it("does not treat valid-base64 non-image bytes as a picture", () => { + expect(splitPostBody('')).toEqual([ + { kind: "text", text: UNDECODEABLE_IMAGE }, + ]); + }); + + it("does not re-dump an invalid alphabet payload", () => { + const html = ''; + expect(splitPostBody(html)).toEqual([{ kind: "text", text: UNDECODEABLE_IMAGE }]); + expect(JSON.stringify(splitPostBody(html))).not.toContain("not-valid-base64"); + }); + it("does not turn a remote http img into a loaded image", () => { const html = '

          See

          end

          '; const segments = splitPostBody(html); @@ -71,4 +105,10 @@ describe("splitPostBody", () => { ); expect(JSON.stringify(segments)).not.toContain("https://example.test"); }); + + it("tells the operator to re-export a remote-only image instead of leaking the URL", () => { + const html = ''; + expect(splitPostBody(html)).toEqual([{ kind: "text", text: REMOTE_IMAGE_SKIPPED }]); + expect(JSON.stringify(splitPostBody(html))).not.toContain("https://example.test"); + }); }); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index c6ea29fdd..d91674965 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -4,35 +4,89 @@ * The popup used to dump the source string, so a buyer who opened a post * with an embedded invoice saw a base64 wall instead of the picture. * Only `data:image/...;base64,...` payloads are turned into images — - * remote `http(s)` img tags are stripped, never fetched. + * remote `http(s)` img tags are stripped, never fetched. A tag-only body + * never falls back to the raw source string. */ export type PostBodySegment = | { kind: "text"; text: string } | { kind: "image"; src: string; mimeType: string; position: number }; -const DATA_URI_IMG = - /]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi; +export const UNDECODEABLE_IMAGE = + "Embedded image could not be decoded. Re-export the source post and open it again."; + +export const REMOTE_IMAGE_SKIPPED = + "This post linked a remote image that was not loaded. Re-export the source with the picture embedded and open it again."; + +export const IMAGE_NOT_READ_HERE = + "Image from this post. Text inside the picture is not read on this screen."; + +const IMG_TAG = /]*>/gi; + +const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i; + +const DATA_URI = + /^data:(image\/[a-zA-Z0-9.+-]+)(?:;[\w.+-]+=[^;,]*)*;base64,([A-Za-z0-9+/=\s]+)$/i; const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; -const UNDECODEABLE_IMAGE = - "Embedded image could not be decoded. Re-export the source post and open it again."; +const REMOTE_SRC = /^https?:\/\//i; function stripHtmlTags(text: string): string { return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim(); } -function isDecodableBase64(raw: string): boolean { - if (raw.length === 0) { - return false; +function decodeBase64(raw: string): Uint8Array | null { + if (raw.length === 0 || raw.length % 4 !== 0 || !/^[A-Za-z0-9+/]+=*$/.test(raw)) { + return null; } try { - atob(raw); - return true; + const binary = atob(raw); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; } catch { - return false; + return null; + } +} + +function looksLikeImage(mimeType: string, bytes: Uint8Array): boolean { + const mime = mimeType.toLowerCase(); + if (mime === "image/png") { + return ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ); } + if (mime === "image/jpeg" || mime === "image/jpg") { + return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + } + if (mime === "image/gif") { + return bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46; + } + if (mime === "image/webp") { + return ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ); + } + if (mime === "image/svg+xml") { + const text = new TextDecoder().decode(bytes).trimStart().toLowerCase(); + return text.startsWith(" 0; } function pushText(segments: PostBodySegment[], raw: string): void { @@ -42,31 +96,62 @@ function pushText(segments: PostBodySegment[], raw: string): void { } } +function srcFromImgTag(tag: string): string | null { + const match = SRC_ATTR.exec(tag); + if (!match) { + return null; + } + return match[1] ?? match[2] ?? match[3] ?? null; +} + export function splitPostBody(body: string): PostBodySegment[] { const segments: PostBodySegment[] = []; - const pattern = new RegExp(DATA_URI_IMG.source, "gi"); + const pattern = new RegExp(IMG_TAG.source, "gi"); let lastIndex = 0; let match = pattern.exec(body); + let sawRemoteImage = false; + let sawUndecodableImage = false; + while (match !== null) { pushText(segments, body.slice(lastIndex, match.index)); - const mimeType = match[1]; - const rawB64 = match[2].replace(/\s+/g, ""); - if (isDecodableBase64(rawB64)) { - segments.push({ - kind: "image", - src: `data:${mimeType};base64,${rawB64}`, - mimeType, - position: match.index, - }); - } else { - segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + const src = srcFromImgTag(match[0]); + if (src && REMOTE_SRC.test(src)) { + sawRemoteImage = true; + } else if (src) { + const data = DATA_URI.exec(src.trim()); + if (data) { + const mimeType = data[1]; + const rawB64 = data[2].replace(/\s+/g, ""); + const bytes = decodeBase64(rawB64); + if (bytes && looksLikeImage(mimeType, bytes)) { + segments.push({ + kind: "image", + src: `data:${mimeType};base64,${rawB64}`, + mimeType, + position: match.index, + }); + } else { + sawUndecodableImage = true; + segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + } + } else if (/^data:image\//i.test(src)) { + sawUndecodableImage = true; + segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + } } lastIndex = match.index + match[0].length; match = pattern.exec(body); } pushText(segments, body.slice(lastIndex)); - if (segments.length === 0) { - return [{ kind: "text", text: body }]; + + if (segments.length > 0) { + return segments; + } + if (sawRemoteImage) { + return [{ kind: "text", text: REMOTE_IMAGE_SKIPPED }]; + } + if (sawUndecodableImage) { + return [{ kind: "text", text: UNDECODEABLE_IMAGE }]; } - return segments; + return [{ kind: "text", text: body }]; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 48bf6e481..efe84890b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.1" +__version__ = "0.86.2" diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index 3bbcbbe15..e1e0808cd 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -33,8 +33,13 @@ from .http_client import post_json -_DATA_URI_IMG = re.compile( - r']*\bsrc\s*=\s*["\']data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["\']', +_IMG_TAG = re.compile(r"]*>", re.IGNORECASE) +_SRC_ATTR = re.compile( + r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", + re.IGNORECASE, +) +_DATA_URI = re.compile( + r"^data:(image/[a-zA-Z0-9.+-]+)(?:;[\w.+-]+=[^;,]*)*;base64,([A-Za-z0-9+/=\s]+)$", re.IGNORECASE, ) @@ -63,14 +68,25 @@ class EmbeddedImage: def extract_base64_images(html: str) -> list[EmbeddedImage]: """Find every ```` in document order. - Malformed base64 in a matched tag is skipped rather than raising -- - one corrupt embedded image must not fail extraction of the rest of the - document. + Accepts quoted or unquoted ``src`` and optional data-URI parameters + such as ``charset=utf-8`` so the same picture the popup renders is + also available to the vision channel. Malformed base64 in a matched + tag is skipped rather than raising -- one corrupt embedded image must + not fail extraction of the rest of the document. """ images: list[EmbeddedImage] = [] - for match in _DATA_URI_IMG.finditer(html): - mime_type = match.group(1) - raw_b64 = re.sub(r"\s+", "", match.group(2)) + for match in _IMG_TAG.finditer(html): + src_match = _SRC_ATTR.search(match.group(0)) + if src_match is None: + continue + src = next((group for group in src_match.groups() if group), None) + if src is None: + continue + data_match = _DATA_URI.match(src.strip()) + if data_match is None: + continue + mime_type = data_match.group(1) + raw_b64 = re.sub(r"\s+", "", data_match.group(2)) try: data = base64.b64decode(raw_b64, validate=True) except (binascii.Error, ValueError): diff --git a/pyproject.toml b/pyproject.toml index eb2e26318..6e41c7ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_image_content.py b/tests/test_image_content.py index 033202be6..333b3fb0b 100644 --- a/tests/test_image_content.py +++ b/tests/test_image_content.py @@ -46,6 +46,18 @@ def test_extract_base64_images_skips_malformed_base64() -> None: assert extract_base64_images(html) == [] +def test_extract_base64_images_accepts_charset_and_unquoted_src() -> None: + quoted_charset = f'' + unquoted = f"" + expected = base64.b64decode(_TINY_PNG_B64) + + for html in (quoted_charset, unquoted): + images = extract_base64_images(html) + assert len(images) == 1 + assert images[0].mime_type == "image/png" + assert images[0].data == expected + + def test_extract_base64_images_ignores_non_data_uri_images() -> None: html = '' assert extract_base64_images(html) == [] diff --git a/uv.lock b/uv.lock index c759df267..e1d2860cf 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" source = { virtual = "." } dependencies = [ { name = "certifi" },