diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cd8eb3d59..e04783514 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -339,6 +339,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 56432a85e..2af7a1bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ All notable changes to this project are documented here. Format follows ### 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. - Renamed "Buyer" terminology to reader/workspace naming across the frontend shell, backend evidence helpers, and living docs (ADR 0119). Historical ADRs and changelog entries retain their point-in-time wording. diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index cb4e3804f..e6c522700 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 @@ -449,6 +451,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]], @@ -457,6 +518,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] = [] @@ -482,6 +545,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 # Safe SQL: the eligibility predicate is an immutable schema fragment; post ids are bound. @@ -496,7 +573,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 {} @@ -511,6 +589,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: @@ -525,12 +606,21 @@ 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"] item["post_body_excerpt"] = posts[node_id]["post_body_excerpt"] item["post_body_truncated"] = posts[node_id]["post_body_truncated"] 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/app/post_eligibility.py b/backend/app/post_eligibility.py index 48e3da9db..0ae2ba122 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,4 +1,9 @@ -"""Shared source-post eligibility SQL for reader-facing 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", diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 622741583..48d776ef7 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -2195,6 +2195,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}"}, @@ -2214,6 +2215,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" @@ -2311,8 +2319,10 @@ def test_related_keymen_role_history_is_empty_without_any_role_classification( 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 includes person-side labels while excluding private and + hidden related posts. """ response = client.get( f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", @@ -2326,6 +2336,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 @@ -3180,6 +3192,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 @@ -3199,8 +3212,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() @@ -3220,7 +3233,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 @@ -3230,7 +3243,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/docs/adr/0106-related-node-business-captions.md b/docs/adr/0106-related-node-business-captions.md new file mode 100644 index 000000000..726effc20 --- /dev/null +++ b/docs/adr/0106-related-node-business-captions.md @@ -0,0 +1,36 @@ +# ADR 0106: 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..a0fe2fd8f --- /dev/null +++ b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md @@ -0,0 +1,23 @@ +# Related-node affiliation references + +APA 7th sources for [ADR 0106](../adr/0106-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 1f1c53a52..05f3f0304 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -5,6 +5,16 @@ in [ADR 0064](adr/0064-lineage-evidence-and-tree-assembly.md), [ADR 0062](adr/00 and the existing channel-specific ADRs; update this file as literature and validation evidence changes, not as an untracked architecture decision. +## Related-node business captions + +ADR 0103 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/0106-related-node-business-captions.md`](adr/0106-related-node-business-captions.md) +and [`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). + ## The problem this is answering Given a pile of short, timestamped records that are only loosely grouped @@ -261,10 +271,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 +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 adaptive reasoning-effort allocation stays centralized with the -adjudication channel. Proven for real during development against +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. @@ -279,7 +300,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 0103) 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/src/App.css b/frontend/src/App.css index 3a40c1cfa..d62552681 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,5 @@ +@import "./relatedNodeTokens.css"; + /* App-level Shell Layout */ .app-shell { display: flex; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2991c04c7..66b7c4807 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1351,6 +1351,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, }, ], @@ -1388,6 +1389,7 @@ describe("App, authenticated", () => { label: "Priya Nair", person_side_code: "counterparty", person_side_label: "Counterparty", + affiliation_ambiguous: true, relevance: 0.4, }, { @@ -1404,6 +1406,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, }, { @@ -1450,6 +1454,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, }, ], @@ -2468,7 +2473,7 @@ 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)", @@ -2483,7 +2488,7 @@ describe("App, authenticated", () => { expect(historyItems[1]).toHaveTextContent("account lead"); expect( screen.getByRole("button", { - name: "Related nodes for Priya Nair (Counterparty)", + name: "Related nodes for Priya Nair, multiple organizations (Counterparty)", }), ).toBeInTheDocument(); const relatedPosts = screen.getByRole("heading", { name: "Related posts", level: 3 }).closest( @@ -2506,7 +2511,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)", ); const relatedPosts = screen.getByRole("list", { name: "Related posts: Ada West" }); expect(within(relatedPosts).getByRole("button", { name: "Open related post: Linked post" })).toBeInTheDocument(); @@ -2523,7 +2528,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)", ); }); @@ -2557,10 +2562,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)", ); }); @@ -2591,7 +2598,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)", ); }); @@ -2602,7 +2609,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)", ); }); @@ -2613,7 +2620,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)", ); }); @@ -2624,7 +2631,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(); }); @@ -2636,7 +2643,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(); }); @@ -3235,7 +3242,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 d22e2d821..803f1744b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,6 +94,8 @@ import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedAffiliationNextAction } from "./relatedNodeCaption"; import { isSupportedLocale, LOCALE_LABELS, @@ -1099,6 +1101,11 @@ function KeymanPanel({ ) : null} + {related !== null && related.some((node) => node.affiliation_ambiguous) ? ( +

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

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

