Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 11 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,17 @@ 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)`.

`GET /api/posts` and `GET /api/posts/{post_id}` include
`voc_type_label` / `visibility_label` from `common_lookup_value` so
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,51 @@ 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 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