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
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,7 @@ jobs:
- name: Build
working-directory: frontend
run: pnpm run build

- name: Build Storybook
working-directory: frontend
run: pnpm run build-storybook
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ __pycache__/
.DS_Store
.codegraph/
.env
storybook-static/
frontend/storybook-static/

12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ Cross-agent conventions for `LineageWeave`, readable by any coding agent
A demo BI prototype that reconstructs git-branch-style lineage between
scattered short records. See [ARCHITECTURE.md](ARCHITECTURE.md) for the
design and [`docs/lineage-bi-research-notes.md`](docs/lineage-bi-research-notes.md)
for the literature it is grounded in.
for the literature it is grounded in. APA 7th citations for product
decisions live under [`docs/doctoring/`](docs/doctoring/) -- start
with
[`RELATED_NODE_AFFILIATION_REFERENCES.md`](docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md)
before changing related-node affiliation display.

## Hard rule: no real data, ever

Expand Down Expand Up @@ -79,9 +83,13 @@ floating Node version):

```bash
cd frontend && pnpm install
pnpm run lint && pnpm run test && pnpm run build
pnpm run lint && pnpm run test && pnpm run build && pnpm run build-storybook
```

Repeating walk objects use design tokens in
`frontend/src/tokens/design-tokens.json` and stories in
`frontend/src/*.stories.tsx` (ADR-0016).

## CI gates

`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
Expand Down
34 changes: 26 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 @@ -263,6 +263,10 @@ regression-tested (`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`
### Frontend (`frontend/`)

React + Vite + TypeScript, pinned Node via `mise.toml`, pnpm via Corepack.
Repeating walk chips are `RelatedNodeChip` plus
`frontend/src/tokens/design-tokens.json` (ADR-0016). Open
`pnpm run storybook` in `frontend/` to compare unique, plural, and
missing affiliation captions before walking a seeded graph.
`react-oidc-context` drives a real Authorization Code redirect through
Keycloak (`src/main.tsx`'s `AuthProvider`) -- no mocked auth, no static
HTML. `src/api.ts` calls the FastAPI backend directly with the token
Expand Down Expand Up @@ -320,7 +324,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 set
`affiliation_ambiguous` and the caption
`Priya Nair, multiple organizations (Counterparty)` after
`make seed` -- never a guessed primary, and never a side-only chip
that looks like a missing affiliation. The related panel then says
to read the Keyman list above (or extract Keymen) before clicking
the chip to continue the walk. 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
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,60 @@ 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.78.0] - 2026-08-16

### Added

- Related-node chips are a Storybook module with design tokens. Run
`cd frontend && pnpm run storybook` and open Walk/RelatedNodeChip
to compare `Ada West, Demo Corp (Our side)`, `Priya Nair, multiple
organizations (Counterparty)`, and a side-only missing affiliation
before you walk a seeded graph. Click a person or organization
story to rehearse the next walk; click the post story to open that
source. `pnpm run build-storybook` is part of the frontend CI gate.

## [0.77.0] - 2026-08-16

### Changed

- When a related-node chip says `multiple organizations`, the related
panel now names the next action: read every organization in the
Keyman list above (or extract Keymen if that list is empty), then
click the chip to continue the walk. A stale payload that sends
both a name and `affiliation_ambiguous` still shows the plural
signal, never a guessed primary.

## [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 list. The chip still never names
a guessed primary. Two distinct catalog orgs are marked the same
way. 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 chips use decision-relevant business context instead of
ontology-class noise. 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.
- 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.71.0] - 2026-08-14

### Added
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# CLAUDE.md

Read [AGENTS.md](AGENTS.md) first. This file is a pointer, not a second
policy.

LineageWeave is a synthetic-data BI prototype. Do not add real
organization records. Reuse ThreadWeave, RankWeave, TEPP, and
contextual-orchestrator rather than reimplementing them.

Frontend walk chips and tokens: [ADR-0016](docs/adr/0016-storybook-and-design-tokens.md)
and [docs/storybook-inventory.md](docs/storybook-inventory.md). Run
`cd frontend && pnpm run storybook` to compare unique, plural, and
missing affiliation captions before walking a seeded graph.
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