diff --git a/AGENTS.md b/AGENTS.md index 735988f09..8c6cd5519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,11 @@ must set `available = False` and make their channel dropped + renormalized (`reconstruct.active_weights`), never silently return a placeholder score, invented Keyman, guessed relationship, fabricated summary/chat, or invented commitment. A missing signal and a -confidently-negative signal are different things. Keyman extraction, +confidently-negative signal are different things. Related-node person +chips follow the same rule: a known-plural affiliation set emits +`affiliation_ambiguous` and the caption `multiple organizations`; it +must not look like a person with no affiliation, and it must not +invent a primary organization. Keyman extraction, entity-relationship classification, post summary, in-popup chat, and commitment derivation go through contextual-orchestrator the same way adjudication does -- never a raw LLM API. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95d..f090d28b2 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 are never +collapsed into a guessed primary; the chip says +`Priya Nair, multiple organizations (Counterparty)` after +`make seed` so the buyer opens the Keyman panel for the full +list. A person with no affiliation stays side-only. A resolved +catalog org supplies `entity_name`; unresolved aliases of that +same org, including letter-case variants, 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..e02f801ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,63 @@ 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.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 panel for the full list. The + chip still never names a guessed primary. 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 person chips include the affiliation organization when + exactly one distinct identity is known. After `make seed`, walking + from Demo Corp shows "Ada West, Demo Corp (Our side)". Priya Nair + has two unresolved orgs (Northridge Grid and Northridge Holdings), + so the chip stays "Priya Nair (Counterparty)" -- a second org is + never collapsed into an invented primary. When the one identity is + a resolved `corporate_entity`, the catalog `entity_name` is shown + rather than the raw extraction string. A person with no affiliation + keeps the side-only caption. + +## [0.74.0] - 2026-08-16 + +### Changed + +- Related-node post chips show the post title only, not + "Linked post (Post)". Person and org chips already use business + labels; the ontology class on a post title was noise. + +## [0.73.0] - 2026-08-16 + +### Changed + +- Related-node organization chips use the `entity_level` lookup + label instead of the ontology class. After `make seed`, walking + from Ada West shows "Demo Corp (Company)" -- not "Demo Corp + (Organization)". The payload now carries `entity_level_label` + from `common_lookup_value`. Missing lookups fall back to the + code. The same caption is the button accessible name. + +## [0.72.0] - 2026-08-16 + +### Changed + +- Related-node person chips use the `person_side` lookup label instead + of the ontology class. After `make seed`, walking from Ada West + shows "Priya Nair (Counterparty)" and walking from Demo Corp shows + "Ada West (Our side)" -- not "Ada West (Person)". The payload + already had `person_side_code`; it now also carries + `person_side_label` from `common_lookup_value`. The same caption is + the button accessible name. + ## [0.71.0] - 2026-08-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..866b0a87b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ +# CLAUDE.md + +Agent conventions for this repository live in [AGENTS.md](AGENTS.md). +Read that file before changing fusion, channels, fixtures, or UI +caption contracts. + +Related-node person chips: unique catalog identity shows +`entity_name`; a known-plural set emits `affiliation_ambiguous` and +the caption `multiple organizations`; never invent a primary +organization. See [ADR 0014](docs/adr/0014-related-node-affiliation-plurality.md). 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/backend/tests/test_related_node_affiliation_ambiguity.py b/backend/tests/test_related_node_affiliation_ambiguity.py new file mode 100644 index 000000000..f48f68382 --- /dev/null +++ b/backend/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,159 @@ +"""Regression tests for related-node affiliation display authority.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from backend.app.knowledge_graph import hydrate_related_nodes +from lineageweave.knowledge_graph import NODE_PERSON, node_key + + +_PERSON_ID = "11111111-1111-4111-8111-111111111111" +_CATALOG_ID = "22222222-2222-4222-8222-222222222222" + + +class _FakeConnection: + """Return the minimum query results needed by ``hydrate_related_nodes``.""" + + def __init__(self, affiliations: list[dict[str, Any]]) -> None: + self._affiliations = affiliations + + async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: + if "from cataloged_person" in query: + return [ + { + "person_id": _PERSON_ID, + "person_name": "Priya Nair", + "person_side_code": "counterparty", + } + ] + if "from person_affiliation" in query: + return [ + { + "person_id": _PERSON_ID, + "affiliated_organization_name": row.get("affiliated_organization_name"), + "affiliated_corporate_entity_id": row.get("affiliated_corporate_entity_id"), + "catalog_entity_name": row.get("catalog_entity_name"), + } + for row in self._affiliations + ] + if "from common_lookup_value" in query: + return [{"lookup_code": "counterparty", "lookup_label": "Counterparty"}] + raise AssertionError(f"unexpected query: {query}") + + +def _hydrate(affiliations: list[dict[str, Any]]) -> dict[str, Any]: + payload = asyncio.run( + hydrate_related_nodes( + _FakeConnection(affiliations), # type: ignore[arg-type] + [(node_key(NODE_PERSON, _PERSON_ID), 0.8)], + ) + ) + assert len(payload) == 1 + return payload[0] + + +def test_related_person_exposes_one_unambiguous_affiliation() -> None: + """A single known affiliation is safe to use as compact display context.""" + node = _hydrate([{"affiliated_organization_name": "Northridge Grid"}]) + assert node["affiliation_organization_name"] == "Northridge Grid" + assert "affiliation_ambiguous" not in node + + +def test_related_person_marks_ambiguous_when_multiple_are_known() -> None: + """Multiple affiliations must not be collapsed into an invented primary one.""" + node = _hydrate( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert "affiliation_organization_name" not in node + assert node["affiliation_ambiguous"] is True + + +def test_related_person_omits_blank_affiliation() -> None: + """Whitespace-only extraction strings are missing evidence, not a name.""" + node = _hydrate([{"affiliated_organization_name": " "}]) + assert "affiliation_organization_name" not in node + assert "affiliation_ambiguous" not in node + + +def test_related_person_uses_catalog_name_for_one_resolved_org() -> None: + """A resolved catalog org supplies entity_name, not the raw extraction.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + } + ] + ) + assert node["affiliation_organization_name"] == "Demo Corp" + assert "affiliation_ambiguous" not in node + + +def test_related_person_collapses_aliases_of_one_catalog_org() -> None: + """Two raw strings for the same corporate_entity_id are one identity.""" + node = _hydrate( + [ + { + "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 node["affiliation_organization_name"] == "Demo Corp" + assert "affiliation_ambiguous" not in node + + +def test_related_person_collapses_unresolved_name_matching_catalog() -> None: + """An unresolved alias of the catalog label is not a second org.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "demo corp"}, + ] + ) + assert node["affiliation_organization_name"] == "Demo Corp" + assert "affiliation_ambiguous" not in node + + +def test_related_person_collapses_unresolved_names_that_differ_only_by_case() -> None: + """Two unresolved strings that casefold-match are one identity, not a plural set.""" + node = _hydrate( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "northridge grid"}, + ] + ) + assert node["affiliation_organization_name"] == "Northridge Grid" + assert "affiliation_ambiguous" not in node + + +def test_related_person_marks_resolved_plus_distinct_unresolved_ambiguous() -> None: + """A catalog org plus a different unresolved name stays ambiguous.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert "affiliation_organization_name" not in node + assert node["affiliation_ambiguous"] is True diff --git a/docs/adr/0014-related-node-affiliation-plurality.md b/docs/adr/0014-related-node-affiliation-plurality.md new file mode 100644 index 000000000..43c0d813e --- /dev/null +++ b/docs/adr/0014-related-node-affiliation-plurality.md @@ -0,0 +1,60 @@ +# ADR 0014 — Related-node chips distinguish plural affiliations from missing ones + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +Related-node person chips may carry at most one compact organization +name. The `person_affiliation` schema is multiple-membership: a person +can belong to several organizations at once, and the table has no +`primary` column. Collapsing that set by sort order invents a primary +the buyer cannot authorize. + +v0.75.0 therefore omitted the organization whenever more than one +distinct identity remained. That avoided a false primary, but it also +made a known-plural set look identical to a person with no affiliation. +A missing signal and a confidently-plural signal are different things +(see `AGENTS.md`). The buyer could not tell whether to open the Keyman +panel for a full affiliation list. + +Multiple-membership models treat those memberships as simultaneous, not +as a ranked primary plus leftovers (Browne et al., 2001). ISO 9241-110 +requires dialogue that does not hide the state the user needs for the +next action (International Organization for Standardization, 2020). + +## Decision + +Hydrate emits: + +- `affiliation_organization_name` only when exactly one distinct + organization identity remains after catalog-id and casefold-alias + collapse; +- `affiliation_ambiguous: true` when more than one identity remains, + with no organization name. + +The chip caption for a plural set is +`{name}, multiple organizations ({side})`. That name is not an +organization. The next action is to open the Keyman / affiliate +surface, which already lists every authorized affiliation. + +Unresolved extraction strings that differ only by letter case count as +one identity. Distinct unresolved names, or a catalog org plus a +different unresolved name, stay plural. + +## Consequences + +After `make seed`, Priya Nair (Northridge Grid and Northridge Holdings) +reads `Priya Nair, multiple organizations (Counterparty)`. Ada West +(Demo Corp only) still reads `Ada West, Demo Corp (Our side)`. A person +with no affiliation row stays side-only. + +## References + +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 + +International Organization for Standardization. (2020). *Ergonomics of +human-system interaction — Part 110: Interaction principles* +(ISO 9241-110:2020). https://www.iso.org/standard/75258.html diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index acd55620b..82ad5c734 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -17,7 +17,11 @@ Psychometrics Platform) answers. TEPP estimates calibrated latent-construct scores and trajectories from evidence-grounded text under an explicit multilevel/multiple-membership model (its own literature register, `docs/research/standards-and-literature.md`, is built on Raudenbush and -Bryk (2002) and Browne et al. (2001) -- see below). LineageWeave's channel +Bryk (2002) and Browne et al. (2001) -- see below). Related-node person +chips apply that multiple-membership rule at the display boundary: a +known-plural affiliation set is labeled `multiple organizations` rather +than collapsed into a guessed primary or hidden as if no affiliation +existed (ISO 9241-110; Browne et al., 2001). LineageWeave's channel scores are a much weaker claim: "this pair of records plausibly continues one another," produced by fast, cheap heuristics plus an optional LLM judgment, with no calibration or uncertainty quantification. TEPP's own @@ -221,6 +225,8 @@ Gildea, D., & Jurafsky, D. (2002). Automatic labeling of semantic roles. *Comput Hearst, M. A. (1997). TextTiling: Segmenting text into multi-paragraph subtopic passages. *Computational Linguistics*, *23*(1), 33-64. +International Organization for Standardization. (2020). *Ergonomics of human-system interaction — Part 110: Interaction principles* (ISO 9241-110:2020). https://www.iso.org/standard/75258.html + Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S., & Kiela, D. (2020). Retrieval-augmented generation for knowledge-intensive NLP tasks. In H. Larochelle, M. Ranzato, R. Hadsell, M. F. Balcan, & H. Lin (Eds.), *Advances in Neural Information Processing Systems* (Vol. 33, pp. 9459-9474). Curran Associates. Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z., & Wei, F. (2023). TrOCR: Transformer-based optical character recognition with pre-trained models. *Proceedings of the AAAI Conference on Artificial Intelligence*, *37*(11), 13094-13102. https://doi.org/10.1609/aaai.v37i11.26538 diff --git a/frontend/package.json b/frontend/package.json index 9c84795d9..9acf4850d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.71.0", + "version": "0.76.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 76cf3665c..85162421b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -263,10 +263,10 @@ .keyman-select { background: none; border: none; - padding: 0; + padding: var(--chip-action-padding); color: inherit; cursor: pointer; - font: inherit; + font: var(--chip-action-font); text-align: left; } @@ -330,7 +330,7 @@ } .related-keymen { - margin-top: 0.75rem; + margin-top: var(--space-related); } .ticket-list { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 415e1419f..cf279826d 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,23 @@ 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( + "Priya Nair, Northridge Grid (Counterparty)", + ); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Priya Nair (Counterparty)", + ); + const relatedPanel = screen.getByText("Related to Ada West").closest(".related-keymen"); + 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 +1009,11 @@ 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 +1022,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 +1061,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 +1072,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 +1083,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 +1094,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 +1106,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..f9e67085f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -469,6 +469,45 @@ const NODE_PERSON = "node_person"; const NODE_POST = "node_post"; const NODE_CORPORATE_ENTITY = "node_corporate_entity"; +/** + * Build the related-node chip caption the buyer reads and activates. + * + * Person chips add a unique organization, or "multiple organizations" + * when the payload marks a known-plural set, so a missing affiliation + * and an ambiguous set are not the same next action. Organization + * chips use the entity-level label. Post chips stay title-only. + */ +function relatedNodeCaption(node: RelatedNode): string { + const name = node.label ?? node.node_id; + const nodeType = node.node_type_code; + if (nodeType === NODE_PERSON) { + const side = node.person_side_label?.trim() || node.person_side_code?.trim(); + const org = node.affiliation_organization_name?.trim(); + const context = org || (node.affiliation_ambiguous ? "multiple organizations" : ""); + if (side && context) { + return `${name}, ${context} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + if (context) { + return `${name}, ${context}`; + } + return name; + } + if (nodeType === NODE_CORPORATE_ENTITY) { + const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); + if (level) { + return `${name} (${level})`; + } + return name; + } + if (nodeType === NODE_POST) { + return name; + } + return `${name} (${node.ontology_label ?? node.node_type_code})`; +} + const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -677,7 +716,7 @@ function KeymanPanel({ ) : (