From bdae4476cf41050047f010d849ff135461726537 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:36:51 +0900 Subject: [PATCH 1/5] feat(ui): name next action on plural affiliation chips (v0.77.0) Related-node chips now distinguish a unique org, a known-plural set, and a missing affiliation. After make seed, walking from Ada West shows Priya Nair, multiple organizations (Counterparty) and tells the operator to read the Keyman list before continuing the walk. The chip never invents a Northridge Grid primary. Identity-rule tests live under tests/ so backend/tests stays live-stack only. No AGENTS.md rewrite. No new ADR-0015 number. --- ARCHITECTURE.md | 16 +- CHANGELOG.md | 36 ++++ backend/app/knowledge_graph.py | 126 +++++++++++- backend/tests/test_api.py | 24 +++ .../0014-related-node-business-captions.md | 64 ++++++ .../RELATED_NODE_AFFILIATION_REFERENCES.md | 46 +++++ docs/lineage-bi-research-notes.md | 24 ++- frontend/package.json | 2 +- frontend/src/App.css | 2 + frontend/src/App.test.tsx | 63 +++++- frontend/src/App.tsx | 52 ++--- frontend/src/RelatedNodeChip.stories.tsx | 90 ++++++++ frontend/src/RelatedNodeChip.test.tsx | 56 +++++ frontend/src/RelatedNodeChip.tsx | 32 +++ frontend/src/api.ts | 5 + frontend/src/relatedNodeCaption.test.ts | 120 +++++++++++ frontend/src/relatedNodeCaption.ts | 107 ++++++++++ frontend/src/relatedNodeTokens.css | 32 +++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- ...test_related_node_affiliation_ambiguity.py | 194 ++++++++++++++++++ uv.lock | 2 +- 22 files changed, 1053 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0014-related-node-business-captions.md create mode 100644 docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md create mode 100644 frontend/src/RelatedNodeChip.stories.tsx create mode 100644 frontend/src/RelatedNodeChip.test.tsx create mode 100644 frontend/src/RelatedNodeChip.tsx create mode 100644 frontend/src/relatedNodeCaption.test.ts create mode 100644 frontend/src/relatedNodeCaption.ts create mode 100644 frontend/src/relatedNodeTokens.css create mode 100644 tests/test_related_node_affiliation_ambiguity.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95d..b9816c321 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -320,7 +320,21 @@ is the same never-guess-a-parent rule `corporate_hierarchy_resolution` already applies. Entity levels and Keyman sides are labeled from `common_lookup_value` (`Our side`, `Plant`, `Company`) so the popup never shows raw `our_side` / `plant` -codes when a label exists. +codes when a label exists. Related-node person chips use the same +side label plus compact affiliation context when exactly one +distinct organization identity is known +(`Ada West, Demo Corp (Our side)`), not the ontology class +(`Ada West (Person)`). Multiple distinct affiliations set +`affiliation_ambiguous` and the caption +`Priya Nair, multiple organizations (Counterparty)` after +`make seed` -- never a guessed primary, and never a side-only chip +that looks like a missing affiliation. The related panel then says +to read the Keyman list above (or extract Keymen) before clicking +the chip to continue the walk. A resolved catalog org supplies `entity_name`; +unresolved aliases of that same org collapse into it. Related-node +organization chips use the entity-level label +(`Demo Corp (Company)`), not `Organization`. Related-node post chips +show the post title only, not `(Post)`. `GET /api/posts` and `GET /api/posts/{post_id}` include `voc_type_label` / `visibility_label` from `common_lookup_value` so diff --git a/CHANGELOG.md b/CHANGELOG.md index 0096828a2..9566a8c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ 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.77.0] - 2026-08-16 + +### Changed + +- When a related-node chip says `multiple organizations`, the related + panel now names the next action: read every organization in the + Keyman list above (or extract Keymen if that list is empty), then + click the chip to continue the walk. A stale payload that sends + both a name and `affiliation_ambiguous` still shows the plural + signal, never a guessed primary. Repeating chips use the + `RelatedNodeChip` module and `--related-node-*` tokens. + +## [0.76.0] - 2026-08-16 + +### Changed + +- Related-node person chips distinguish a known-plural affiliation + set from a missing one. After `make seed`, walking from Ada West + shows `Priya Nair, multiple organizations (Counterparty)` so the + next action is to open the Keyman list. The chip still never names + a guessed primary. Two distinct catalog orgs are marked the same + way. A person with no affiliation stays side-only. Unresolved + names that differ only by letter case count as one identity. + +## [0.75.0] - 2026-08-16 + +### Changed + +- Related-node chips use decision-relevant business context instead of + ontology-class noise. Walking from Demo Corp shows + `Ada West, Demo Corp (Our side)` and `Demo Corp (Company)`. A person + chip adds an organization only when exactly one identity is known; + a resolved catalog org shows `entity_name`, and aliases of that org + collapse. Post chips show the title only. Click the chip to continue + the walk, or open the Keyman list when you need every affiliation. + ## [0.71.0] - 2026-08-14 ### Added diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index bb398d141..0978c9784 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -8,6 +8,8 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from uuid import UUID @@ -275,6 +277,86 @@ async def load_visible_subgraph( return [edge_spec_from_row(row) for row in rows] +@dataclass(frozen=True) +class CompactAffiliation: + """Authorized compact affiliation for one related-node person. + + ``identity_count`` is the number of distinct organization identities + after catalog-id and casefold-alias collapse. ``display_name`` is + set only when that count is exactly one so the chip never invents + a primary. ``ambiguous`` is true when the count is greater than + one -- a known plural set is not the same as a missing affiliation + (Browne et al., 2001). + """ + + identity_count: int + display_name: str | None = None + + @property + def ambiguous(self) -> bool: + """True when more than one distinct organization identity remains.""" + return self.identity_count > 1 + + +def compact_affiliation_summaries( + rows: list[Mapping[str, Any]], +) -> dict[str, CompactAffiliation]: + """Return the compact affiliation summary per person. + + A resolved ``corporate_entity`` is one identity, labeled with + ``catalog_entity_name`` (falling back to the raw extraction + string). Unresolved names that casefold-match that catalog label + collapse into it -- the catalog name wins. Distinct unresolved + names stay distinct, except two unresolved strings that differ + only by letter case count as one identity. A person with more + than one remaining identity keeps ``ambiguous=True`` and no + ``display_name`` so the chip never invents a primary org. + """ + catalog_ids: dict[str, set[str]] = {} + catalog_labels: dict[str, dict[str, str]] = {} + unresolved_labels: dict[str, dict[str, str]] = {} + for row in rows: + person_id = str(row["person_id"]) + raw_name = (row["affiliated_organization_name"] or "").strip() + catalog_id = row["affiliated_corporate_entity_id"] + catalog_name = (row["catalog_entity_name"] or "").strip() + if catalog_id is not None: + identity = str(catalog_id) + catalog_ids.setdefault(person_id, set()).add(identity) + label = catalog_name or raw_name + if label: + catalog_labels.setdefault(person_id, {})[identity] = label + continue + if raw_name: + unresolved_labels.setdefault(person_id, {}).setdefault( + raw_name.casefold(), raw_name + ) + + summaries: dict[str, CompactAffiliation] = {} + for person_id in set(catalog_ids) | set(unresolved_labels): + labels_by_id = catalog_labels.get(person_id, {}) + catalog_name_fold = {name.casefold() for name in labels_by_id.values()} + leftover_names = { + name + for fold, name in unresolved_labels.get(person_id, {}).items() + if fold not in catalog_name_fold + } + identity_count = len(catalog_ids.get(person_id, set())) + len(leftover_names) + if identity_count == 0: + continue + display_name: str | None = None + if identity_count == 1: + if leftover_names: + display_name = next(iter(leftover_names)) + elif labels_by_id: + display_name = next(iter(labels_by_id.values())) + summaries[person_id] = CompactAffiliation( + identity_count=identity_count, + display_name=display_name, + ) + return summaries + + async def hydrate_related_nodes( conn: asyncpg.Connection, related: list[tuple[str, float]], @@ -283,6 +365,11 @@ async def hydrate_related_nodes( Unknown ids are dropped. Ontology fields are omitted (not faked) when ``node_type_code`` has no term in lineageweave-kg.ttl. + Person nodes carry compact affiliation context only when exactly one + distinct organization identity is known. A resolved catalog org + supplies ``entity_name``; aliases of that same org collapse into it. + Multiple distinct affiliations set ``affiliation_ambiguous`` and + omit the name rather than collapsing into an invented primary. """ person_ids: list[str] = [] post_ids: list[str] = [] @@ -305,6 +392,22 @@ async def hydrate_related_nodes( person_ids, ) } if person_ids else {} + affiliations = compact_affiliation_summaries( + await conn.fetch( + """ + select + pa.person_id, + pa.affiliated_organization_name, + pa.affiliated_corporate_entity_id, + ce.entity_name as catalog_entity_name + from person_affiliation pa + left join corporate_entity ce + on ce.corporate_entity_id = pa.affiliated_corporate_entity_id + where pa.person_id = any($1::uuid[]) + """, + person_ids, + ) + ) if person_ids else {} posts = { str(row["post_id"]): row for row in await conn.fetch( @@ -315,11 +418,19 @@ async def hydrate_related_nodes( corps = { str(row["corporate_entity_id"]): row for row in await conn.fetch( - "select corporate_entity_id, entity_name from corporate_entity where corporate_entity_id = any($1::uuid[])", + "select corporate_entity_id, entity_name, entity_level_code " + "from corporate_entity where corporate_entity_id = any($1::uuid[])", corp_ids, ) } if corp_ids else {} + side_labels = await labels_for_codes( + conn, [row["person_side_code"] for row in people.values()] + ) + level_labels = await labels_for_codes( + conn, [row["entity_level_code"] for row in corps.values()] + ) + payload: list[dict[str, Any]] = [] for node_type_code, node_id, score in parsed: item: dict[str, Any] = { @@ -329,12 +440,23 @@ async def hydrate_related_nodes( **ontology_annotations(node_type_code), } if node_type_code == NODE_PERSON and node_id in people: + side = people[node_id]["person_side_code"] item["label"] = people[node_id]["person_name"] - item["person_side_code"] = people[node_id]["person_side_code"] + item["person_side_code"] = side + item["person_side_label"] = side_labels.get(side, side) + summary = affiliations.get(node_id) + if summary is not None: + if summary.display_name: + item["affiliation_organization_name"] = summary.display_name + if summary.ambiguous: + item["affiliation_ambiguous"] = True elif node_type_code == NODE_POST and node_id in posts: item["label"] = posts[node_id]["post_title"] elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps: + level = corps[node_id]["entity_level_code"] item["label"] = corps[node_id]["entity_name"] + item["entity_level_code"] = level + item["entity_level_label"] = level_labels.get(level, level) else: continue payload.append(item) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 74ab44701..3fd8d672f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -883,8 +883,27 @@ def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_to counterpart = by_id[seeded_db["counterpart_person_id"]] assert counterpart["ontology_label"] == "Person" assert counterpart["ontology_iri"].endswith("#Person") + assert counterpart["person_side_code"] == "counterparty" + assert counterpart["person_side_label"] == "Counterparty" + assert "affiliation_organization_name" not in counterpart + assert counterpart["affiliation_ambiguous"] is True + for node in body["related"]: + if node["node_type_code"] != "node_person": + continue + org = node.get("affiliation_organization_name") + if org is not None: + assert org.strip() own_post = by_id[seeded_db["own_private_post_id"]] assert own_post["ontology_label"] == "Post" + corp_nodes = [ + node for node in body["related"] if node["node_type_code"] == "node_corporate_entity" + ] + assert corp_nodes + assert all(node.get("entity_level_label") for node in corp_nodes) + if seeded_db["own_corp_id"] in related_ids: + own_corp = by_id[seeded_db["own_corp_id"]] + assert own_corp["entity_level_code"] == "company" + assert own_corp["entity_level_label"] == "Company" def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts( @@ -902,6 +921,11 @@ def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts( assert body["entity_name"] == "Test Corp" related_ids = {node["node_id"] for node in body["related"]} assert seeded_db["our_person_id"] in related_ids + our_person = next(node for node in body["related"] if node["node_id"] == seeded_db["our_person_id"]) + assert our_person["person_side_code"] == "our_side" + assert our_person["person_side_label"] == "Our side" + assert our_person["affiliation_organization_name"] == "Test Corp" + assert "affiliation_ambiguous" not in our_person assert seeded_db["other_private_post_id"] not in related_ids assert seeded_db["hidden_person_id"] not in related_ids diff --git a/docs/adr/0014-related-node-business-captions.md b/docs/adr/0014-related-node-business-captions.md new file mode 100644 index 000000000..ccbca43e6 --- /dev/null +++ b/docs/adr/0014-related-node-business-captions.md @@ -0,0 +1,64 @@ +# ADR-0014: Related-node chips use business context, not ontology class + +- Status: Accepted +- Date: 2026-08-16 + +## Context + +Related-node chips in the Keyman walk showed the ontology class +(`Person`, `Organization`, `Post`). Buyers already know they clicked a +person or an organization. The class label does not tell them which +side the person is on, which company they represent, or what to click +next. Keyman list rows already expose `person_side_label` and every +affiliation. The compact related-node chip is a different surface: it +must stay short enough to scan while walking. + +`person_affiliation` is N:N and has no `primary` column. Sorting +affiliations and taking the first row would invent a primary +organization. Priya Nair in the synthetic fixture belongs to both +Northridge Grid and Northridge Holdings. + +## Decision + +Hydrate related-node payloads with authorized lookup labels: + +- Person chips use `person_side_label` (fallback: raw `person_side_code`). +- A person chip adds `affiliation_organization_name` only when exactly + one distinct organization identity is known. A resolved catalog org + supplies `corporate_entity.entity_name`; unresolved aliases that + casefold-match that label collapse into it. Two unresolved names + that differ only by letter case count as one identity. +- A known-plural set (two unresolved names, two catalog orgs, or a + catalog org plus a distinct unresolved name) sets + `affiliation_ambiguous` and the caption uses + `multiple organizations`. That is not the same as a missing + affiliation. The related panel then names the next action: read + every organization in the Keyman list (or extract Keymen if the + list is empty), then click the chip to continue the walk. The + caption prefers the plural signal if a name is also present so a + stale payload cannot invent a primary. +- A unique org without a side still names the org + (`Ada West, Demo Corp`) so a missing side cannot revive the + ontology-class caption. +- Organization chips use `entity_level_label` (fallback: raw code). +- Post chips show the post title only. + +Person and organization chips use `Related nodes for ${caption}` as +the accessible name. Post chips use `Open related post: ${caption}` +so the next action stays in the name and the visible caption is +contained (WCAG 2.5.3). Full affiliation lists stay on the Keyman +and affiliate-tree surfaces. + +## Consequences + +Walking from Ada West shows +`Priya Nair, multiple organizations (Counterparty)` rather than +`Priya Nair, Northridge Grid (Counterparty)` or a side-only chip that +looks like Priya has no organization. Walking from Demo Corp shows +`Ada West, Demo Corp (Our side)` and `Demo Corp (Company)`. +Click a chip to continue the walk. When the chip says multiple +organizations, read the Keyman list above first. + +## References + +See [RELATED_NODE_AFFILIATION_REFERENCES.md](../doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). diff --git a/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md new file mode 100644 index 000000000..c33f20a49 --- /dev/null +++ b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md @@ -0,0 +1,46 @@ +# Related-node affiliation references + +APA 7th citations for ADR-0014 (compact related-node captions). These +are the sources to open before changing affiliation display. + +## Multiple membership (do not invent a primary) + +Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership +multiple classification (MMMC) models. *Statistical Modelling, 1*(2), +103–124. https://doi.org/10.1177/1471082X0100100202 + +A person can belong to several organizations at once. Sorting +`person_affiliation` and taking the first row would treat a +multiple-membership structure as if one org were the atom. Compact +chips therefore name an organization only when exactly one identity +remains, and they say `multiple organizations` when more than one +remains. + +## Accessible name contains the visible caption + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +Success Criterion 2.5.3 Label in Name: the visible chip text is +contained in `Related nodes for ${caption}` or +`Open related post: ${caption}`. + +## Design tokens for repeating walk controls + +W3C Design Tokens Community Group. (2025). *Design Tokens Format +Module* (Editor's Draft). https://www.w3.org/community/design-tokens/ + +Repeating related-node chips share `--related-node-*` tokens in +`frontend/src/relatedNodeTokens.css` and the `RelatedNodeChip` module. +Do not restyle one walk surface with a one-off class. + +## Time (proposed; not on this schema yet) + +Singer, J. D., & Willett, J. B. (2003). *Applied longitudinal data +analysis: Modeling change and event occurrence*. Oxford University +Press. + +A past affiliation that has ended is not a current second membership. +Until `person_affiliation` stores an interval, compact chips count +every stored row. Do not add those columns until Milestone 2.1 and +the #74 ontology stack settle `0012+` migration numbers. diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index acd55620b..ddcfd0227 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -253,10 +253,21 @@ not yet a resolved person node, and a mention whose side cannot be classified into the closed `{our_side, counterparty}` set is dropped rather than guessed. N:N organization attachments are slot-filling on that mention (a person may have zero, one, or several affiliations in -the same post), not a second independent NER pass. The live client -calls contextual-orchestrator (`mode="route"`) rather than a raw LLM -API so reasoning-effort allocation stays centralized with the -adjudication channel. Proven for real during development against +the same post), not a second independent NER pass. Compact related-node +chips therefore add an organization only when exactly one organization +identity is known. Resolved catalog aliases collapse; distinct +memberships stay distinct and the chip says `multiple organizations` +so a plural set is not mistaken for a missing affiliation. Collapsing +several memberships into a sorted "primary" would repeat the +atomistic fallacy Browne et al. (2001) warn against for +multiple-membership structures. The related panel names that next +action: read the Keyman list (or extract Keymen), then click the +chip to continue the walk. Citations live in +[`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). The live client +calls contextual-orchestrator (`mode="auto"`) rather than a raw LLM +API so the orchestration plane can allocate route, verify, or a +deeper workflow; adjudication and post-chat keep explicit +`mode="verify"`. Proven for real during development against `fixtures.ambiguous_keyman_post` when orchestrator credentials are set; the default suite asserts the parser and the never-fake null client. @@ -271,7 +282,10 @@ adaptive cutoff (a relevance-ratio threshold against the top score) -- `tests/test_knowledge_graph.py` proves this concretely: the same ratio threshold yields a five-node related-set from a well-connected "hub" node and a one-node related-set from a sparsely-connected node, with no hop-count -constant anywhere in the algorithm or the test. +constant anywhere in the algorithm or the test. Hydrated related-node +chips (ADR-0014) then replace the ontology class with the authorized +side or entity-level label so the next click is a business decision, +not a class reminder. ## Entity-relationship classification and corporate hierarchy resolution (Phase 3) diff --git a/frontend/package.json b/frontend/package.json index 9c84795d9..5f8f72e51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.71.0", + "version": "0.77.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 76cf3665c..ddddd716a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,5 @@ +@import "./relatedNodeTokens.css"; + #root { max-width: 960px; margin: 0 auto; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 415e1419f..959d234f3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -514,6 +514,9 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", ontology_label: "Person", label: "Ada West", + person_side_code: "our_side", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", relevance: 0.4, }, ], @@ -533,6 +536,9 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", ontology_label: "Person", label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + affiliation_ambiguous: true, relevance: 0.4, }, { @@ -549,6 +555,8 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Organization", ontology_label: "Organization", label: "Demo Corp", + entity_level_code: "company", + entity_level_label: "Company", relevance: 0.2, }, ], @@ -567,6 +575,9 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", ontology_label: "Person", label: "Ada West", + person_side_code: "our_side", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", relevance: 0.5, }, ], @@ -969,7 +980,21 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); - expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Priya Nair, multiple organizations (Counterparty)" }), + ).toBeInTheDocument(); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Priya Nair (Person)", + ); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Northridge Grid", + ); + const relatedPanel = screen.getByText("Related to Ada West").closest(".related-keymen"); + expect(relatedPanel).toHaveTextContent( + "A chip that says multiple organizations is not a missing affiliation. Read every organization in the Keyman list above, then click the chip to continue the walk.", + ); + expect(relatedPanel).toHaveTextContent("Linked post"); + expect(relatedPanel).not.toHaveTextContent("Linked post (Post)"); await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); await waitFor(() => expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), @@ -982,7 +1007,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "R&R Keyman: Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); - expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Priya Nair, multiple organizations (Counterparty)" }), + ).toBeInTheDocument(); }); it("opens related nodes from a related corporate entity", async () => { @@ -991,9 +1018,17 @@ describe("App, authenticated", () => { 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 Demo Corp" })); + expect( + screen.getByRole("button", { name: "Related nodes for Demo Corp (Company)" }), + ).toBeInTheDocument(); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Demo Corp (Organization)", + ); + await userEvent.click(screen.getByRole("button", { name: "Related nodes for Demo Corp (Company)" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("shows the VOC excerpt under its counterparty, not a detached list", async () => { @@ -1022,7 +1057,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "VOC Keyman: Northridge Grid" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("opens related Keyman nodes from an affiliate-tree person", async () => { @@ -1031,7 +1068,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("opens related nodes from a Keyman affiliation organization", async () => { @@ -1040,7 +1079,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Keyman affiliation: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("opens related nodes from an affiliate-tree organization", async () => { @@ -1049,7 +1090,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Affiliate org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Affiliate org: Northridge Grid" })).not.toBeInTheDocument(); }); @@ -1059,7 +1102,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Counterparty org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Counterparty org: Northridge Grid" })).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e39a9253..952fe07de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,8 @@ import { } from "./api"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedAffiliationNextAction } from "./relatedNodeCaption"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -670,6 +672,11 @@ function KeymanPanel({ {selectedName && (

