Skip to content
Closed
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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ must set `available = False` and make their channel dropped +
renormalized (`reconstruct.active_weights`), never silently return a
placeholder score, invented Keyman, guessed relationship, fabricated
summary/chat, or invented commitment. A missing signal and a
confidently-negative signal are different things. Keyman extraction,
confidently-negative signal are different things. Related-node person
chips follow the same rule: a known-plural affiliation set emits
`affiliation_ambiguous` and the caption `multiple organizations`; it
must not look like a person with no affiliation, and it must not
invent a primary organization. Keyman extraction,
entity-relationship classification, post summary, in-popup chat, and
commitment derivation go through contextual-orchestrator the same way
adjudication does -- never a raw LLM API.
Expand Down
16 changes: 15 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,21 @@ 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 never
collapsed into a guessed primary; the chip says
`Priya Nair, multiple organizations (Counterparty)` after
`make seed` so the buyer opens the Keyman panel for the full
list. A person with no affiliation stays side-only. A resolved
catalog org supplies `entity_name`; unresolved aliases of that
same org, including letter-case variants, 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
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,63 @@ 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-16

### Changed

- Related-node person chips distinguish a known-plural affiliation
set from a missing one. After `make seed`, walking from Ada West
shows "Priya Nair, multiple organizations (Counterparty)" so the
next action is to open the Keyman panel for the full list. The
chip still never names a guessed primary. A person with no
affiliation stays side-only. Unresolved names that differ only
by letter case count as one identity.

## [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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# CLAUDE.md

Agent conventions for this repository live in [AGENTS.md](AGENTS.md).
Read that file before changing fusion, channels, fixtures, or UI
caption contracts.

Related-node person chips: unique catalog identity shows
`entity_name`; a known-plural set emits `affiliation_ambiguous` and
the caption `multiple organizations`; never invent a primary
organization. See [ADR 0014](docs/adr/0014-related-node-affiliation-plurality.md).
126 changes: 124 additions & 2 deletions backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from __future__ import annotations

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

Expand Down Expand Up @@ -275,6 +277,86 @@ async def load_visible_subgraph(
return [edge_spec_from_row(row) for row in rows]


@dataclass(frozen=True)
class CompactAffiliation:
"""Authorized compact affiliation for one related-node person.

``identity_count`` is the number of distinct organization identities
after catalog-id and casefold-alias collapse. ``display_name`` is
set only when that count is exactly one so the chip never invents
a primary. ``ambiguous`` is true when the count is greater than
one -- a known plural set is not the same as a missing affiliation
(Browne et al., 2001).
"""

identity_count: int
display_name: str | None = None

@property
def ambiguous(self) -> bool:
"""True when more than one distinct organization identity remains."""
return self.identity_count > 1


def compact_affiliation_summaries(
rows: list[Mapping[str, Any]],
) -> dict[str, CompactAffiliation]:
"""Return the compact affiliation summary 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, except two unresolved strings that differ
only by letter case count as one identity. A person with more
than one remaining identity keeps ``ambiguous=True`` and no
``display_name`` so the chip never invents a primary org.
"""
catalog_ids: dict[str, set[str]] = {}
catalog_labels: dict[str, dict[str, str]] = {}
unresolved_labels: dict[str, dict[str, 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_labels.setdefault(person_id, {}).setdefault(
raw_name.casefold(), raw_name
)

summaries: dict[str, CompactAffiliation] = {}
for person_id in set(catalog_ids) | set(unresolved_labels):
labels_by_id = catalog_labels.get(person_id, {})
catalog_name_fold = {name.casefold() for name in labels_by_id.values()}
leftover_names = {
name
for fold, name in unresolved_labels.get(person_id, {}).items()
if fold not in catalog_name_fold
}
identity_count = len(catalog_ids.get(person_id, set())) + len(leftover_names)
if identity_count == 0:
continue
display_name: str | None = None
if identity_count == 1:
if leftover_names:
display_name = next(iter(leftover_names))
elif labels_by_id:
display_name = next(iter(labels_by_id.values()))
summaries[person_id] = CompactAffiliation(
identity_count=identity_count,
display_name=display_name,
)
return summaries


async def hydrate_related_nodes(
conn: asyncpg.Connection,
related: list[tuple[str, float]],
Expand All @@ -283,6 +365,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 set ``affiliation_ambiguous`` and
omit the name rather than collapsing into an invented primary.
"""
person_ids: list[str] = []
post_ids: list[str] = []
Expand All @@ -305,6 +392,22 @@ async def hydrate_related_nodes(
person_ids,
)
} if person_ids else {}
affiliations = compact_affiliation_summaries(
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 +418,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 +440,23 @@ 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)
summary = affiliations.get(node_id)
if summary is not None:
if summary.display_name:
item["affiliation_organization_name"] = summary.display_name
if summary.ambiguous:
item["affiliation_ambiguous"] = True
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
24 changes: 24 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,8 +883,27 @@ 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
assert counterpart["affiliation_ambiguous"] is True
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 +921,11 @@ 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 "affiliation_ambiguous" not in our_person
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