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 (