Related to {selectedName}

+ {related !== null && related.some((node) => node.affiliation_ambiguous) ? ( +

+ {relatedAffiliationNextAction(Boolean(keymen && keymen.length > 0))} +

+ ) : null} {related === null ? (

Loading related nodes...

) : related.length === 0 ? ( @@ -677,48 +684,47 @@ function KeymanPanel({ ) : ( diff --git a/frontend/src/RelatedNodeChip.stories.tsx b/frontend/src/RelatedNodeChip.stories.tsx new file mode 100644 index 000000000..34c96e05e --- /dev/null +++ b/frontend/src/RelatedNodeChip.stories.tsx @@ -0,0 +1,90 @@ +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; + +/** + * Storybook inventory for the repeating related-node chip. + * + * Host this file with Storybook 10 (Vite + React) when the later + * analysis-run token stack lands. Until then the same four states + * are locked by RelatedNodeChip.test.tsx and relatedNodeCaption.test.ts. + */ +const meta = { + title: "Lineage/RelatedNodeChip", + component: RelatedNodeChip, +}; + +export default meta; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +export const UniqueAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + }, +}; + +export const PluralAffiliations = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + }, +}; + +export const MissingAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + }, +}; + +export const OrganizationAndPost = { + render: () => ( +
    +
  • + undefined} + node={node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + })} + /> +
  • +
  • + undefined} + node={node({ + node_type_code: "node_post", + label: "Linked post", + })} + /> +
  • +