{t("Loading related nodes...")}

) : related.length === 0 ? ( @@ -1140,42 +1147,38 @@ function KeymanPanel({ 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: () => ( + + ), +}; 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 54b22ad96..0334d74c6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -175,6 +175,10 @@ export interface RelatedNode { post_body_truncated?: boolean; 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/i18n.test.ts b/frontend/src/i18n.test.ts index fb917de3e..b0af0ff63 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -30,6 +30,9 @@ describe("i18n", () => { "Close", "Post body", "Summary", + "multiple organizations", + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.", + "Multiple organizations are recorded. Extract Keymen to list every organization, then continue the walk.", "Calendar", "Board", "Search", diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ed235d1c0..13ae4c82e 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -196,6 +196,11 @@ const TRANSLATIONS: Partial>> = { "Event Lineage": "이벤트 계보", "Lineage evidence": "계보 근거", "Related posts": "관련 글", + "multiple organizations": "여러 조직", + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.": + "여러 조직이 기록되어 있습니다. 위 Keyman 목록에서 모든 조직을 확인한 뒤 탐색을 계속하세요.", + "Multiple organizations are recorded. Extract Keymen to list every organization, then continue the walk.": + "여러 조직이 기록되어 있습니다. 모든 조직을 표시하려면 Keyman을 추출한 뒤 탐색을 계속하세요.", "Open navigation": "탐색 메뉴 열기", "Skip to main content": "본문으로 바로가기", "Lineage legend": "계보 범례", @@ -560,6 +565,11 @@ const TRANSLATIONS: Partial>> = { "Event Lineage": "事件谱系", "Lineage evidence": "谱系证据", "Related posts": "相关文章", + "multiple organizations": "多个组织", + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.": + "已记录多个组织。请先查看上方关键联系人列表中的所有组织,然后继续浏览。", + "Multiple organizations are recorded. Extract Keymen to list every organization, then continue the walk.": + "已记录多个组织。请先提取关键联系人以列出所有组织,然后继续浏览。", "Related to": "相关对象:", "Graph relation": "图谱关联", "Loading related nodes...": "正在加载相关节点...", @@ -948,6 +958,11 @@ const TRANSLATIONS: Partial>> = { "Event Lineage": "イベント系譜", "Lineage evidence": "系譜の証拠", "Related posts": "関連する投稿", + "multiple organizations": "複数の組織", + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.": + "複数の組織が記録されています。上のキーパーソン一覧ですべての組織を確認してから、探索を続けてください。", + "Multiple organizations are recorded. Extract Keymen to list every organization, then continue the walk.": + "複数の組織が記録されています。すべての組織を表示するにはキーパーソンを抽出してから、探索を続けてください。", "Related to": "関連対象:", "Graph relation": "グラフ関連", "Loading related nodes...": "関連ノードを読み込んでいます...", @@ -1312,6 +1327,11 @@ const TRANSLATIONS: Partial>> = { "Event Lineage": "Dòng sự kiện", "Lineage evidence": "Bằng chứng dòng sự kiện", "Related posts": "Bài viết liên quan", + "multiple organizations": "nhiều tổ chức", + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.": + "Đã ghi nhận nhiều tổ chức. Hãy đọc tất cả tổ chức trong danh sách nhân sự chủ chốt ở trên, rồi tiếp tục khám phá.", + "Multiple organizations are recorded. Extract Keymen to list every organization, then continue the walk.": + "Đã ghi nhận nhiều tổ chức. Hãy trích xuất nhân sự chủ chốt để liệt kê tất cả tổ chức, rồi tiếp tục khám phá.", "Related to": "Liên quan đến:", "Graph relation": "Liên kết đồ thị", "Loading related nodes...": "Đang tải các nút liên quan...", diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts new file mode 100644 index 000000000..ec3a702a3 --- /dev/null +++ b/frontend/src/relatedNodeCaption.test.ts @@ -0,0 +1,116 @@ +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( + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.", + ); + }); + + it("tells the buyer to extract Keymen when the list is empty", () => { + expect(relatedAffiliationNextAction(false)).toBe( + "Multiple organizations are recorded. Extract Keymen to list every organization, then 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..73b0c5a4a --- /dev/null +++ b/frontend/src/relatedNodeCaption.ts @@ -0,0 +1,117 @@ +import type { RelatedNode } from "./api"; +import { t, tf } from "./i18n"; + +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 ? t("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 tf("Related nodes for {name}", { name: caption }); + case "open_post": + return tf("Open related post: {label}", { label: 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 t( + "Multiple organizations are recorded. Read every organization in the Keyman list above, then continue the walk.", + ); + } + return t( + "Multiple organizations are recorded. Extract Keymen to list every organization, then 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/relation_verification.py b/lineageweave/relation_verification.py index e670f2262..527a9ba01 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -147,6 +147,11 @@ def corroborating_evidence_url( ) -> str | None: """Return a safe result URL when all distinctive name tokens are present. + Search engines echo the query in result titles, so "any hit" is not + 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. This prevents a generic word such as ``fictitious`` from + corroborating an unrelated page. Missing or empty URLs are not evidence. Search engines echo query text in titles, so only the result host and snippet are considered. A result must contain every distinctive token; missing, search-host, non-HTTP, and title-only URLs are not evidence. diff --git a/tests/test_related_node_affiliation_ambiguity.py b/tests/test_related_node_affiliation_ambiguity.py new file mode 100644 index 000000000..36bb47997 --- /dev/null +++ b/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,195 @@ +"""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]]) -> Any: + """Build one compact affiliation result from synthetic database rows.""" + 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 e68ef81c9..48db4cecb 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -232,6 +232,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_short_name_token_inside_another_word_is_not_corroboration() -> None: """A search snippet must contain the organization token as a word.""" assert (