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
26 changes: 18 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,17 @@ flowchart LR
| `server.py` | Stdlib HTTP server: `GET /api/lineage` (JSON graph) + static viewer |
| `web/index.html` | Self-contained SVG DAG viewer, no build step, no external script dependency |

> **Known local-test-environment limitation:** `adjudication_client.py`'s
> `mode="verify"` call depends on contextual-orchestrator's
> `TaskOrchestrator.route_and_verify`, which as of this writing is still
> an open, unmerged upstream PR
> **Known local-test-environment limitation:** `adjudication_client.py`
> and `post_chat.py` send `mode="verify"` (ADR-0013). That call depends
> on contextual-orchestrator's `TaskOrchestrator.route_and_verify`,
> which as of this writing is still an open, unmerged upstream PR
> (`ContextualWisdomLab/contextual-orchestrator#149`). Until it merges,
> the four adjudication/chat tests that exercise `mode="verify"` against
> the live adjudication/chat tests that exercise `mode="verify"` against
> a real orchestrator fail with `invalid_mode` (the deployed `main` only
> accepts `auto`/`route`/`conduct`) -- confirmed by reproducing the same
> `400` directly against the orchestrator's own `/v1/chat/completions`,
> not caused by anything in this repo. `mode="route"` (every other
> pluggable client) is unaffected.
> not caused by anything in this repo. Ordinary product adapters request
> `mode="auto"` and are unaffected.

## Design decisions worth naming

Expand Down 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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ 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).

## [Unreleased]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This head also bumps pyproject.toml / lineageweave/__init__.py / frontend/package.json to 0.75.0. Tagging 0.75.0 from 5eeaa7f would ship the orchestrator-mode lock while the notes still call it Unreleased. Move this bullet under [0.75.0], or drop the version bump. #123 folds it.


### Changed

- Product LLM adapters now request contextual-orchestrator
`mode="auto"` rather than forcing a one-model route. The
orchestrator owns the quality-sufficient route, verification, or
conducted workflow. Citation-bearing post-chat and lineage
adjudication keep their explicit `verify` contracts. Policy scans
require the payload literals `"mode": "auto"` / `"mode": "verify"`
so a docstring mention cannot satisfy ADR-0013.

## [0.75.0] - 2026-08-16

### Changed

- Related-node chips use decision-relevant business context instead of
ontology-class noise. After `make seed`, walking from Ada West shows
`Priya Nair (Counterparty)` -- not `Priya Nair (Person)` and not an
invented `Northridge Grid` primary, because Priya has two
unresolved orgs. Walking from Demo Corp shows
`Ada West, Demo Corp (Our side)` and `Demo Corp (Company)`. A person
chip adds an organization only when exactly one identity is known;
a resolved catalog org shows `entity_name`, and aliases of that org
collapse. Post chips show the title only. Click the chip to continue
the walk, or open the Keyman list when you need every affiliation.

## [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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

identity_count != 1 drops the person from the display map. After make seed, Priya has two unresolved orgs, so the chip becomes Priya Nair (Counterparty) — the same caption as a person with no affiliation. Keep display_name unset, but return a summary with ambiguous=True so the frontend can say multiple organizations and the buyer opens the Keyman list. #123 does this; also lock two distinct catalog ids so a first-catalog rewrite cannot pass.

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