+ ), +}; diff --git a/frontend/src/RelatedNodeChip.test.tsx b/frontend/src/RelatedNodeChip.test.tsx new file mode 100644 index 000000000..305bb3391 --- /dev/null +++ b/frontend/src/RelatedNodeChip.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedNodeChipAccessibleName } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("RelatedNodeChip", () => { + it("keeps the visible plural caption inside the walk name", () => { + const caption = "Priya Nair, multiple organizations (Counterparty)"; + expect(relatedNodeChipAccessibleName(caption, "walk_person")).toBe( + `Related nodes for ${caption}`, + ); + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + }); + + it("opens the post when the buyer clicks a title-only chip", async () => { + const onSelect = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0].node_id).toBe("post-1"); + }); +}); diff --git a/frontend/src/RelatedNodeChip.tsx b/frontend/src/RelatedNodeChip.tsx new file mode 100644 index 000000000..f0c1c381c --- /dev/null +++ b/frontend/src/RelatedNodeChip.tsx @@ -0,0 +1,32 @@ +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, + type RelatedNodeChipAction, +} from "./relatedNodeCaption"; + +/** + * One related-node chip. Use this module for every repeating walk + * control so caption, tokens, and accessible name stay one contract. + */ +export function RelatedNodeChip({ + node, + action, + onSelect, +}: { + node: RelatedNode; + action: RelatedNodeChipAction; + onSelect: (node: RelatedNode) => void; +}) { + const caption = relatedNodeCaption(node); + return ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b6692776..5fa4f5469 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -78,6 +78,11 @@ export interface RelatedNode { relevance: number; label?: string; person_side_code?: string; + person_side_label?: string; + affiliation_organization_name?: string; + affiliation_ambiguous?: boolean; + entity_level_code?: string; + entity_level_label?: string; ontology_iri?: string; ontology_label?: string; } diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts new file mode 100644 index 000000000..a5ac60044 --- /dev/null +++ b/frontend/src/relatedNodeCaption.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { RelatedNode } from "./api"; +import { relatedAffiliationNextAction, relatedNodeCaption } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("relatedNodeCaption", () => { + it("names the side and unique org so the next click is a business walk", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + ), + ).toBe("Ada West, Demo Corp (Our side)"); + }); + + it("names a known-plural set so the next action is the Keyman list", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + ), + ).toBe("Priya Nair, multiple organizations (Counterparty)"); + }); + + it("keeps a person with no affiliation side-only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + }), + ), + ).toBe("Priya Nair (Counterparty)"); + }); + + it("keeps a unique org when the side label is missing", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + affiliation_organization_name: "Demo Corp", + ontology_label: "Person", + }), + ), + ).toBe("Ada West, Demo Corp"); + }); + + it("prefers the plural signal when a name is also present", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_organization_name: "Northridge Grid", + affiliation_ambiguous: true, + }), + ), + ).toBe("Priya Nair, multiple organizations (Counterparty)"); + }); + + it("tells the buyer to read the Keyman list when it is already on screen", () => { + expect(relatedAffiliationNextAction(true)).toBe( + "A chip that says multiple organizations is not a missing affiliation. " + + "Read every organization in the Keyman list above, then click the chip " + + "to continue the walk.", + ); + }); + + it("tells the buyer to extract Keymen when the list is empty", () => { + expect(relatedAffiliationNextAction(false)).toBe( + "A chip that says multiple organizations is not a missing affiliation. " + + "Extract Keymen to list every organization, then click the chip to " + + "continue the walk.", + ); + }); + + it("uses the entity-level label on organization chips", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + }), + ), + ).toBe("Demo Corp (Company)"); + }); + + it("shows the post title only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_post", + label: "Linked post", + ontology_label: "Post", + }), + ), + ).toBe("Linked post"); + }); +}); diff --git a/frontend/src/relatedNodeCaption.ts b/frontend/src/relatedNodeCaption.ts new file mode 100644 index 000000000..68fa524a5 --- /dev/null +++ b/frontend/src/relatedNodeCaption.ts @@ -0,0 +1,107 @@ +import type { RelatedNode } from "./api"; + +const NODE_PERSON = "node_person"; +const NODE_POST = "node_post"; +const NODE_CORPORATE_ENTITY = "node_corporate_entity"; + +type RelatedNodeKind = typeof NODE_PERSON | typeof NODE_POST | typeof NODE_CORPORATE_ENTITY; + +function isRelatedNodeKind(code: string): code is RelatedNodeKind { + return code === NODE_PERSON || code === NODE_POST || code === NODE_CORPORATE_ENTITY; +} + +/** + * Decision-facing label for a related-node chip. + * + * Person chips use the authorized side label and, when exactly one + * organization identity is known, that organization. A known-plural + * set uses "multiple organizations" even if a name is also present + * so a stale payload cannot invent a primary. That is not the same + * as a missing affiliation. + * A unique org without a side still names the org so a missing side + * cannot revive the ontology-class caption. Organization chips use + * the entity-level label. Post chips are the title only. + */ +export function relatedNodeCaption(node: RelatedNode): string { + const name = node.label?.trim() || node.node_id; + const kind = node.node_type_code; + if (!isRelatedNodeKind(kind)) { + return `${name} (${node.ontology_label ?? kind})`; + } + switch (kind) { + case NODE_PERSON: { + const side = node.person_side_label?.trim() || node.person_side_code?.trim(); + const org = node.affiliation_organization_name?.trim(); + const context = node.affiliation_ambiguous ? "multiple organizations" : org || ""; + if (side && context) { + return `${name}, ${context} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + if (context) { + return `${name}, ${context}`; + } + return `${name} (${node.ontology_label ?? kind})`; + } + case NODE_CORPORATE_ENTITY: { + const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); + if (level) { + return `${name} (${level})`; + } + return `${name} (${node.ontology_label ?? kind})`; + } + case NODE_POST: + return name; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +export type RelatedNodeChipAction = "walk_person" | "walk_entity" | "open_post"; + +/** + * Accessible name for a related-node chip. + * + * The visible caption is contained in the name (WCAG 2.2 Success + * Criterion 2.5.3). Walk chips continue the graph. Post chips open + * the evidence body. + */ +export function relatedNodeChipAccessibleName( + caption: string, + action: RelatedNodeChipAction, +): string { + switch (action) { + case "walk_person": + case "walk_entity": + return `Related nodes for ${caption}`; + case "open_post": + return `Open related post: ${caption}`; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +/** + * Next action when a related-node chip marks a known-plural affiliation + * set. The Keyman list is the full N:N surface; the chip click continues + * the walk and must not be mistaken for "this person has no organization." + */ +export function relatedAffiliationNextAction(hasKeymanList: boolean): string { + if (hasKeymanList) { + return ( + "A chip that says multiple organizations is not a missing affiliation. " + + "Read every organization in the Keyman list above, then click the chip " + + "to continue the walk." + ); + } + return ( + "A chip that says multiple organizations is not a missing affiliation. " + + "Extract Keymen to list every organization, then click the chip to " + + "continue the walk." + ); +} diff --git a/frontend/src/relatedNodeTokens.css b/frontend/src/relatedNodeTokens.css new file mode 100644 index 000000000..be299dead --- /dev/null +++ b/frontend/src/relatedNodeTokens.css @@ -0,0 +1,32 @@ +:root { + --related-node-chip-font: inherit; + --related-node-chip-color: inherit; + --related-node-chip-padding: 0; + --related-node-chip-background: none; + --related-node-chip-border: none; + --related-node-chip-text-align: left; + --related-node-chip-cursor: pointer; + --related-node-hint-font-size: 0.9rem; + --related-node-hint-margin-block-end: 0.5rem; + --related-node-hint-color: inherit; +} + +.related-node-chip { + background: var(--related-node-chip-background); + border: var(--related-node-chip-border); + padding: var(--related-node-chip-padding); + color: var(--related-node-chip-color); + cursor: var(--related-node-chip-cursor); + font: var(--related-node-chip-font); + text-align: var(--related-node-chip-text-align); +} + +.related-node-chip:hover { + text-decoration: underline; +} + +.related-affiliation-hint { + margin: 0 0 var(--related-node-hint-margin-block-end); + font-size: var(--related-node-hint-font-size); + color: var(--related-node-hint-color); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 0ac8e50fe..b5a55a249 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.71.0" +__version__ = "0.77.0" diff --git a/pyproject.toml b/pyproject.toml index 9a2272d3a..fcb40baf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.71.0" +version = "0.77.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_related_node_affiliation_ambiguity.py b/tests/test_related_node_affiliation_ambiguity.py new file mode 100644 index 000000000..cb9d3fea3 --- /dev/null +++ b/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,194 @@ +"""Identity-rule unit tests for compact related-node affiliation.""" + +from __future__ import annotations + +from typing import Any + +from backend.app.knowledge_graph import compact_affiliation_summaries + +_PERSON_ID = "11111111-1111-4111-8111-111111111111" +_CATALOG_ID = "22222222-2222-4222-8222-222222222222" +_SECOND_CATALOG_ID = "33333333-3333-4333-8333-333333333333" + + +def _summarize(affiliations: list[dict[str, Any]]): + rows = [ + { + "person_id": _PERSON_ID, + "affiliated_organization_name": None, + "affiliated_corporate_entity_id": None, + "catalog_entity_name": None, + **row, + } + for row in affiliations + ] + return compact_affiliation_summaries(rows).get(_PERSON_ID) + + +def _payload(summary) -> dict[str, Any]: + """Mirror hydrate: emit a name or the plural flag, never both.""" + if summary is None: + return {} + item: dict[str, Any] = {} + if summary.display_name: + item["affiliation_organization_name"] = summary.display_name + if summary.ambiguous: + item["affiliation_ambiguous"] = True + return item + + +def test_related_person_exposes_one_unambiguous_affiliation() -> None: + """A single known affiliation is safe to use as compact display context.""" + summary = _summarize([{"affiliated_organization_name": "Northridge Grid"}]) + assert summary is not None + assert summary.display_name == "Northridge Grid" + assert summary.ambiguous is False + assert _payload(summary) == {"affiliation_organization_name": "Northridge Grid"} + + +def test_related_person_marks_plural_affiliations_ambiguous() -> None: + """A known-plural set is not a missing affiliation and never invents a primary.""" + summary = _summarize( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert summary is not None + assert summary.display_name is None + assert summary.ambiguous is True + assert _payload(summary) == {"affiliation_ambiguous": True} + + +def test_related_person_omits_blank_affiliation() -> None: + """Whitespace-only extraction strings are missing evidence, not a name.""" + summary = _summarize([{"affiliated_organization_name": " "}]) + assert summary is None + assert _payload(summary) == {} + + +def test_related_person_uses_catalog_name_for_one_resolved_org() -> None: + """A resolved catalog org supplies entity_name, not the raw extraction.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + } + ] + ) + assert summary is not None + assert summary.display_name == "Demo Corp" + assert summary.ambiguous is False + assert _payload(summary) == {"affiliation_organization_name": "Demo Corp"} + + +def test_related_person_collapses_aliases_of_one_catalog_org() -> None: + """Two raw strings for the same corporate_entity_id are one identity.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + ] + ) + assert summary is not None + assert summary.display_name == "Demo Corp" + assert summary.ambiguous is False + + +def test_related_person_collapses_unresolved_name_matching_catalog() -> None: + """An unresolved alias of the catalog label is not a second org.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "demo corp"}, + ] + ) + assert summary is not None + assert summary.display_name == "Demo Corp" + assert summary.ambiguous is False + + +def test_related_person_omits_resolved_plus_distinct_unresolved() -> None: + """A catalog org plus a different unresolved name stays ambiguous.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert summary is not None + assert summary.display_name is None + assert summary.ambiguous is True + assert _payload(summary) == {"affiliation_ambiguous": True} + + +def test_related_person_marks_two_distinct_catalog_orgs_ambiguous() -> None: + """Two resolved catalog orgs must not collapse into a guessed primary.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + { + "affiliated_organization_name": "Northridge Holdings", + "affiliated_corporate_entity_id": _SECOND_CATALOG_ID, + "catalog_entity_name": "Northridge Holdings", + }, + ] + ) + assert summary is not None + assert summary.display_name is None + assert summary.ambiguous is True + assert _payload(summary) == {"affiliation_ambiguous": True} + + +def test_related_person_keeps_nameless_catalog_identity_side_only() -> None: + """An orphaned catalog id with no name is not a guessed primary or a plural set.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "", + } + ] + ) + assert summary is not None + assert summary.identity_count == 1 + assert summary.display_name is None + assert summary.ambiguous is False + assert _payload(summary) == {} + + +def test_related_person_collapses_unresolved_names_that_differ_only_by_case() -> None: + """Letter-case variants of one unresolved name are one identity.""" + summary = _summarize( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "northridge grid"}, + ] + ) + assert summary is not None + assert summary.display_name == "Northridge Grid" + assert summary.ambiguous is False + assert _payload(summary) == {"affiliation_organization_name": "Northridge Grid"} diff --git a/uv.lock b/uv.lock index 1964f34b9..21f1182cd 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.71.0" +version = "0.77.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From c5903bd1f7fd9cf7c180abdebfab3ae8786e7160 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:39:06 +0900 Subject: [PATCH 2/5] test: stabilize related-node verification coverage --- backend/tests/test_api.py | 16 ++++++++++------ pyproject.toml | 1 + tests/test_related_node_affiliation_ambiguity.py | 3 ++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3fd8d672f..1fbd77440 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -866,6 +866,7 @@ def test_other_corp_private_voc_evidence_is_forbidden(client, demo_analyst_token def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_token, seeded_db) -> None: + """Expose buyer-facing labels while excluding invisible related posts.""" response = client.get( f"/api/keymen/{seeded_db['our_person_id']}/related", headers={"Authorization": f"Bearer {demo_analyst_token}"}, @@ -909,8 +910,10 @@ def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_to def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts( client, demo_analyst_token, seeded_db ) -> None: - """GET /api/corporate-entities/{id}/related must walk from the org - the same way Keyman related walks from a person. + """Verify the org-related endpoint walks like Keyman-related lookup. + + The response must include the organization's person-side labels while + excluding private and hidden related posts. """ response = client.get( f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", @@ -1079,6 +1082,7 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok POST /api/posts/{id}/verify-relations. """ os.environ["SEARXNG_BASE_URL"] = _SEARXNG_BASE_URL + fake_org_name = f"Zzqxvthorp Fictitious Nonexistent Org {uuid.uuid4().hex}" admin_conn = psycopg2.connect(seeded_db["dsn"]) admin_conn.autocommit = True @@ -1098,8 +1102,8 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok cur.execute( "insert into post_counterparty_entity (post_id, counterparty_entity_name, relationship_type_code) " "values (%s, 'Wikipedia', 'rel_voc'), " - "(%s, 'Zzqxvthorp Fictitious Nonexistent Org 8f3e1c', 'rel_voco')", - (seeded_db["public_post_id"], seeded_db["public_post_id"]), + "(%s, %s, 'rel_voco')", + (seeded_db["public_post_id"], seeded_db["public_post_id"], fake_org_name), ) finally: admin_conn.close() @@ -1119,7 +1123,7 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok ) assert real_org["verification_evidence_url"] - fake_org = verified["Zzqxvthorp Fictitious Nonexistent Org 8f3e1c"] + fake_org = verified[fake_org_name] assert fake_org["verification_status_code"] == "verify_uncorroborated" assert fake_org["verification_evidence_url"] is None @@ -1129,7 +1133,7 @@ def test_verify_relations_persists_real_search_outcomes(client, demo_analyst_tok ) persisted = {c["counterparty_entity_name"]: c for c in counterparties_response.json()["counterparties"]} assert persisted["Wikipedia"]["verification_status_code"] == "verify_corroborated" - assert persisted["Zzqxvthorp Fictitious Nonexistent Org 8f3e1c"]["verification_status_code"] == "verify_uncorroborated" + assert persisted[fake_org_name]["verification_status_code"] == "verify_uncorroborated" # Already-checked rows are left alone on a second call, not re-searched. second_response = client.post( diff --git a/pyproject.toml b/pyproject.toml index fcb40baf6..df477d006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,4 @@ include = ["lineageweave*", "backend*"] [tool.pytest.ini_options] testpaths = ["tests", "backend/tests"] +pythonpath = ["."] diff --git a/tests/test_related_node_affiliation_ambiguity.py b/tests/test_related_node_affiliation_ambiguity.py index cb9d3fea3..36bb47997 100644 --- a/tests/test_related_node_affiliation_ambiguity.py +++ b/tests/test_related_node_affiliation_ambiguity.py @@ -11,7 +11,8 @@ _SECOND_CATALOG_ID = "33333333-3333-4333-8333-333333333333" -def _summarize(affiliations: list[dict[str, Any]]): +def _summarize(affiliations: list[dict[str, Any]]) -> Any: + """Build one compact affiliation result from synthetic database rows.""" rows = [ { "person_id": _PERSON_ID, From 27a3648cb90b8a344b9ac808c13ce3a1cd8e470d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:56:36 +0900 Subject: [PATCH 3/5] feat: add truthful related-node business captions --- ARCHITECTURE.md | 7 + CHANGELOG.md | 11 + backend/app/knowledge_graph.py | 92 ++++++++- backend/tests/test_api.py | 9 + .../0036-related-node-business-captions.md | 36 ++++ .../RELATED_NODE_AFFILIATION_REFERENCES.md | 23 +++ docs/lineage-bi-research-notes.md | 10 + frontend/package.json | 2 +- frontend/src/App.css | 2 + frontend/src/App.test.tsx | 31 +-- frontend/src/App.tsx | 65 +++--- frontend/src/RelatedNodeChip.stories.tsx | 90 ++++++++ frontend/src/RelatedNodeChip.test.tsx | 56 +++++ frontend/src/RelatedNodeChip.tsx | 35 ++++ frontend/src/api.ts | 4 + frontend/src/relatedNodeCaption.test.ts | 120 +++++++++++ frontend/src/relatedNodeCaption.ts | 120 +++++++++++ frontend/src/relatedNodeTokens.css | 32 +++ lineageweave/__init__.py | 2 +- lineageweave/relation_verification.py | 7 +- pyproject.toml | 2 +- ...test_related_node_affiliation_ambiguity.py | 194 ++++++++++++++++++ tests/test_relation_verification.py | 15 ++ uv.lock | 2 +- 24 files changed, 915 insertions(+), 52 deletions(-) create mode 100644 docs/adr/0036-related-node-business-captions.md create mode 100644 docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md create mode 100644 frontend/src/RelatedNodeChip.stories.tsx create mode 100644 frontend/src/RelatedNodeChip.test.tsx create mode 100644 frontend/src/RelatedNodeChip.tsx create mode 100644 frontend/src/relatedNodeCaption.test.ts create mode 100644 frontend/src/relatedNodeCaption.ts create mode 100644 frontend/src/relatedNodeTokens.css create mode 100644 tests/test_related_node_affiliation_ambiguity.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5bdebfab1..0689d499b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -335,6 +335,13 @@ Keyman sides are labeled from `common_lookup_value` (`Our side`, codes when a label exists. Related-node person chips use the same side lookup label (for example, `Our side` or `Counterparty`) rather than exposing the generic PROV-O `Person` class as business context. +When a person has several distinct affiliation identities, the API emits +`affiliation_ambiguous` and the reusable `RelatedNodeChip` says +`multiple organizations`; it never chooses the first row as a primary. +When exactly one identity remains, the chip includes that organization. +Organization chips use the cataloged entity-level label and post chips use +the source title only. The full N:N list stays visible on the Keyman panel, +which names the next action before the buyer continues the walk. `GET /api/posts` and `GET /api/posts/{post_id}` include `voc_type_label` / `visibility_label` from `common_lookup_value` so diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9b3f19b..b961a02b1 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). +## [2.12.6] - 2026-08-20 + +### Changed + +- Related-node chips now show authorized business context: a unique + affiliation, a truthful `multiple organizations` signal, or the + cataloged entity level. Post chips retain the source title only. +- The plural-affiliation panel tells the buyer to read the complete + Keyman list before continuing the graph walk, preserving every + membership instead of inventing a primary organization. + ## [2.12.5] - 2026-08-18 ### Fixed diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index ce7289bb5..c26546627 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -8,6 +8,8 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from uuid import UUID @@ -442,6 +444,65 @@ async def load_visible_subgraph( ) return [edge_spec_from_row(row) for row in rows] + +@dataclass(frozen=True) +class CompactAffiliation: + """Authorized compact affiliation for one related-node person.""" + + identity_count: int + display_name: str | None = None + + @property + def ambiguous(self) -> bool: + """Return whether more than one organization identity is known.""" + return self.identity_count > 1 + + +def compact_affiliation_summaries( + rows: list[Mapping[str, Any]], +) -> dict[str, CompactAffiliation]: + """Summarize affiliations without inventing a primary organization.""" + catalog_ids: dict[str, set[str]] = {} + catalog_labels: dict[str, dict[str, str]] = {} + unresolved_labels: dict[str, dict[str, str]] = {} + for row in rows: + person_id = str(row["person_id"]) + raw_name = (row["affiliated_organization_name"] or "").strip() + catalog_id = row["affiliated_corporate_entity_id"] + catalog_name = (row["catalog_entity_name"] or "").strip() + if catalog_id is not None: + identity = str(catalog_id) + catalog_ids.setdefault(person_id, set()).add(identity) + label = catalog_name or raw_name + if label: + catalog_labels.setdefault(person_id, {})[identity] = label + continue + if raw_name: + unresolved_labels.setdefault(person_id, {}).setdefault( + raw_name.casefold(), raw_name + ) + + summaries: dict[str, CompactAffiliation] = {} + for person_id in set(catalog_ids) | set(unresolved_labels): + labels_by_id = catalog_labels.get(person_id, {}) + catalog_name_fold = {name.casefold() for name in labels_by_id.values()} + leftover_names = { + name + for fold, name in unresolved_labels.get(person_id, {}).items() + if fold not in catalog_name_fold + } + identity_count = len(catalog_ids.get(person_id, set())) + len(leftover_names) + if identity_count == 0: + continue + display_name: str | None = None + if identity_count == 1: + display_name = next(iter(leftover_names), None) + if display_name is None and labels_by_id: + display_name = next(iter(labels_by_id.values())) + summaries[person_id] = CompactAffiliation(identity_count, display_name) + return summaries + + async def hydrate_related_nodes( conn: asyncpg.Connection, related: list[tuple[str, float]], @@ -450,6 +511,8 @@ async def hydrate_related_nodes( Unknown ids are dropped. Ontology fields are omitted (not faked) when ``node_type_code`` has no term in lineageweave-kg.ttl. + Person and organization nodes carry decision-relevant affiliation and + entity-level labels when the catalog provides them. """ person_ids: list[str] = [] post_ids: list[str] = [] @@ -475,6 +538,20 @@ async def hydrate_related_nodes( person_ids, ) } if person_ids else {} + affiliations = compact_affiliation_summaries( + await conn.fetch( + """ + select pa.person_id, pa.affiliated_organization_name, + pa.affiliated_corporate_entity_id, + ce.entity_name as catalog_entity_name + from person_affiliation pa + left join corporate_entity ce + on ce.corporate_entity_id = pa.affiliated_corporate_entity_id + where pa.person_id = any($1::uuid[]) + """, + person_ids, + ) + ) if person_ids else {} posts = { str(row["post_id"]): row for row in await conn.fetch( @@ -485,7 +562,8 @@ async def hydrate_related_nodes( corps = { str(row["corporate_entity_id"]): row for row in await conn.fetch( - "select corporate_entity_id, entity_name from corporate_entity where corporate_entity_id = any($1::uuid[])", + "select corporate_entity_id, entity_name, entity_level_code " + "from corporate_entity where corporate_entity_id = any($1::uuid[])", corp_ids, ) } if corp_ids else {} @@ -500,6 +578,9 @@ async def hydrate_related_nodes( side_labels = await labels_for_codes( conn, [row["person_side_code"] for row in people.values()] ) + level_labels = await labels_for_codes( + conn, [row["entity_level_code"] for row in corps.values()] + ) payload: list[dict[str, Any]] = [] for node_type_code, node_id, score in parsed: @@ -514,10 +595,19 @@ async def hydrate_related_nodes( item["label"] = people[node_id]["person_name"] item["person_side_code"] = side item["person_side_label"] = side_labels.get(side, side) + summary = affiliations.get(node_id) + if summary is not None: + if summary.display_name: + item["affiliation_organization_name"] = summary.display_name + if summary.ambiguous: + item["affiliation_ambiguous"] = True elif node_type_code == NODE_POST and node_id in posts: 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"] + level = corps[node_id]["entity_level_code"] + item["entity_level_code"] = level + item["entity_level_label"] = level_labels.get(level, level) elif node_type_code == NODE_TEAM and node_id in teams: item["label"] = teams[node_id]["team_name"] else: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 910a78480..23b6b1ba3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1904,6 +1904,13 @@ def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_to assert counterpart["ontology_iri"].endswith("#Person") assert counterpart["person_side_code"] == "counterparty" assert counterpart["person_side_label"] == "Counterparty" + assert "affiliation_organization_name" not in counterpart + assert counterpart["affiliation_ambiguous"] is True + corp_nodes = [ + node for node in body["related"] if node["node_type_code"] == "node_corporate_entity" + ] + assert corp_nodes + assert all(node.get("entity_level_label") for node in corp_nodes) own_post = by_id[seeded_db["own_private_post_id"]] assert own_post["ontology_label"] == "Post" @@ -1926,6 +1933,8 @@ def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts( our_person = next(node for node in body["related"] if node["node_id"] == seeded_db["our_person_id"]) assert our_person["person_side_code"] == "our_side" assert our_person["person_side_label"] == "Our side" + assert our_person["affiliation_organization_name"] == "Test Corp" + assert "affiliation_ambiguous" not in our_person assert seeded_db["other_private_post_id"] not in related_ids assert seeded_db["hidden_person_id"] not in related_ids diff --git a/docs/adr/0036-related-node-business-captions.md b/docs/adr/0036-related-node-business-captions.md new file mode 100644 index 000000000..4b2d00b32 --- /dev/null +++ b/docs/adr/0036-related-node-business-captions.md @@ -0,0 +1,36 @@ +# ADR-0036: Related-node chips use business context, not ontology class + +- Status: Accepted +- Date: 2026-08-20 + +## Context + +The related-node walk is a buyer decision surface. Showing only `Person`, +`Organization`, or `Post` does not identify the next useful action. A person +may have several memberships, and `person_affiliation` has no primary marker; +choosing the first sorted row would invent a primary organization. + +## Decision + +- Use the authorized side label and a unique organization only when one + identity remains after catalog-id and case-folded alias reconciliation. +- Mark more than one identity as `affiliation_ambiguous` and render + `multiple organizations`; never expose a guessed primary. +- Use the cataloged entity level for organization chips and the source title + only for post chips. +- Keep the full affiliation list on the Keyman surface. The related panel + tells the buyer to read that list, or extract Keymen first, before clicking + the chip to continue the walk. +- Reuse `RelatedNodeChip` and `--related-node-*` design tokens for every + repeated walk control. Visible captions are included in accessible names. + +## Consequences + +The compact walk remains scannable while retaining multiple-membership truth. +An unavailable or unresolved affiliation remains unavailable; it is never +converted into a plausible-sounding company name. Temporal membership +intervals remain a follow-up schema decision and are not inferred here. + +## References + +See [RELATED_NODE_AFFILIATION_REFERENCES.md](../doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). diff --git a/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md new file mode 100644 index 000000000..d561268cb --- /dev/null +++ b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md @@ -0,0 +1,23 @@ +# Related-node affiliation references + +APA 7th sources for [ADR-0036](../adr/0036-related-node-business-captions.md). + +Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership +multiple classification (MMMC) models. *Statistical Modelling, 1*(2), +103–124. https://doi.org/10.1177/1471082X0100100202 + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines +(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +W3C Design Tokens Community Group. (2025). *Design Tokens Format Module*. +https://www.w3.org/community/design-tokens/ + +Singer, J. D., & Willett, J. B. (2003). *Applied longitudinal data analysis: +Modeling change and event occurrence*. Oxford University Press. + +MMMC grounds the no-invented-primary rule; WCAG 2.2 grounds accessible names +that contain visible chip captions; the Design Tokens Format grounds the +shared repeated-control tokens. Singer and Willett document why a future +time-bounded affiliation schema must distinguish a former membership from a +current one. Until that schema exists, this feature counts stored identities +without asserting that they are current. diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index acd55620b..c5bd868bc 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -1,5 +1,15 @@ # Research notes: what this design is grounded in +## Related-node business captions + +ADR-0036 keeps compact graph navigation truthful for multiple-membership +people: the UI uses an authorized unique affiliation only when one identity +remains and otherwise says `multiple organizations`. The full N:N evidence +stays on the Keyman surface, and the panel gives the buyer the next action. +The implementation and APA 7th sources are recorded in +[`docs/adr/0036-related-node-business-captions.md`](adr/0036-related-node-business-captions.md) +and [`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). + **Status:** living document -- update when the channel set or fusion method changes. ## The problem this is answering diff --git a/frontend/package.json b/frontend/package.json index 312b92591..6a1d67048 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.5", + "version": "2.12.6", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 3b312b7aa..e8b8b1407 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,5 @@ +@import "./relatedNodeTokens.css"; + #root { max-width: 960px; margin: 0 auto; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c2691e341..897b9f669 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1325,6 +1325,7 @@ describe("App, authenticated", () => { label: "Ada West", person_side_code: "our_side", person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", relevance: 0.4, }, ], @@ -1346,6 +1347,7 @@ describe("App, authenticated", () => { label: "Priya Nair", person_side_code: "counterparty", person_side_label: "Counterparty", + affiliation_ambiguous: true, relevance: 0.4, }, { @@ -1362,6 +1364,8 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Organization", ontology_label: "Organization", label: "Demo Corp", + entity_level_code: "company", + entity_level_label: "Company", relevance: 0.2, }, { @@ -1408,6 +1412,7 @@ describe("App, authenticated", () => { label: "Ada West", person_side_code: "our_side", person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", relevance: 0.5, }, ], @@ -1879,14 +1884,14 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); expect(screen.getByText("Related to Ada West").closest(".related-keymen")).toHaveTextContent( - "Priya Nair (Counterparty)", + "Priya Nair, multiple organizations (Counterparty)", ); expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( "Priya Nair (Person)", ); expect( screen.getByRole("button", { - name: "Related nodes for Priya Nair (Counterparty)", + name: "Related nodes for Priya Nair, multiple organizations (Counterparty)", }), ).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); @@ -1902,7 +1907,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "R&R Keyman: Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); expect(screen.getByText("Related to Ada West").closest(".related-keymen")).toHaveTextContent( - "Priya Nair (Counterparty)", + "Priya Nair, multiple organizations (Counterparty)", ); }); @@ -1913,7 +1918,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "R&R person: Priya Nair" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); }); @@ -1947,10 +1952,12 @@ describe("App, authenticated", () => { 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 Demo Corp" })); + await userEvent.click( + screen.getByRole("button", { name: "Related nodes for Demo Corp (Company)" }), + ); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); }); @@ -1981,7 +1988,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "VOC Keyman: Northridge Grid" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); }); @@ -1992,7 +1999,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); }); @@ -2003,7 +2010,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "Keyman affiliation: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); }); @@ -2014,7 +2021,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "Affiliate org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); expect(screen.queryByRole("button", { name: "Affiliate org: Northridge Grid" })).not.toBeInTheDocument(); }); @@ -2026,7 +2033,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "Counterparty org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( - "Ada West (Our side)", + "Ada West, Demo Corp (Our side)", ); expect(screen.queryByRole("button", { name: "Counterparty org: Northridge Grid" })).not.toBeInTheDocument(); }); @@ -2730,7 +2737,7 @@ describe("App, authenticated", () => { ); expect( within(popup as HTMLElement).getByRole("button", { - name: "Related nodes for Priya Nair (Counterparty)", + name: "Related nodes for Priya Nair, multiple organizations (Counterparty)", }), ).toHaveAttribute("aria-current", "true"); const landedRelated = await within(popup as HTMLElement).findByRole("heading", { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e9099931..8f02abef9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,6 +76,8 @@ import { StatusAlert } from "./components/StatusAlert"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedAffiliationNextAction } from "./relatedNodeCaption"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -872,6 +874,11 @@ function KeymanPanel({ const relatedBlock = selectedName ? (

Related to {selectedName}

+ {related !== null && related.some((node) => node.affiliation_ambiguous) ? ( +

+ {relatedAffiliationNextAction(Boolean(keymen && keymen.length > 0))} +

+ ) : null} {related === null ? (

Loading related nodes...

) : related.length === 0 ? ( @@ -891,54 +898,48 @@ function KeymanPanel({ } return (
  • - + onSelectPost(selected.node_id)} + />
  • ); case NODE_PERSON: return (
  • - + />
  • ); case NODE_CORPORATE_ENTITY: return (
  • - + + handleSelectEntity(selected.node_id, selected.label ?? selected.node_id) + } + />
  • ); case NODE_TEAM: return (
  • - + + handleSelectTeam(selected.node_id, selected.label ?? selected.node_id) + } + />
  • ); default: { diff --git a/frontend/src/RelatedNodeChip.stories.tsx b/frontend/src/RelatedNodeChip.stories.tsx new file mode 100644 index 000000000..34c96e05e --- /dev/null +++ b/frontend/src/RelatedNodeChip.stories.tsx @@ -0,0 +1,90 @@ +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; + +/** + * Storybook inventory for the repeating related-node chip. + * + * Host this file with Storybook 10 (Vite + React) when the later + * analysis-run token stack lands. Until then the same four states + * are locked by RelatedNodeChip.test.tsx and relatedNodeCaption.test.ts. + */ +const meta = { + title: "Lineage/RelatedNodeChip", + component: RelatedNodeChip, +}; + +export default meta; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +export const UniqueAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + }, +}; + +export const PluralAffiliations = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + }, +}; + +export const MissingAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + }, +}; + +export const OrganizationAndPost = { + render: () => ( +
      +
    • + undefined} + node={node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + })} + /> +
    • +
    • + undefined} + node={node({ + node_type_code: "node_post", + label: "Linked post", + })} + /> +
    • +
    + ), +}; diff --git a/frontend/src/RelatedNodeChip.test.tsx b/frontend/src/RelatedNodeChip.test.tsx new file mode 100644 index 000000000..305bb3391 --- /dev/null +++ b/frontend/src/RelatedNodeChip.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedNodeChipAccessibleName } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("RelatedNodeChip", () => { + it("keeps the visible plural caption inside the walk name", () => { + const caption = "Priya Nair, multiple organizations (Counterparty)"; + expect(relatedNodeChipAccessibleName(caption, "walk_person")).toBe( + `Related nodes for ${caption}`, + ); + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + }); + + it("opens the post when the buyer clicks a title-only chip", async () => { + const onSelect = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0].node_id).toBe("post-1"); + }); +}); diff --git a/frontend/src/RelatedNodeChip.tsx b/frontend/src/RelatedNodeChip.tsx new file mode 100644 index 000000000..b554b05c6 --- /dev/null +++ b/frontend/src/RelatedNodeChip.tsx @@ -0,0 +1,35 @@ +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, + type RelatedNodeChipAction, +} from "./relatedNodeCaption"; + +/** + * One related-node chip. Use this module for every repeating walk + * control so caption, tokens, and accessible name stay one contract. + */ +export function RelatedNodeChip({ + node, + action, + current, + onSelect, + }: { + node: RelatedNode; + action: RelatedNodeChipAction; + current?: boolean; + onSelect: (node: RelatedNode) => void; + }) { + const caption = relatedNodeCaption(node); + return ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e5d252d9f..7f4027791 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -94,6 +94,10 @@ export interface RelatedNode { label?: string; person_side_code?: string; person_side_label?: string; + affiliation_organization_name?: string; + affiliation_ambiguous?: boolean; + entity_level_code?: string; + entity_level_label?: string; ontology_iri?: string; ontology_label?: string; } diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts new file mode 100644 index 000000000..a5ac60044 --- /dev/null +++ b/frontend/src/relatedNodeCaption.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { RelatedNode } from "./api"; +import { relatedAffiliationNextAction, relatedNodeCaption } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("relatedNodeCaption", () => { + it("names the side and unique org so the next click is a business walk", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + ), + ).toBe("Ada West, Demo Corp (Our side)"); + }); + + it("names a known-plural set so the next action is the Keyman list", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + ), + ).toBe("Priya Nair, multiple organizations (Counterparty)"); + }); + + it("keeps a person with no affiliation side-only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + }), + ), + ).toBe("Priya Nair (Counterparty)"); + }); + + it("keeps a unique org when the side label is missing", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + affiliation_organization_name: "Demo Corp", + ontology_label: "Person", + }), + ), + ).toBe("Ada West, Demo Corp"); + }); + + it("prefers the plural signal when a name is also present", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_organization_name: "Northridge Grid", + affiliation_ambiguous: true, + }), + ), + ).toBe("Priya Nair, multiple organizations (Counterparty)"); + }); + + it("tells the buyer to read the Keyman list when it is already on screen", () => { + expect(relatedAffiliationNextAction(true)).toBe( + "A chip that says multiple organizations is not a missing affiliation. " + + "Read every organization in the Keyman list above, then click the chip " + + "to continue the walk.", + ); + }); + + it("tells the buyer to extract Keymen when the list is empty", () => { + expect(relatedAffiliationNextAction(false)).toBe( + "A chip that says multiple organizations is not a missing affiliation. " + + "Extract Keymen to list every organization, then click the chip to " + + "continue the walk.", + ); + }); + + it("uses the entity-level label on organization chips", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + }), + ), + ).toBe("Demo Corp (Company)"); + }); + + it("shows the post title only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_post", + label: "Linked post", + ontology_label: "Post", + }), + ), + ).toBe("Linked post"); + }); +}); diff --git a/frontend/src/relatedNodeCaption.ts b/frontend/src/relatedNodeCaption.ts new file mode 100644 index 000000000..2da8c35f6 --- /dev/null +++ b/frontend/src/relatedNodeCaption.ts @@ -0,0 +1,120 @@ +import type { RelatedNode } from "./api"; + +const NODE_PERSON = "node_person"; +const NODE_POST = "node_post"; +const NODE_CORPORATE_ENTITY = "node_corporate_entity"; +const NODE_TEAM = "node_team"; + +type RelatedNodeKind = + | typeof NODE_PERSON + | typeof NODE_POST + | typeof NODE_CORPORATE_ENTITY + | typeof NODE_TEAM; + +function isRelatedNodeKind(code: string): code is RelatedNodeKind { + return ( + code === NODE_PERSON || + code === NODE_POST || + code === NODE_CORPORATE_ENTITY || + code === NODE_TEAM + ); +} + +/** + * Decision-facing label for a related-node chip. + * + * Person chips use the authorized side label and, when exactly one + * organization identity is known, that organization. A known-plural + * set uses "multiple organizations" even if a name is also present + * so a stale payload cannot invent a primary. That is not the same + * as a missing affiliation. + * A unique org without a side still names the org so a missing side + * cannot revive the ontology-class caption. Organization chips use + * the entity-level label. Post chips are the title only. + */ +export function relatedNodeCaption(node: RelatedNode): string { + const name = node.label?.trim() || node.node_id; + const kind = node.node_type_code; + if (!isRelatedNodeKind(kind)) { + return `${name} (${node.ontology_label ?? kind})`; + } + switch (kind) { + case NODE_PERSON: { + const side = node.person_side_label?.trim() || node.person_side_code?.trim(); + const org = node.affiliation_organization_name?.trim(); + const context = node.affiliation_ambiguous ? "multiple organizations" : org || ""; + if (side && context) { + return `${name}, ${context} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + if (context) { + return `${name}, ${context}`; + } + return `${name} (${node.ontology_label ?? kind})`; + } + case NODE_CORPORATE_ENTITY: { + const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); + if (level) { + return `${name} (${level})`; + } + return `${name} (${node.ontology_label ?? kind})`; + } + case NODE_POST: + return name; + case NODE_TEAM: + return name; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +export type RelatedNodeChipAction = "walk_person" | "walk_entity" | "walk_team" | "open_post"; + +/** + * Accessible name for a related-node chip. + * + * The visible caption is contained in the name (WCAG 2.2 Success + * Criterion 2.5.3). Walk chips continue the graph. Post chips open + * the evidence body. + */ +export function relatedNodeChipAccessibleName( + caption: string, + action: RelatedNodeChipAction, +): string { + switch (action) { + case "walk_person": + case "walk_entity": + case "walk_team": + return `Related nodes for ${caption}`; + case "open_post": + return `Open related post: ${caption}`; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +/** + * Next action when a related-node chip marks a known-plural affiliation + * set. The Keyman list is the full N:N surface; the chip click continues + * the walk and must not be mistaken for "this person has no organization." + */ +export function relatedAffiliationNextAction(hasKeymanList: boolean): string { + if (hasKeymanList) { + return ( + "A chip that says multiple organizations is not a missing affiliation. " + + "Read every organization in the Keyman list above, then click the chip " + + "to continue the walk." + ); + } + return ( + "A chip that says multiple organizations is not a missing affiliation. " + + "Extract Keymen to list every organization, then click the chip to " + + "continue the walk." + ); +} diff --git a/frontend/src/relatedNodeTokens.css b/frontend/src/relatedNodeTokens.css new file mode 100644 index 000000000..be299dead --- /dev/null +++ b/frontend/src/relatedNodeTokens.css @@ -0,0 +1,32 @@ +:root { + --related-node-chip-font: inherit; + --related-node-chip-color: inherit; + --related-node-chip-padding: 0; + --related-node-chip-background: none; + --related-node-chip-border: none; + --related-node-chip-text-align: left; + --related-node-chip-cursor: pointer; + --related-node-hint-font-size: 0.9rem; + --related-node-hint-margin-block-end: 0.5rem; + --related-node-hint-color: inherit; +} + +.related-node-chip { + background: var(--related-node-chip-background); + border: var(--related-node-chip-border); + padding: var(--related-node-chip-padding); + color: var(--related-node-chip-color); + cursor: var(--related-node-chip-cursor); + font: var(--related-node-chip-font); + text-align: var(--related-node-chip-text-align); +} + +.related-node-chip:hover { + text-decoration: underline; +} + +.related-affiliation-hint { + margin: 0 0 var(--related-node-hint-margin-block-end); + font-size: var(--related-node-hint-font-size); + color: var(--related-node-hint-color); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index d2f41f222..95330cb50 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.5" +__version__ = "2.12.6" diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index acba7b225..c2a46b63c 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -159,9 +159,10 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - """Return ``result['url']`` when it is a real-world footprint of ``organization_name``. Search engines echo the query in result titles, so "any hit" is not - corroboration. A result counts only when a distinctive name token + corroboration. A result counts only when every distinctive name token appears in the host or snippet, and the host is not itself a search - page. Missing or empty URLs are not evidence. + page. This prevents a generic word such as ``fictitious`` from + corroborating an unrelated page. Missing or empty URLs are not evidence. """ url = result.get("url") if not isinstance(url, str) or not url.strip(): @@ -177,6 +178,6 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - if not tokens: return None haystack = f"{host} {result.get('content') or ''}".lower() - if any(token in haystack for token in tokens): + if all(token in haystack for token in tokens): return url return None diff --git a/pyproject.toml b/pyproject.toml index 8203fc63d..892d8f0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.5" +version = "2.12.6" 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_related_node_affiliation_ambiguity.py b/tests/test_related_node_affiliation_ambiguity.py new file mode 100644 index 000000000..cb9d3fea3 --- /dev/null +++ b/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,194 @@ +"""Identity-rule unit tests for compact related-node affiliation.""" + +from __future__ import annotations + +from typing import Any + +from backend.app.knowledge_graph import compact_affiliation_summaries + +_PERSON_ID = "11111111-1111-4111-8111-111111111111" +_CATALOG_ID = "22222222-2222-4222-8222-222222222222" +_SECOND_CATALOG_ID = "33333333-3333-4333-8333-333333333333" + + +def _summarize(affiliations: list[dict[str, Any]]): + rows = [ + { + "person_id": _PERSON_ID, + "affiliated_organization_name": None, + "affiliated_corporate_entity_id": None, + "catalog_entity_name": None, + **row, + } + for row in affiliations + ] + return compact_affiliation_summaries(rows).get(_PERSON_ID) + + +def _payload(summary) -> dict[str, Any]: + """Mirror hydrate: emit a name or the plural flag, never both.""" + if summary is None: + return {} + item: dict[str, Any] = {} + if summary.display_name: + item["affiliation_organization_name"] = summary.display_name + if summary.ambiguous: + item["affiliation_ambiguous"] = True + return item + + +def test_related_person_exposes_one_unambiguous_affiliation() -> None: + """A single known affiliation is safe to use as compact display context.""" + summary = _summarize([{"affiliated_organization_name": "Northridge Grid"}]) + assert summary is not None + assert summary.display_name == "Northridge Grid" + assert summary.ambiguous is False + assert _payload(summary) == {"affiliation_organization_name": "Northridge Grid"} + + +def test_related_person_marks_plural_affiliations_ambiguous() -> None: + """A known-plural set is not a missing affiliation and never invents a primary.""" + summary = _summarize( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert summary is not None + assert summary.display_name is None + assert summary.ambiguous is True + assert _payload(summary) == {"affiliation_ambiguous": True} + + +def test_related_person_omits_blank_affiliation() -> None: + """Whitespace-only extraction strings are missing evidence, not a name.""" + summary = _summarize([{"affiliated_organization_name": " "}]) + assert summary is None + assert _payload(summary) == {} + + +def test_related_person_uses_catalog_name_for_one_resolved_org() -> None: + """A resolved catalog org supplies entity_name, not the raw extraction.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + } + ] + ) + assert summary is not None + assert summary.display_name == "Demo Corp" + assert summary.ambiguous is False + assert _payload(summary) == {"affiliation_organization_name": "Demo Corp"} + + +def test_related_person_collapses_aliases_of_one_catalog_org() -> None: + """Two raw strings for the same corporate_entity_id are one identity.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + ] + ) + assert summary is not None + assert summary.display_name == "Demo Corp" + assert summary.ambiguous is False + + +def test_related_person_collapses_unresolved_name_matching_catalog() -> None: + """An unresolved alias of the catalog label is not a second org.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "demo corp"}, + ] + ) + assert summary is not None + assert summary.display_name == "Demo Corp" + assert summary.ambiguous is False + + +def test_related_person_omits_resolved_plus_distinct_unresolved() -> None: + """A catalog org plus a different unresolved name stays ambiguous.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert summary is not None + assert summary.display_name is None + assert summary.ambiguous is True + assert _payload(summary) == {"affiliation_ambiguous": True} + + +def test_related_person_marks_two_distinct_catalog_orgs_ambiguous() -> None: + """Two resolved catalog orgs must not collapse into a guessed primary.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + { + "affiliated_organization_name": "Northridge Holdings", + "affiliated_corporate_entity_id": _SECOND_CATALOG_ID, + "catalog_entity_name": "Northridge Holdings", + }, + ] + ) + assert summary is not None + assert summary.display_name is None + assert summary.ambiguous is True + assert _payload(summary) == {"affiliation_ambiguous": True} + + +def test_related_person_keeps_nameless_catalog_identity_side_only() -> None: + """An orphaned catalog id with no name is not a guessed primary or a plural set.""" + summary = _summarize( + [ + { + "affiliated_organization_name": "", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "", + } + ] + ) + assert summary is not None + assert summary.identity_count == 1 + assert summary.display_name is None + assert summary.ambiguous is False + assert _payload(summary) == {} + + +def test_related_person_collapses_unresolved_names_that_differ_only_by_case() -> None: + """Letter-case variants of one unresolved name are one identity.""" + summary = _summarize( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "northridge grid"}, + ] + ) + assert summary is not None + assert summary.display_name == "Northridge Grid" + assert summary.ambiguous is False + assert _payload(summary) == {"affiliation_organization_name": "Northridge Grid"} diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f515d5d25..ea6757070 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -122,6 +122,21 @@ def test_org_token_in_result_host_is_corroboration() -> None: ) +def test_generic_token_in_result_is_not_enough_for_a_compound_name() -> None: + """A result mentioning only common qualifiers is not identity evidence.""" + assert ( + corroborating_evidence_url( + "Zzqxvthorp Fictitious Nonexistent Org", + { + "url": "https://example.test/search-result", + "title": "Fictitious projects", + "content": "A list of fictitious and nonexistent examples.", + }, + ) + is None + ) + + def test_legal_suffix_alone_is_not_corroboration() -> None: """'Corp' is in almost every corporate host; it is not evidence.""" assert ( diff --git a/uv.lock b/uv.lock index 1575f180a..1fcce8ad6 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.3" +version = "2.12.6" source = { virtual = "." } dependencies = [ { name = "certifi" }, From eb6822ef81f6d0389e6687bb32110ef3fab9a78a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:55:08 +0900 Subject: [PATCH 4/5] fix: keep shared post eligibility importable --- backend/app/post_eligibility.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 41473d9da..0ae2ba122 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,4 +1,9 @@ -"""Shared source-post eligibility SQL for buyer evidence reads.""" +"""Shared source-post eligibility SQL for buyer evidence reads. + +Keep this module importable as a first-class backend dependency: the knowledge +graph and every post-scoped read use the same predicate so a graph projection +cannot bypass the buyer visibility boundary. +""" SOURCE_CONTEXT_COLUMNS = ( "source_author_code", From 172898e367cd212811d8e6b71b3ffcf98ce86236 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:38:55 +0900 Subject: [PATCH 5/5] docs: keep ADR numbers unique --- ...iness-captions.md => 0105-related-node-business-captions.md} | 2 +- docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md | 2 +- docs/lineage-bi-research-notes.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename docs/adr/{0103-related-node-business-captions.md => 0105-related-node-business-captions.md} (95%) diff --git a/docs/adr/0103-related-node-business-captions.md b/docs/adr/0105-related-node-business-captions.md similarity index 95% rename from docs/adr/0103-related-node-business-captions.md rename to docs/adr/0105-related-node-business-captions.md index 6b9458349..719df277c 100644 --- a/docs/adr/0103-related-node-business-captions.md +++ b/docs/adr/0105-related-node-business-captions.md @@ -1,4 +1,4 @@ -# ADR 0103: Related-node chips use business context, not ontology class +# ADR 0105: Related-node chips use business context, not ontology class - Status: Accepted - Date: 2026-08-20 diff --git a/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md index 8e82f2785..980869024 100644 --- a/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md +++ b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md @@ -1,6 +1,6 @@ # Related-node affiliation references -APA 7th sources for [ADR 0103](../adr/0103-related-node-business-captions.md). +APA 7th sources for [ADR 0105](../adr/0105-related-node-business-captions.md). Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership multiple classification (MMMC) models. *Statistical Modelling, 1*(2), diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index b38bc7b7a..62ab7c99c 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -12,7 +12,7 @@ people: the UI uses an authorized unique affiliation only when one identity remains and otherwise says `multiple organizations`. The full N:N evidence stays on the Keyman surface, and the panel gives the buyer the next action. The implementation and APA 7th sources are recorded in -[`docs/adr/0103-related-node-business-captions.md`](adr/0103-related-node-business-captions.md) +[`docs/adr/0105-related-node-business-captions.md`](adr/0105-related-node-business-captions.md) and [`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). ## The problem this is answering