Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ against a live local stack (`make up`) and self-skip without one -- see

`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
Expand Down
14 changes: 13 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,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
Expand Down
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,62 @@ 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` and a Storybook
inventory (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.

Comment thread
seonghobae marked this conversation as resolved.
## [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
Expand Down
91 changes: 89 additions & 2 deletions backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

from collections.abc import Mapping
from typing import Any
from uuid import UUID

Expand Down Expand Up @@ -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]],
Expand All @@ -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] = []
Expand All @@ -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(
Expand All @@ -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] = {
Expand All @@ -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)
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 "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(
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
Loading
Loading