From 9fd439c29f2ca77fed64f4796ab935feaa242e59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:33:00 +0900 Subject: [PATCH] feat: show affiliation on related-node person chips (v0.75.0) Related person chips now include the primary catalog affiliation when one exists, so Ada West walks to Priya Nair, Northridge Grid (Counterparty) instead of a side-only caption. Missing affiliations stay omitted rather than guessed. --- ARCHITECTURE.md | 6 ++++-- CHANGELOG.md | 11 +++++++++++ backend/app/knowledge_graph.py | 24 ++++++++++++++++++++++++ backend/tests/test_api.py | 8 ++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 22 ++++++++++++++-------- frontend/src/App.tsx | 4 ++++ frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 11 files changed, 70 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 879e5553..ccfd338a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -321,8 +321,10 @@ is the same never-guess-a-parent rule 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. Related-node person chips use the same -side label (`Ada West (Our side)`), not the ontology class -(`Ada West (Person)`). Related-node organization chips use the +side label plus the primary affiliation when one exists +(`Ada West, Demo Corp (Our side)`), not the ontology class +(`Ada West (Person)`). A missing affiliation is omitted, never +guessed. 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)`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 070e2862..1fe719dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.75.0] - 2026-08-16 + +### Changed + +- Related-node person chips include the primary affiliation + organization when one exists. After `make seed`, walking from Ada + West shows "Priya Nair, Northridge Grid (Counterparty)" and walking + from Demo Corp shows "Ada West, Demo Corp (Our side)". A person with + no affiliation keeps the side-only caption -- the org is never + guessed. Resolved catalog orgs win over unresolved names. + ## [0.74.0] - 2026-08-16 ### Changed diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 32a3b1b2..1d37981c 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -283,6 +283,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 nodes carry the primary affiliation organization when one + exists; a missing affiliation is omitted, never invented. """ person_ids: list[str] = [] post_ids: list[str] = [] @@ -305,6 +307,25 @@ async def hydrate_related_nodes( person_ids, ) } if person_ids else {} + affiliations: dict[str, str] = {} + if person_ids: + affiliation_rows = await conn.fetch( + """ + select person_id, affiliated_organization_name, affiliated_corporate_entity_id + from person_affiliation + where person_id = any($1::uuid[]) + order by + (affiliated_corporate_entity_id is not null) desc, + affiliated_organization_name + """, + person_ids, + ) + for row in affiliation_rows: + person_id = str(row["person_id"]) + org = (row["affiliated_organization_name"] or "").strip() + if person_id in affiliations or not org: + continue + affiliations[person_id] = org posts = { str(row["post_id"]): row for row in await conn.fetch( @@ -341,6 +362,9 @@ 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) + 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: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 00cf78b4..6f2b42ca 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -885,6 +885,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 counterpart["affiliation_organization_name"] == "Northridge Grid" + 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 = [ @@ -916,6 +923,7 @@ 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 seeded_db["other_private_post_id"] not in related_ids assert seeded_db["hidden_person_id"] not in related_ids diff --git a/frontend/package.json b/frontend/package.json index 3c4d979a..575b7c58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.74.0", + "version": "0.75.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1b1a8fe3..0ec54033 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -516,6 +516,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, }, ], @@ -537,6 +538,7 @@ describe("App, authenticated", () => { label: "Priya Nair", person_side_code: "counterparty", person_side_label: "Counterparty", + affiliation_organization_name: "Northridge Grid", relevance: 0.4, }, { @@ -575,6 +577,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, }, ], @@ -978,11 +981,14 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); expect( - screen.getByRole("button", { name: "Related nodes for Priya Nair (Counterparty)" }), + screen.getByRole("button", { name: "Related nodes for Priya Nair, Northridge Grid (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 (Counterparty)", + ); const relatedPanel = screen.getByText("Related to Ada West").closest(".related-keymen"); expect(relatedPanel).toHaveTextContent("Linked post"); expect(relatedPanel).not.toHaveTextContent("Linked post (Post)"); @@ -999,7 +1005,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.getByRole("button", { name: "Related nodes for Priya Nair (Counterparty)" }), + screen.getByRole("button", { name: "Related nodes for Priya Nair, Northridge Grid (Counterparty)" }), ).toBeInTheDocument(); }); @@ -1018,7 +1024,7 @@ describe("App, authenticated", () => { 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.getByRole("button", { name: "Related nodes for Ada West (Our side)" }), + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), ).toBeInTheDocument(); }); @@ -1049,7 +1055,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.getByRole("button", { name: "Related nodes for Ada West (Our side)" }), + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), ).toBeInTheDocument(); }); @@ -1060,7 +1066,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.getByRole("button", { name: "Related nodes for Ada West (Our side)" }), + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), ).toBeInTheDocument(); }); @@ -1071,7 +1077,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.getByRole("button", { name: "Related nodes for Ada West (Our side)" }), + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), ).toBeInTheDocument(); }); @@ -1082,7 +1088,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.getByRole("button", { name: "Related nodes for Ada West (Our side)" }), + 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(); }); @@ -1094,7 +1100,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.getByRole("button", { name: "Related nodes for Ada West (Our side)" }), + 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 58e08553..af3d9e34 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -473,6 +473,10 @@ 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 ?? node.person_side_code; + const org = node.affiliation_organization_name?.trim(); + if (side && org) { + return `${name}, ${org} (${side})`; + } if (side) { return `${name} (${side})`; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9ec00dd4..960bf915 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -79,6 +79,7 @@ export interface RelatedNode { 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; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 9c249405..1710c009 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.74.0" +__version__ = "0.75.0" diff --git a/pyproject.toml b/pyproject.toml index 61e982c3..764ebad7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.74.0" +version = "0.75.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 3f488912..08eab776 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.74.0" +version = "0.75.0" source = { virtual = "." } dependencies = [ { name = "certifi" },