diff --git a/AGENTS.md b/AGENTS.md index c790995c1..af4f8ede6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,9 @@ a click opens that post. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a -floating Node version): +floating Node version). Related-node walk chips live in +`RelatedNodeChip` (ADR 0014). Caption and accessible name stay in +`relatedNodeCaption.ts`. Do not invent a primary organization. ```bash cd frontend && pnpm install diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb1..c6c271b2f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -326,7 +326,19 @@ 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 omitted, +never collapsed into a guessed primary (`Priya Nair (Counterparty)` +after `make seed`). A resolved catalog org supplies `entity_name`; +unresolved aliases of that same org collapse into it. Related-node +organization chips use the +entity-level label (`Demo Corp (Company)`), not `Organization`. +Related-node post chips show the post title only, not `(Post)`. +`RelatedNodeChip` plus `RelatedNodeChip.stories.tsx` are the +repeating walk inventory (ADR 0014). `GET /api/posts` and `GET /api/posts/{post_id}` include `voc_type_label` / `visibility_label` from `common_lookup_value` so diff --git a/CHANGELOG.d/0.76.0-related-node-chip-inventory.md b/CHANGELOG.d/0.76.0-related-node-chip-inventory.md new file mode 100644 index 000000000..2c65a9f98 --- /dev/null +++ b/CHANGELOG.d/0.76.0-related-node-chip-inventory.md @@ -0,0 +1,9 @@ +# 0.76.0 — Related-node chip inventory + +## Changed + +- Related-node walk chips share `RelatedNodeChip`. After seed, + walking from Demo Corp still shows "Ada West, Demo Corp + (Our side)". Priya Nair stays "Priya Nair (Counterparty)" — two + orgs are never collapsed into an invented primary. Click a chip + to continue the walk or open that post. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28f..3c4da3e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.76.0] - 2026-08-17 + +### Changed + +- Related-node walk chips share `RelatedNodeChip` (ADR 0014). After + seed, walking from Demo Corp still shows "Ada West, Demo Corp + (Our side)". Priya Nair stays "Priya Nair (Counterparty)" — two + orgs are never collapsed into an invented primary. Click a chip + to continue the walk or open that post. + ## [0.75.0] - 2026-08-17 ### Added @@ -14,6 +24,37 @@ All notable changes to this project are documented here. Format follows An accepted hit lists the title; click opens that post. A hidden post is omitted. Never invent a fused score or a theta. +## [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.2] - 2026-08-17 ### Added diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index bb398d141..7ed6897b1 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -8,6 +8,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from uuid import UUID @@ -275,6 +276,55 @@ async def load_visible_subgraph( return [edge_spec_from_row(row) for row in rows] +def compact_affiliation_display_names( + rows: list[Mapping[str, Any]], +) -> dict[str, str]: + """Return at most one display organization 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. A person with more than one remaining + identity is omitted so the chip never invents a primary org. + """ + catalog_ids: dict[str, set[str]] = {} + catalog_labels: dict[str, dict[str, str]] = {} + unresolved_names: dict[str, set[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_names.setdefault(person_id, set()).add(raw_name) + + display_names: dict[str, str] = {} + for person_id in set(catalog_ids) | set(unresolved_names): + labels_by_id = catalog_labels.get(person_id, {}) + catalog_name_fold = {name.casefold() for name in labels_by_id.values()} + leftover_names = { + name + for name in unresolved_names.get(person_id, set()) + if name.casefold() not in catalog_name_fold + } + identity_count = len(catalog_ids.get(person_id, set())) + len(leftover_names) + if identity_count != 1: + continue + if leftover_names: + display_names[person_id] = next(iter(leftover_names)) + elif labels_by_id: + display_names[person_id] = next(iter(labels_by_id.values())) + return display_names + + async def hydrate_related_nodes( conn: asyncpg.Connection, related: list[tuple[str, float]], @@ -283,6 +333,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 are omitted rather than collapsed + into an invented primary organization. """ person_ids: list[str] = [] post_ids: list[str] = [] @@ -305,6 +360,22 @@ async def hydrate_related_nodes( person_ids, ) } if person_ids else {} + affiliations = compact_affiliation_display_names( + 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 +386,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 +408,20 @@ 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) + org = affiliations.get(node_id) + if org: + item["affiliation_organization_name"] = org 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 1db483ee0..c39dfb6a8 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -883,8 +883,26 @@ 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 + 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 +920,10 @@ 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 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..e1bbcac86 --- /dev/null +++ b/backend/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,206 @@ +"""Live-PostgreSQL regressions for related-node affiliation display authority.""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from pathlib import Path +from typing import Iterator + +import asyncpg +import psycopg2 +import pytest + +from backend.app.knowledge_graph import hydrate_related_nodes +from lineageweave.knowledge_graph import NODE_PERSON, node_key + + +_POSTGRES_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", + "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave", +) +_MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" + + +def _postgres_available() -> bool: + """Return whether the repository's local PostgreSQL integration stack is reachable.""" + try: + psycopg2.connect(_POSTGRES_ADMIN_DSN, connect_timeout=2).close() + return True + except psycopg2.OperationalError: + return False + + +pytestmark = pytest.mark.skipif( + not _postgres_available(), + reason="requires the local PostgreSQL integration stack -- run `make up` first", +) + + +@pytest.fixture(scope="module") +def affiliation_database() -> Iterator[tuple[str, str, str]]: + """Create one migrated throwaway database with a person and catalog organization.""" + database_name = f"lineageweave_affiliation_test_{uuid.uuid4().hex[:12]}" + admin_connection = psycopg2.connect(_POSTGRES_ADMIN_DSN) + admin_connection.autocommit = True + with admin_connection.cursor() as cursor: + cursor.execute(f'create database "{database_name}"') + + database_dsn = _POSTGRES_ADMIN_DSN.rsplit("/", 1)[0] + f"/{database_name}" + connection = psycopg2.connect(database_dsn) + try: + with connection.cursor() as cursor: + cursor.execute(_MIGRATION_PATH.read_text(encoding="utf-8")) + cursor.execute( + "insert into common_lookup_value " + "(lookup_category, lookup_code, lookup_label) values " + "('person_side', 'counterparty', 'Counterparty'), " + "('corporate_entity_level', 'company', 'Company')" + ) + cursor.execute( + "insert into cataloged_person (person_name, person_side_code) " + "values ('Priya Nair', 'counterparty') returning person_id" + ) + person_id = str(cursor.fetchone()[0]) + cursor.execute( + "insert into corporate_entity " + "(corporate_entity_code, entity_name, entity_level_code) " + "values ('DEMO-CORP', 'Demo Corp', 'company') " + "returning corporate_entity_id" + ) + catalog_id = str(cursor.fetchone()[0]) + connection.commit() + yield database_dsn, person_id, catalog_id + finally: + connection.close() + with admin_connection.cursor() as cursor: + cursor.execute(f'drop database "{database_name}"') + admin_connection.close() + + +def _replace_affiliations( + database_dsn: str, + person_id: str, + affiliations: list[tuple[str, str | None]], +) -> None: + """Replace the fixture person's affiliation rows using the real schema.""" + connection = psycopg2.connect(database_dsn) + try: + with connection.cursor() as cursor: + cursor.execute("delete from person_affiliation where person_id = %s", (person_id,)) + for organization_name, catalog_id in affiliations: + cursor.execute( + "insert into person_affiliation " + "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " + "values (%s, %s, %s)", + (person_id, organization_name, catalog_id), + ) + connection.commit() + finally: + connection.close() + + +def _hydrate(database_dsn: str, person_id: str) -> dict[str, object]: + """Execute ``hydrate_related_nodes`` through a real asyncpg connection.""" + + async def run() -> dict[str, object]: + connection = await asyncpg.connect(database_dsn) + try: + payload = await hydrate_related_nodes( + connection, + [(node_key(NODE_PERSON, person_id), 0.8)], + ) + finally: + await connection.close() + assert len(payload) == 1 + return payload[0] + + return asyncio.run(run()) + + +def test_related_person_exposes_one_unambiguous_affiliation( + affiliation_database: tuple[str, str, str], +) -> None: + """A single unresolved affiliation survives the production SQL boundary.""" + database_dsn, person_id, _ = affiliation_database + _replace_affiliations(database_dsn, person_id, [("Northridge Grid", None)]) + node = _hydrate(database_dsn, person_id) + assert node["affiliation_organization_name"] == "Northridge Grid" + assert node["person_side_label"] == "Counterparty" + + +def test_related_person_omits_affiliation_when_multiple_are_known( + affiliation_database: tuple[str, str, str], +) -> None: + """Multiple live affiliation rows cannot become an invented primary organization.""" + database_dsn, person_id, _ = affiliation_database + _replace_affiliations( + database_dsn, + person_id, + [("Northridge Grid", None), ("Northridge Holdings", None)], + ) + node = _hydrate(database_dsn, person_id) + assert "affiliation_organization_name" not in node + + +def test_related_person_omits_blank_affiliation( + affiliation_database: tuple[str, str, str], +) -> None: + """Whitespace-only stored evidence remains missing display context.""" + database_dsn, person_id, _ = affiliation_database + _replace_affiliations(database_dsn, person_id, [(" ", None)]) + node = _hydrate(database_dsn, person_id) + assert "affiliation_organization_name" not in node + + +def test_related_person_uses_catalog_name_for_one_resolved_org( + affiliation_database: tuple[str, str, str], +) -> None: + """The real left join supplies corporate_entity.entity_name for resolved evidence.""" + database_dsn, person_id, catalog_id = affiliation_database + _replace_affiliations(database_dsn, person_id, [("Demo Corp Inc.", catalog_id)]) + node = _hydrate(database_dsn, person_id) + assert node["affiliation_organization_name"] == "Demo Corp" + + +def test_related_person_collapses_aliases_of_one_catalog_org( + affiliation_database: tuple[str, str, str], +) -> None: + """Two rows bound to one catalog UUID remain one organization identity.""" + database_dsn, person_id, catalog_id = affiliation_database + _replace_affiliations( + database_dsn, + person_id, + [("Demo Corp Inc.", catalog_id), ("Demo Corp", catalog_id)], + ) + node = _hydrate(database_dsn, person_id) + assert node["affiliation_organization_name"] == "Demo Corp" + + +def test_related_person_collapses_unresolved_name_matching_catalog( + affiliation_database: tuple[str, str, str], +) -> None: + """An unresolved case variant of the catalog label is not a second identity.""" + database_dsn, person_id, catalog_id = affiliation_database + _replace_affiliations( + database_dsn, + person_id, + [("Demo Corp", catalog_id), ("demo corp", None)], + ) + node = _hydrate(database_dsn, person_id) + assert node["affiliation_organization_name"] == "Demo Corp" + + +def test_related_person_omits_resolved_plus_distinct_unresolved( + affiliation_database: tuple[str, str, str], +) -> None: + """A catalog organization plus a distinct unresolved row remains ambiguous.""" + database_dsn, person_id, catalog_id = affiliation_database + _replace_affiliations( + database_dsn, + person_id, + [("Demo Corp", catalog_id), ("Northridge Holdings", None)], + ) + node = _hydrate(database_dsn, person_id) + assert "affiliation_organization_name" not in node diff --git a/docs/adr/0014-related-node-chip-stories.md b/docs/adr/0014-related-node-chip-stories.md new file mode 100644 index 000000000..e37a2b066 --- /dev/null +++ b/docs/adr/0014-related-node-chip-stories.md @@ -0,0 +1,41 @@ +# ADR-0014: Related-node chips share one module and story inventory + +- Status: Accepted +- Date: 2026-08-17 +- Stack: `feat/related-node-person-side-labels-main` (#92) @ `9bb5829` + +## Context + +Related-node walk chips live inline in `App.tsx`. The Figma synthetic +chip library (ADR 0002, +https://www.figma.com/design/nMmCeOdwGMKPxDrG8pWEAX) names the same +four buyer states: unique affiliation, side-only when two orgs would +invent a primary, organization level, and post title only. Repeating +the caption and accessible-name rules in App, tests, and a later +Storybook host would drift. + +## Decision + +1. `relatedNodeCaption` / `relatedNodeChipAccessibleName` own the + caption contract. Person chips name side plus a unique org. + Multiple distinct affiliations stay omitted. Organization chips + use the entity-level label. Post chips are the title only. +2. `RelatedNodeChip` is the only repeating walk control. +3. `RelatedNodeChip.stories.tsx` is the inventory. Host it with + Storybook 10 when the later token stack lands. Until then the + same states are locked by vitest. + +This slice does not add `affiliation_ambiguous` or a "multiple +organizations" caption. That next-action copy is #123 / #192. + +## Consequences + +Walking from Demo Corp still shows `Ada West, Demo Corp (Our side)`. +Priya Nair stays `Priya Nair (Counterparty)`. Click a chip to continue +the walk or open the post. Do not mix this increment into #74. + +## References + +World Wide Web Consortium. (2024). *Web content accessibility +guidelines (WCAG) 2.2* (Success Criterion 2.5.3 Label in Name). +https://www.w3.org/TR/WCAG22/#label-in-name diff --git a/frontend/package.json b/frontend/package.json index 575b7c586..9acf4850d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.76.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a26403..cce1e0dbd 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -556,6 +556,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, }, ], @@ -575,6 +578,8 @@ 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", relevance: 0.4, }, { @@ -591,6 +596,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, }, ], @@ -609,6 +616,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, }, ], @@ -1011,7 +1021,18 @@ 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 (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)", + ); + 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(), @@ -1024,7 +1045,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "R&R Keyman: Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); - expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Priya Nair (Counterparty)" }), + ).toBeInTheDocument(); }); it("opens related nodes from a related corporate entity", async () => { @@ -1033,9 +1056,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 () => { @@ -1064,7 +1095,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 () => { @@ -1073,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: "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 () => { @@ -1082,7 +1117,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 () => { @@ -1091,7 +1128,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(); }); @@ -1101,7 +1140,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 6056e5eb4..c84d6f733 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,13 @@ import { } from "./api"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + relatedNodeCaption, +} from "./relatedNodeCaption"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -467,10 +474,6 @@ function VocEvidenceSection({ ); } -const NODE_PERSON = "node_person"; -const NODE_POST = "node_post"; -const NODE_CORPORATE_ENTITY = "node_corporate_entity"; - const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -679,48 +682,47 @@ function KeymanPanel({ ) : ( diff --git a/frontend/src/RelatedNodeChip.stories.tsx b/frontend/src/RelatedNodeChip.stories.tsx new file mode 100644 index 000000000..2155be874 --- /dev/null +++ b/frontend/src/RelatedNodeChip.stories.tsx @@ -0,0 +1,90 @@ +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import type { RelatedNodeChipAction } from "./relatedNodeCaption"; + +type RelatedNodeStoryArgs = { + action: RelatedNodeChipAction; + onSelect: (node: RelatedNode) => void; + node: RelatedNode; +}; + +/** + * Storybook inventory for the repeating related-node chip. + * + * Host this file with Storybook 10 (Vite + React) when the later + * token stack lands. Until then the same four states are locked by + * RelatedNodeChip.test.tsx and relatedNodeCaption.test.ts. + * + * Buyer states after seed: + * - Ada West, Demo Corp (Our side) + * - Priya Nair (Counterparty) — two orgs stay omitted + * - Demo Corp (Company) + * - Linked post (title only) + */ +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", + }), + } satisfies RelatedNodeStoryArgs, +}; + +export const SideOnlyPluralAffiliations = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + } satisfies RelatedNodeStoryArgs, +}; + +export const OrganizationAndPost = { + render: () => ( +
    +
  • + undefined} + node={node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + })} + /> +
  • +
  • + undefined} + node={node({ + node_type_code: "node_post", + label: "Linked post", + })} + /> +
  • +
+ ), +}; diff --git a/frontend/src/RelatedNodeChip.test.tsx b/frontend/src/RelatedNodeChip.test.tsx new file mode 100644 index 000000000..4a46aa216 --- /dev/null +++ b/frontend/src/RelatedNodeChip.test.tsx @@ -0,0 +1,66 @@ +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 { + UniqueAffiliation, + SideOnlyPluralAffiliations, +} from "./RelatedNodeChip.stories"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("RelatedNodeChip", () => { + it("keeps the unique-affiliation caption inside the walk name", () => { + const caption = "Ada West, Demo Corp (Our side)"; + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + }); + + it("does not invent a primary org on a side-only chip", () => { + const caption = "Priya Nair (Counterparty)"; + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + expect(screen.queryByText(/Northridge/)).not.toBeInTheDocument(); + }); + + 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..97a27d7a2 --- /dev/null +++ b/frontend/src/RelatedNodeChip.tsx @@ -0,0 +1,34 @@ +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, + type RelatedNodeChipAction, +} from "./relatedNodeCaption"; +import "./relatedNodeTokens.css"; + +/** + * One related-node chip. Caption, tokens, and accessible name stay + * one contract so the walk inventory matches the Figma chip library + * (ADR 0002 / 0014). + */ +export function RelatedNodeChip({ + node, + action, + onSelect, +}: { + node: RelatedNode; + action: RelatedNodeChipAction; + onSelect: (node: RelatedNode) => void; +}) { + const caption = relatedNodeCaption(node); + return ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e6dfcbad2..9125db37d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -78,6 +78,10 @@ export interface RelatedNode { relevance: number; label?: string; person_side_code?: string; + person_side_label?: string; + affiliation_organization_name?: string; + entity_level_code?: string; + entity_level_label?: string; ontology_iri?: string; ontology_label?: string; } diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts new file mode 100644 index 000000000..932d74442 --- /dev/null +++ b/frontend/src/relatedNodeCaption.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, +} from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("relatedNodeCaption", () => { + it("names a unique affiliation and side", () => { + 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("keeps a side-only chip when no single org is known", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + ), + ).toBe("Priya Nair (Counterparty)"); + }); + + it("names the organization level, not the ontology class", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + }), + ), + ).toBe("Demo Corp (Company)"); + }); + + it("falls back to raw authorized codes when lookup labels are absent", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Synthetic Person", + person_side_code: "SIDE_EXTERNAL", + }), + ), + ).toBe("Synthetic Person (SIDE_EXTERNAL)"); + + expect( + relatedNodeCaption( + node({ + node_type_code: "node_corporate_entity", + label: "Synthetic Organization", + entity_level_code: "LEVEL_COMPANY", + }), + ), + ).toBe("Synthetic Organization (LEVEL_COMPANY)"); + }); + + it("shows a post title only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_post", + label: "Linked post", + }), + ), + ).toBe("Linked post"); + }); +}); + +describe("relatedNodeChipAccessibleName", () => { + it("contains the visible caption for a walk chip", () => { + expect( + relatedNodeChipAccessibleName("Ada West, Demo Corp (Our side)", "walk_person"), + ).toBe("Related nodes for Ada West, Demo Corp (Our side)"); + }); + + it("names the next action on a post chip", () => { + expect(relatedNodeChipAccessibleName("Linked post", "open_post")).toBe( + "Open related post: Linked post", + ); + }); +}); diff --git a/frontend/src/relatedNodeCaption.ts b/frontend/src/relatedNodeCaption.ts new file mode 100644 index 000000000..170f236c2 --- /dev/null +++ b/frontend/src/relatedNodeCaption.ts @@ -0,0 +1,64 @@ +import type { RelatedNode } from "./api"; + +export const NODE_PERSON = "node_person"; +export const NODE_POST = "node_post"; +export const NODE_CORPORATE_ENTITY = "node_corporate_entity"; + +/** + * Decision-facing label for a related-node chip on the #92 walk. + * + * Person chips use the authorized side label and, when exactly one + * organization identity is known, that organization. Multiple + * distinct affiliations stay omitted so a second org is never + * collapsed into an invented primary. Organization chips use the + * entity-level label. Post chips are the title only. + */ +export function relatedNodeCaption(node: RelatedNode): string { + const name = node.label ?? node.node_id; + if (node.node_type_code === NODE_PERSON) { + const side = node.person_side_label?.trim() || node.person_side_code?.trim(); + const org = node.affiliation_organization_name?.trim(); + if (side && org) { + return `${name}, ${org} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + } + if (node.node_type_code === NODE_CORPORATE_ENTITY) { + const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); + if (level) { + return `${name} (${level})`; + } + } + if (node.node_type_code === NODE_POST) { + return name; + } + return `${name} (${node.ontology_label ?? node.node_type_code})`; +} + +export type RelatedNodeChipAction = "walk_person" | "walk_entity" | "open_post"; + +/** + * Accessible name for a related-node chip. + * + * The visible caption is contained in the name (WCAG 2.2 Success + * Criterion 2.5.3). Walk chips continue the graph. Post chips open + * the evidence body. + */ +export function relatedNodeChipAccessibleName( + caption: string, + action: RelatedNodeChipAction, +): string { + switch (action) { + case "walk_person": + case "walk_entity": + return `Related nodes for ${caption}`; + case "open_post": + return `Open related post: ${caption}`; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} diff --git a/frontend/src/relatedNodeTokens.css b/frontend/src/relatedNodeTokens.css new file mode 100644 index 000000000..45e91885f --- /dev/null +++ b/frontend/src/relatedNodeTokens.css @@ -0,0 +1,23 @@ +: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-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; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009e..691380be0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.76.0" diff --git a/pyproject.toml b/pyproject.toml index 764ebad72..b10fc3bb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.76.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 08eab7768..9de5184c3 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.76.0" source = { virtual = "." } dependencies = [ { name = "certifi" },