Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,13 @@ 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 the primary affiliation when one exists
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
(`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)`.

`GET /api/posts` and `GET /api/posts/{post_id}` include
`voc_type_label` / `visibility_label` from `common_lookup_value` so
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,48 @@ 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.
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

## [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
Expand Down
45 changes: 43 additions & 2 deletions backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ 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 non-empty affiliation is known; ambiguous affiliations are
omitted rather than collapsed into an invented primary organization.
"""
person_ids: list[str] = []
post_ids: list[str] = []
Expand All @@ -305,6 +308,28 @@ async def hydrate_related_nodes(
person_ids,
)
} if person_ids else {}
affiliation_names_by_person: dict[str, set[str]] = {}
if person_ids:
affiliation_rows = await conn.fetch(
"""
select person_id, affiliated_organization_name
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
from person_affiliation
where person_id = any($1::uuid[])
order by person_id, affiliated_organization_name
""",
person_ids,
)
for row in affiliation_rows:
person_id = str(row["person_id"])
org = (row["affiliated_organization_name"] or "").strip()
if not org:
continue
affiliation_names_by_person.setdefault(person_id, set()).add(org)
affiliations = {
person_id: next(iter(organization_names))
for person_id, organization_names in affiliation_names_by_person.items()
if len(organization_names) == 1
}
posts = {
str(row["post_id"]): row
for row in await conn.fetch(
Expand All @@ -315,11 +340,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] = {
Expand All @@ -329,12 +362,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)
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 counterpart["affiliation_organization_name"] == "Northridge Grid"
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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(
Expand All @@ -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

Expand Down
64 changes: 64 additions & 0 deletions backend/tests/test_related_node_affiliation_ambiguity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""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"


class _FakeConnection:
"""Return the minimum query results needed by ``hydrate_related_nodes``."""

def __init__(self, affiliations: list[str]) -> 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": organization_name,
"affiliated_corporate_entity_id": None,
}
for organization_name 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[str]) -> 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(["Northridge Grid"])
assert node["affiliation_organization_name"] == "Northridge Grid"


def test_related_person_omits_affiliation_when_multiple_are_known() -> None:
"""Multiple affiliations must not be collapsed into an invented primary one."""
node = _hydrate(["Northridge Grid", "Northridge Holdings"])
assert "affiliation_organization_name" not in node
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.71.0",
"version": "0.75.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Loading
Loading