diff --git a/AGENTS.md b/AGENTS.md index 735988f09..be33b65eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95d..99d3fcc2b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0096828a2..03e090e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ 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.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. Repeating chips use the + `RelatedNodeChip` module and `--related-node-*` tokens. + +## [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 diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index bb398d141..0978c9784 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -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 @@ -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]], @@ -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] = [] @@ -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( @@ -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] = { @@ -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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 74ab44701..3fd8d672f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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( @@ -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 diff --git a/backend/tests/test_related_node_affiliation_ambiguity.py b/backend/tests/test_related_node_affiliation_ambiguity.py new file mode 100644 index 000000000..e5d2f7e96 --- /dev/null +++ b/backend/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,198 @@ +"""Regression tests for related-node affiliation display authority.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from backend.app.knowledge_graph import hydrate_related_nodes +from lineageweave.knowledge_graph import NODE_PERSON, node_key + + +_PERSON_ID = "11111111-1111-4111-8111-111111111111" +_CATALOG_ID = "22222222-2222-4222-8222-222222222222" +_SECOND_CATALOG_ID = "33333333-3333-4333-8333-333333333333" + + +class _FakeConnection: + """Return the minimum query results needed by ``hydrate_related_nodes``.""" + + def __init__(self, affiliations: list[dict[str, Any]]) -> None: + self._affiliations = affiliations + + async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: + if "from cataloged_person" in query: + return [ + { + "person_id": _PERSON_ID, + "person_name": "Priya Nair", + "person_side_code": "counterparty", + } + ] + if "from person_affiliation" in query: + return [ + { + "person_id": _PERSON_ID, + "affiliated_organization_name": row.get("affiliated_organization_name"), + "affiliated_corporate_entity_id": row.get("affiliated_corporate_entity_id"), + "catalog_entity_name": row.get("catalog_entity_name"), + } + for row in self._affiliations + ] + if "from common_lookup_value" in query: + return [{"lookup_code": "counterparty", "lookup_label": "Counterparty"}] + raise AssertionError(f"unexpected query: {query}") + + +def _hydrate(affiliations: list[dict[str, Any]]) -> dict[str, Any]: + payload = asyncio.run( + hydrate_related_nodes( + _FakeConnection(affiliations), # type: ignore[arg-type] + [(node_key(NODE_PERSON, _PERSON_ID), 0.8)], + ) + ) + assert len(payload) == 1 + return payload[0] + + +def test_related_person_exposes_one_unambiguous_affiliation() -> None: + """A single known affiliation is safe to use as compact display context.""" + node = _hydrate([{"affiliated_organization_name": "Northridge Grid"}]) + assert node["affiliation_organization_name"] == "Northridge Grid" + assert "affiliation_ambiguous" not in node + assert node["person_side_label"] == "Counterparty" + + +def test_related_person_marks_plural_affiliations_ambiguous() -> None: + """A known-plural set is not a missing affiliation and never invents a primary.""" + node = _hydrate( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert "affiliation_organization_name" not in node + assert node["affiliation_ambiguous"] is True + assert node["person_side_label"] == "Counterparty" + + +def test_related_person_omits_blank_affiliation() -> None: + """Whitespace-only extraction strings are missing evidence, not a name.""" + node = _hydrate([{"affiliated_organization_name": " "}]) + assert "affiliation_organization_name" not in node + assert "affiliation_ambiguous" not in node + + +def test_related_person_uses_catalog_name_for_one_resolved_org() -> None: + """A resolved catalog org supplies entity_name, not the raw extraction.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + } + ] + ) + assert node["affiliation_organization_name"] == "Demo Corp" + assert "affiliation_ambiguous" not in node + + +def test_related_person_collapses_aliases_of_one_catalog_org() -> None: + """Two raw strings for the same corporate_entity_id are one identity.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp Inc.", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + ] + ) + assert node["affiliation_organization_name"] == "Demo Corp" + assert "affiliation_ambiguous" not in node + + +def test_related_person_collapses_unresolved_name_matching_catalog() -> None: + """An unresolved alias of the catalog label is not a second org.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "demo corp"}, + ] + ) + assert node["affiliation_organization_name"] == "Demo Corp" + assert "affiliation_ambiguous" not in node + + +def test_related_person_omits_resolved_plus_distinct_unresolved() -> None: + """A catalog org plus a different unresolved name stays ambiguous.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + {"affiliated_organization_name": "Northridge Holdings"}, + ] + ) + assert "affiliation_organization_name" not in node + assert node["affiliation_ambiguous"] is True + + +def test_related_person_marks_two_distinct_catalog_orgs_ambiguous() -> None: + """Two resolved catalog orgs must not collapse into a guessed primary.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "Demo Corp", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "Demo Corp", + }, + { + "affiliated_organization_name": "Northridge Holdings", + "affiliated_corporate_entity_id": _SECOND_CATALOG_ID, + "catalog_entity_name": "Northridge Holdings", + }, + ] + ) + assert "affiliation_organization_name" not in node + assert node["affiliation_ambiguous"] is True + + +def test_related_person_keeps_nameless_catalog_identity_side_only() -> None: + """An orphaned catalog id with no name is not a guessed primary or a plural set.""" + node = _hydrate( + [ + { + "affiliated_organization_name": "", + "affiliated_corporate_entity_id": _CATALOG_ID, + "catalog_entity_name": "", + } + ] + ) + assert "affiliation_organization_name" not in node + assert "affiliation_ambiguous" not in node + assert node["person_side_label"] == "Counterparty" + + +def test_related_person_collapses_unresolved_names_that_differ_only_by_case() -> None: + """Letter-case variants of one unresolved name are one identity.""" + node = _hydrate( + [ + {"affiliated_organization_name": "Northridge Grid"}, + {"affiliated_organization_name": "northridge grid"}, + ] + ) + assert node["affiliation_organization_name"] == "Northridge Grid" + assert "affiliation_ambiguous" not in node diff --git a/docs/adr/0014-related-node-business-captions.md b/docs/adr/0014-related-node-business-captions.md new file mode 100644 index 000000000..ccbca43e6 --- /dev/null +++ b/docs/adr/0014-related-node-business-captions.md @@ -0,0 +1,64 @@ +# ADR-0014: Related-node chips use business context, not ontology class + +- Status: Accepted +- Date: 2026-08-16 + +## Context + +Related-node chips in the Keyman walk showed the ontology class +(`Person`, `Organization`, `Post`). Buyers already know they clicked a +person or an organization. The class label does not tell them which +side the person is on, which company they represent, or what to click +next. Keyman list rows already expose `person_side_label` and every +affiliation. The compact related-node chip is a different surface: it +must stay short enough to scan while walking. + +`person_affiliation` is N:N and has no `primary` column. Sorting +affiliations and taking the first row would invent a primary +organization. Priya Nair in the synthetic fixture belongs to both +Northridge Grid and Northridge Holdings. + +## Decision + +Hydrate related-node payloads with authorized lookup labels: + +- Person chips use `person_side_label` (fallback: raw `person_side_code`). +- A person chip adds `affiliation_organization_name` only when exactly + one distinct organization identity is known. A resolved catalog org + supplies `corporate_entity.entity_name`; unresolved aliases that + casefold-match that label collapse into it. Two unresolved names + that differ only by letter case count as one identity. +- A known-plural set (two unresolved names, two catalog orgs, or a + catalog org plus a distinct unresolved name) sets + `affiliation_ambiguous` and the caption uses + `multiple organizations`. That is not the same as a missing + affiliation. The related panel then names the next action: read + every organization in the Keyman list (or extract Keymen if the + list is empty), then click the chip to continue the walk. The + caption prefers the plural signal if a name is also present so a + stale payload cannot invent a primary. +- A unique org without a side still names the org + (`Ada West, Demo Corp`) so a missing side cannot revive the + ontology-class caption. +- Organization chips use `entity_level_label` (fallback: raw code). +- Post chips show the post title only. + +Person and organization chips use `Related nodes for ${caption}` as +the accessible name. Post chips use `Open related post: ${caption}` +so the next action stays in the name and the visible caption is +contained (WCAG 2.5.3). Full affiliation lists stay on the Keyman +and affiliate-tree surfaces. + +## Consequences + +Walking from Ada West shows +`Priya Nair, multiple organizations (Counterparty)` rather than +`Priya Nair, Northridge Grid (Counterparty)` or a side-only chip that +looks like Priya has no organization. Walking from Demo Corp shows +`Ada West, Demo Corp (Our side)` and `Demo Corp (Company)`. +Click a chip to continue the walk. When the chip says multiple +organizations, read the Keyman list above first. + +## References + +See [RELATED_NODE_AFFILIATION_REFERENCES.md](../doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). diff --git a/docs/adr/0015-affiliation-validity-interval.md b/docs/adr/0015-affiliation-validity-interval.md new file mode 100644 index 000000000..e0de7aa25 --- /dev/null +++ b/docs/adr/0015-affiliation-validity-interval.md @@ -0,0 +1,44 @@ +# ADR-0015: Affiliation validity interval (proposed) + +- Status: Proposed +- Date: 2026-08-16 + +## Context + +`person_affiliation` is N:N with no interval. Compact related-node +chips (ADR-0014) count every stored identity. That is correct for +Priya Nair's two current counterparty orgs after `make seed`. It is +wrong for a person who left one organization last year and now has +exactly one current membership: the chip would still say +`multiple organizations`. + +Browne et al. (2001) treat multiple membership as simultaneous +classification. Singer and Willett (2003) treat change over time as +a different structure. Collapsing those two into one unordered set +repeats the same atomistic mistake ADR-0014 already refused for +"primary" org. + +Migration numbers `0012+` are reserved on other open heads +(Milestone 2.1 analysis-run registry, #74 ontology stack). This +decision must not steal those numbers. + +## Decision (when those heads land) + +Add nullable `affiliation_started_on` and `affiliation_ended_on` +(date) on `person_affiliation`. Both null means current and +unbounded. Compact summaries count an identity only when the +as-of date is inside that interval. Seed a synthetic person with +one current org and one ended org; the chip must name the current +org, not `multiple organizations`. + +Until then, do not invent interval columns on a second caption PR. + +## Consequences + +Buyers walking "as of today" will stop seeing leftover orgs as +plural membership. The Keyman list can still show ended rows with +their dates. Full as-of graph walks stay a later change. + +## References + +See [RELATED_NODE_AFFILIATION_REFERENCES.md](../doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). diff --git a/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md new file mode 100644 index 000000000..c8e53a449 --- /dev/null +++ b/docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md @@ -0,0 +1,48 @@ +# Related-node affiliation references + +APA 7th citations for ADR-0014 (compact related-node captions) and the +proposed temporal-validity follow-up. These are the sources to open +before changing affiliation display or `person_affiliation` columns. + +## Multiple membership (do not invent a primary) + +Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership +multiple classification (MMMC) models. *Statistical Modelling, 1*(2), +103–124. https://doi.org/10.1177/1471082X0100100202 + +A person can belong to several organizations at once. Sorting +`person_affiliation` and taking the first row would treat a +multiple-membership structure as if one org were the atom. Compact +chips therefore name an organization only when exactly one identity +remains, and they say `multiple organizations` when more than one +remains. + +## Accessible name contains the visible caption + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +Success Criterion 2.5.3 Label in Name: the visible chip text is +contained in `Related nodes for ${caption}` or +`Open related post: ${caption}`. + +## Design tokens for repeating walk controls + +W3C Design Tokens Community Group. (2025). *Design Tokens Format +Module* (Editor's Draft). https://www.w3.org/community/design-tokens/ + +Repeating related-node chips share `--related-node-*` tokens in +`frontend/src/relatedNodeTokens.css` and the `RelatedNodeChip` module. +Do not restyle one walk surface with a one-off class. + +## Time (proposed; not on this schema yet) + +Singer, J. D., & Willett, J. B. (2003). *Applied longitudinal data +analysis: Modeling change and event occurrence*. Oxford University +Press. + +A past affiliation that has ended is not a current second membership. +Until `person_affiliation` stores an interval, compact chips count +every stored row. Do not add those columns until Milestone 2.1 +(`0012+`) and the #74 ontology stack settle migration numbers. See +[ADR-0015](../adr/0015-affiliation-validity-interval.md). diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index acd55620b..ddcfd0227 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -253,10 +253,21 @@ not yet a resolved person node, and a mention whose side cannot be classified into the closed `{our_side, counterparty}` set is dropped rather than guessed. N:N organization attachments are slot-filling on that mention (a person may have zero, one, or several affiliations in -the same post), not a second independent NER pass. The live client -calls contextual-orchestrator (`mode="route"`) rather than a raw LLM -API so reasoning-effort allocation stays centralized with the -adjudication channel. Proven for real during development against +the same post), not a second independent NER pass. Compact related-node +chips therefore add an organization only when exactly one organization +identity is known. Resolved catalog aliases collapse; distinct +memberships stay distinct and the chip says `multiple organizations` +so a plural set is not mistaken for a missing affiliation. Collapsing +several memberships into a sorted "primary" would repeat the +atomistic fallacy Browne et al. (2001) warn against for +multiple-membership structures. The related panel names that next +action: read the Keyman list (or extract Keymen), then click the +chip to continue the walk. Citations live in +[`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). The live client +calls contextual-orchestrator (`mode="auto"`) rather than a raw LLM +API so the orchestration plane can allocate route, verify, or a +deeper workflow; adjudication and post-chat keep explicit +`mode="verify"`. Proven for real during development against `fixtures.ambiguous_keyman_post` when orchestrator credentials are set; the default suite asserts the parser and the never-fake null client. @@ -271,7 +282,10 @@ adaptive cutoff (a relevance-ratio threshold against the top score) -- `tests/test_knowledge_graph.py` proves this concretely: the same ratio threshold yields a five-node related-set from a well-connected "hub" node and a one-node related-set from a sparsely-connected node, with no hop-count -constant anywhere in the algorithm or the test. +constant anywhere in the algorithm or the test. Hydrated related-node +chips (ADR-0014) then replace the ontology class with the authorized +side or entity-level label so the next click is a business decision, +not a class reminder. ## Entity-relationship classification and corporate hierarchy resolution (Phase 3) diff --git a/frontend/package.json b/frontend/package.json index 9c84795d9..5f8f72e51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.71.0", + "version": "0.77.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 76cf3665c..ddddd716a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,5 @@ +@import "./relatedNodeTokens.css"; + #root { max-width: 960px; margin: 0 auto; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 415e1419f..959d234f3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -514,6 +514,9 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", ontology_label: "Person", label: "Ada West", + person_side_code: "our_side", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", relevance: 0.4, }, ], @@ -533,6 +536,9 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", ontology_label: "Person", label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + affiliation_ambiguous: true, relevance: 0.4, }, { @@ -549,6 +555,8 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Organization", ontology_label: "Organization", label: "Demo Corp", + entity_level_code: "company", + entity_level_label: "Company", relevance: 0.2, }, ], @@ -567,6 +575,9 @@ describe("App, authenticated", () => { ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", ontology_label: "Person", label: "Ada West", + person_side_code: "our_side", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", relevance: 0.5, }, ], @@ -969,7 +980,21 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); - expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Priya Nair, multiple organizations (Counterparty)" }), + ).toBeInTheDocument(); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Priya Nair (Person)", + ); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Northridge Grid", + ); + const relatedPanel = screen.getByText("Related to Ada West").closest(".related-keymen"); + expect(relatedPanel).toHaveTextContent( + "A chip that says multiple organizations is not a missing affiliation. Read every organization in the Keyman list above, then click the chip to continue the walk.", + ); + expect(relatedPanel).toHaveTextContent("Linked post"); + expect(relatedPanel).not.toHaveTextContent("Linked post (Post)"); await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); await waitFor(() => expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), @@ -982,7 +1007,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "R&R Keyman: Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); - expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Priya Nair, multiple organizations (Counterparty)" }), + ).toBeInTheDocument(); }); it("opens related nodes from a related corporate entity", async () => { @@ -991,9 +1018,17 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); - await userEvent.click(screen.getByRole("button", { name: "Related nodes for Demo Corp" })); + expect( + screen.getByRole("button", { name: "Related nodes for Demo Corp (Company)" }), + ).toBeInTheDocument(); + expect(screen.getByText("Related to Ada West").closest(".related-keymen")).not.toHaveTextContent( + "Demo Corp (Organization)", + ); + await userEvent.click(screen.getByRole("button", { name: "Related nodes for Demo Corp (Company)" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("shows the VOC excerpt under its counterparty, not a detached list", async () => { @@ -1022,7 +1057,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "VOC Keyman: Northridge Grid" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("opens related Keyman nodes from an affiliate-tree person", async () => { @@ -1031,7 +1068,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("opens related nodes from a Keyman affiliation organization", async () => { @@ -1040,7 +1079,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Keyman affiliation: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); }); it("opens related nodes from an affiliate-tree organization", async () => { @@ -1049,7 +1090,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Affiliate org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Affiliate org: Northridge Grid" })).not.toBeInTheDocument(); }); @@ -1059,7 +1102,9 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); await userEvent.click(await screen.findByRole("button", { name: "Counterparty org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); - expect(screen.getByText("Ada West (Person)")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Counterparty org: Northridge Grid" })).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e39a9253..952fe07de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,8 @@ import { } from "./api"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedAffiliationNextAction } from "./relatedNodeCaption"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -670,6 +672,11 @@ function KeymanPanel({ {selectedName && (

Related to {selectedName}

+ {related !== null && related.some((node) => node.affiliation_ambiguous) ? ( +

+ {relatedAffiliationNextAction(Boolean(keymen && keymen.length > 0))} +

+ ) : null} {related === null ? (

Loading related nodes...

) : related.length === 0 ? ( @@ -677,48 +684,47 @@ function KeymanPanel({ ) : ( diff --git a/frontend/src/RelatedNodeChip.stories.tsx b/frontend/src/RelatedNodeChip.stories.tsx new file mode 100644 index 000000000..34c96e05e --- /dev/null +++ b/frontend/src/RelatedNodeChip.stories.tsx @@ -0,0 +1,90 @@ +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; + +/** + * Storybook inventory for the repeating related-node chip. + * + * Host this file with Storybook 10 (Vite + React) when the later + * analysis-run token stack lands. Until then the same four states + * are locked by RelatedNodeChip.test.tsx and relatedNodeCaption.test.ts. + */ +const meta = { + title: "Lineage/RelatedNodeChip", + component: RelatedNodeChip, +}; + +export default meta; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +export const UniqueAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + }, +}; + +export const PluralAffiliations = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + }, +}; + +export const MissingAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + }, +}; + +export const OrganizationAndPost = { + render: () => ( +
    +
  • + undefined} + node={node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + })} + /> +
  • +
  • + undefined} + node={node({ + node_type_code: "node_post", + label: "Linked post", + })} + /> +
  • +
+ ), +}; diff --git a/frontend/src/RelatedNodeChip.test.tsx b/frontend/src/RelatedNodeChip.test.tsx new file mode 100644 index 000000000..305bb3391 --- /dev/null +++ b/frontend/src/RelatedNodeChip.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { relatedNodeChipAccessibleName } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("RelatedNodeChip", () => { + it("keeps the visible plural caption inside the walk name", () => { + const caption = "Priya Nair, multiple organizations (Counterparty)"; + expect(relatedNodeChipAccessibleName(caption, "walk_person")).toBe( + `Related nodes for ${caption}`, + ); + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + }); + + it("opens the post when the buyer clicks a title-only chip", async () => { + const onSelect = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0].node_id).toBe("post-1"); + }); +}); diff --git a/frontend/src/RelatedNodeChip.tsx b/frontend/src/RelatedNodeChip.tsx new file mode 100644 index 000000000..f0c1c381c --- /dev/null +++ b/frontend/src/RelatedNodeChip.tsx @@ -0,0 +1,32 @@ +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, + type RelatedNodeChipAction, +} from "./relatedNodeCaption"; + +/** + * One related-node chip. Use this module for every repeating walk + * control so caption, tokens, and accessible name stay one contract. + */ +export function RelatedNodeChip({ + node, + action, + onSelect, +}: { + node: RelatedNode; + action: RelatedNodeChipAction; + onSelect: (node: RelatedNode) => void; +}) { + const caption = relatedNodeCaption(node); + return ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b6692776..5fa4f5469 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -78,6 +78,11 @@ export interface RelatedNode { relevance: number; label?: string; person_side_code?: string; + person_side_label?: string; + affiliation_organization_name?: string; + affiliation_ambiguous?: boolean; + entity_level_code?: string; + entity_level_label?: string; ontology_iri?: string; ontology_label?: string; } diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts new file mode 100644 index 000000000..a5ac60044 --- /dev/null +++ b/frontend/src/relatedNodeCaption.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { RelatedNode } from "./api"; +import { relatedAffiliationNextAction, relatedNodeCaption } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("relatedNodeCaption", () => { + it("names the side and unique org so the next click is a business walk", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + ), + ).toBe("Ada West, Demo Corp (Our side)"); + }); + + it("names a known-plural set so the next action is the Keyman list", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + ), + ).toBe("Priya Nair, multiple organizations (Counterparty)"); + }); + + it("keeps a person with no affiliation side-only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_code: "counterparty", + person_side_label: "Counterparty", + }), + ), + ).toBe("Priya Nair (Counterparty)"); + }); + + it("keeps a unique org when the side label is missing", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + affiliation_organization_name: "Demo Corp", + ontology_label: "Person", + }), + ), + ).toBe("Ada West, Demo Corp"); + }); + + it("prefers the plural signal when a name is also present", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_organization_name: "Northridge Grid", + affiliation_ambiguous: true, + }), + ), + ).toBe("Priya Nair, multiple organizations (Counterparty)"); + }); + + it("tells the buyer to read the Keyman list when it is already on screen", () => { + expect(relatedAffiliationNextAction(true)).toBe( + "A chip that says multiple organizations is not a missing affiliation. " + + "Read every organization in the Keyman list above, then click the chip " + + "to continue the walk.", + ); + }); + + it("tells the buyer to extract Keymen when the list is empty", () => { + expect(relatedAffiliationNextAction(false)).toBe( + "A chip that says multiple organizations is not a missing affiliation. " + + "Extract Keymen to list every organization, then click the chip to " + + "continue the walk.", + ); + }); + + it("uses the entity-level label on organization chips", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + }), + ), + ).toBe("Demo Corp (Company)"); + }); + + it("shows the post title only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_post", + label: "Linked post", + ontology_label: "Post", + }), + ), + ).toBe("Linked post"); + }); +}); diff --git a/frontend/src/relatedNodeCaption.ts b/frontend/src/relatedNodeCaption.ts new file mode 100644 index 000000000..68fa524a5 --- /dev/null +++ b/frontend/src/relatedNodeCaption.ts @@ -0,0 +1,107 @@ +import type { RelatedNode } from "./api"; + +const NODE_PERSON = "node_person"; +const NODE_POST = "node_post"; +const NODE_CORPORATE_ENTITY = "node_corporate_entity"; + +type RelatedNodeKind = typeof NODE_PERSON | typeof NODE_POST | typeof NODE_CORPORATE_ENTITY; + +function isRelatedNodeKind(code: string): code is RelatedNodeKind { + return code === NODE_PERSON || code === NODE_POST || code === NODE_CORPORATE_ENTITY; +} + +/** + * Decision-facing label for a related-node chip. + * + * Person chips use the authorized side label and, when exactly one + * organization identity is known, that organization. A known-plural + * set uses "multiple organizations" even if a name is also present + * so a stale payload cannot invent a primary. That is not the same + * as a missing affiliation. + * A unique org without a side still names the org so a missing side + * cannot revive the ontology-class caption. Organization chips use + * the entity-level label. Post chips are the title only. + */ +export function relatedNodeCaption(node: RelatedNode): string { + const name = node.label?.trim() || node.node_id; + const kind = node.node_type_code; + if (!isRelatedNodeKind(kind)) { + return `${name} (${node.ontology_label ?? kind})`; + } + switch (kind) { + case NODE_PERSON: { + const side = node.person_side_label?.trim() || node.person_side_code?.trim(); + const org = node.affiliation_organization_name?.trim(); + const context = node.affiliation_ambiguous ? "multiple organizations" : org || ""; + if (side && context) { + return `${name}, ${context} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + if (context) { + return `${name}, ${context}`; + } + return `${name} (${node.ontology_label ?? kind})`; + } + case NODE_CORPORATE_ENTITY: { + const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); + if (level) { + return `${name} (${level})`; + } + return `${name} (${node.ontology_label ?? kind})`; + } + case NODE_POST: + return name; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +export type RelatedNodeChipAction = "walk_person" | "walk_entity" | "open_post"; + +/** + * Accessible name for a related-node chip. + * + * The visible caption is contained in the name (WCAG 2.2 Success + * Criterion 2.5.3). Walk chips continue the graph. Post chips open + * the evidence body. + */ +export function relatedNodeChipAccessibleName( + caption: string, + action: RelatedNodeChipAction, +): string { + switch (action) { + case "walk_person": + case "walk_entity": + return `Related nodes for ${caption}`; + case "open_post": + return `Open related post: ${caption}`; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +/** + * Next action when a related-node chip marks a known-plural affiliation + * set. The Keyman list is the full N:N surface; the chip click continues + * the walk and must not be mistaken for "this person has no organization." + */ +export function relatedAffiliationNextAction(hasKeymanList: boolean): string { + if (hasKeymanList) { + return ( + "A chip that says multiple organizations is not a missing affiliation. " + + "Read every organization in the Keyman list above, then click the chip " + + "to continue the walk." + ); + } + return ( + "A chip that says multiple organizations is not a missing affiliation. " + + "Extract Keymen to list every organization, then click the chip to " + + "continue the walk." + ); +} diff --git a/frontend/src/relatedNodeTokens.css b/frontend/src/relatedNodeTokens.css new file mode 100644 index 000000000..be299dead --- /dev/null +++ b/frontend/src/relatedNodeTokens.css @@ -0,0 +1,32 @@ +:root { + --related-node-chip-font: inherit; + --related-node-chip-color: inherit; + --related-node-chip-padding: 0; + --related-node-chip-background: none; + --related-node-chip-border: none; + --related-node-chip-text-align: left; + --related-node-chip-cursor: pointer; + --related-node-hint-font-size: 0.9rem; + --related-node-hint-margin-block-end: 0.5rem; + --related-node-hint-color: inherit; +} + +.related-node-chip { + background: var(--related-node-chip-background); + border: var(--related-node-chip-border); + padding: var(--related-node-chip-padding); + color: var(--related-node-chip-color); + cursor: var(--related-node-chip-cursor); + font: var(--related-node-chip-font); + text-align: var(--related-node-chip-text-align); +} + +.related-node-chip:hover { + text-decoration: underline; +} + +.related-affiliation-hint { + margin: 0 0 var(--related-node-hint-margin-block-end); + font-size: var(--related-node-hint-font-size); + color: var(--related-node-hint-color); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 0ac8e50fe..b5a55a249 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.71.0" +__version__ = "0.77.0" diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 7ec67e943..200ffaccd 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -186,8 +186,8 @@ class ContextualOrchestratorPostChatClient: -- a chat answer with citations is exactly the checked-judgment shape ``mode="verify"`` exists for (one worker call plus one checked verifier judgment), same reasoning ``adjudication_client`` already - uses, not ``keyman_extraction``/``entity_relationship_classification``'s - single-pass ``mode="route"`` structured extraction. + uses, not the ordinary adapters' single-pass adaptive + structured extraction. """ available = True diff --git a/pyproject.toml b/pyproject.toml index 9a2272d3a..fcb40baf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.71.0" +version = "0.77.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_adaptive_orchestrator_default.py b/tests/test_adaptive_orchestrator_default.py new file mode 100644 index 000000000..6b88197d3 --- /dev/null +++ b/tests/test_adaptive_orchestrator_default.py @@ -0,0 +1,111 @@ +"""LineageWeave delegates product-default LLM execution to auto policy.""" + +from __future__ import annotations + +from pathlib import Path + +from lineageweave import adjudication_client, post_chat, post_evaluation +from lineageweave.post_chat import ChatSourceDocument, ContextualOrchestratorPostChatClient + + +def test_post_evaluation_adapter_defaults_to_auto(monkeypatch) -> None: + observed: dict[str, object] = {} + + def fake_post_json(url, payload, *, headers, timeout): + observed.update( + url=url, + payload=payload, + headers=headers, + timeout=timeout, + ) + return {"choices": [{"message": {"content": "{}"}}]} + + monkeypatch.setattr(post_evaluation, "post_json", fake_post_json) + adapter = post_evaluation._OrchestratorCompleteAdapter( + "https://orchestrator.example.test", "inference_token" + ) + adapter.complete([{"role": "user", "content": "Evaluate this evidence."}]) + + assert observed["payload"]["mode"] == "auto" + + +def test_post_evaluation_judge_uses_auto_by_default() -> None: + client = post_evaluation.ContextualOrchestratorPostEvaluationClient( + "https://orchestrator.example.test", "inference_token" + ) + assert client._judge.mode == "auto" + + +def test_post_chat_requests_verify_mode(monkeypatch) -> None: + """Citation chat must send verify on the wire, not a docstring mention of auto.""" + + observed: dict[str, object] = {} + + def fake_post_json(url, payload, *, headers, timeout): + observed["payload"] = payload + return { + "choices": [ + { + "message": { + "content": ( + '{"answer_text": "The follow-up names the same bid.",' + ' "cited_source_numbers": [1]}' + ) + } + } + ] + } + + monkeypatch.setattr(post_chat, "post_json", fake_post_json) + client = ContextualOrchestratorPostChatClient( + "https://orchestrator.example.test", "inference_token" + ) + answer = client.answer( + "What happened between these events?", + [ + ChatSourceDocument( + post_id="post-bid-follow-up", + post_title="Bid follow-up", + post_body="Northridge asked to confirm the bid date.", + ) + ], + ) + + assert answer.cited_post_ids == ("post-bid-follow-up",) + assert observed["payload"]["mode"] == "verify" + + +def test_adjudication_requests_verify_mode(monkeypatch) -> None: + """Lineage adjudication must send verify on the wire, not a source substring.""" + + observed: dict[str, object] = {} + + def fake_post_json(url, payload, *, headers, timeout): + observed["payload"] = payload + return {"choices": [{"message": {"content": "0.91"}}]} + + monkeypatch.setattr(adjudication_client, "post_json", fake_post_json) + client = adjudication_client.ContextualOrchestratorAdjudicationClient( + "https://orchestrator.example.test", "inference_token" + ) + confidence = client.judge( + "Quarterly budget review meeting notes", + "Budget review follow-up: revised quarterly numbers", + ) + + assert confidence == 0.91 + assert observed["payload"]["mode"] == "verify" + + +def test_runtime_clients_do_not_force_single_model_route() -> None: + package_root = Path(__file__).resolve().parents[1] / "lineageweave" + violations: list[str] = [] + for path in sorted(package_root.glob("*.py")): + text = path.read_text(encoding="utf-8") + if '"mode": "route"' in text or "'mode': 'route'" in text: + violations.append(f"{path.name}: request payload") + if 'mode="route"' in text or "mode='route'" in text: + violations.append(f"{path.name}: constructor/call default") + if 'mode: str = "route"' in text or "mode: str = 'route'" in text: + violations.append(f"{path.name}: typed default") + assert violations == [] diff --git a/tests/test_contextual_orchestrator_default_policy.py b/tests/test_contextual_orchestrator_default_policy.py new file mode 100644 index 000000000..0b2472989 --- /dev/null +++ b/tests/test_contextual_orchestrator_default_policy.py @@ -0,0 +1,69 @@ +"""Contract tests for adaptive contextual-orchestrator consumer defaults.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +AUTO_CLIENTS = ( + "lineageweave/post_summary.py", + "lineageweave/post_evaluation.py", + "lineageweave/keyman_extraction.py", + "lineageweave/commitment_extraction.py", + "lineageweave/entity_relationship_classification.py", +) +VERIFY_CLIENTS = ( + "lineageweave/post_chat.py", + "lineageweave/adjudication_client.py", +) +_ROUTE_MARKERS = ( + '"mode": "route"', + 'mode="route"', + 'mode: str = "route"', +) + + +def _source(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +class AdaptiveOrchestratorDefaultTest(unittest.TestCase): + """Protect production clients from regressing to forced one-model routing.""" + + def test_auto_clients_request_auto_and_never_force_route(self) -> None: + for relative in AUTO_CLIENTS: + source = _source(relative) + with self.subTest(path=relative): + for marker in _ROUTE_MARKERS: + self.assertNotIn(marker, source) + if relative.endswith("post_evaluation.py"): + self.assertIn('"mode": mode', source) + self.assertIn('mode: str = "auto"', source) + continue + self.assertIn( + '"mode": "auto"', + source, + f"{relative} must send a payload-level mode=auto literal", + ) + + def test_verify_clients_keep_checked_judgment_and_never_force_route(self) -> None: + for relative in VERIFY_CLIENTS: + source = _source(relative) + with self.subTest(path=relative): + for marker in _ROUTE_MARKERS: + self.assertNotIn(marker, source) + self.assertIn( + '"mode": "verify"', + source, + f"{relative} must send a payload-level mode=verify literal", + ) + self.assertNotIn( + '"mode": "auto"', + source, + f"{relative} must send verify, not a payload-level auto default", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 1964f34b9..21f1182cd 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.71.0" +version = "0.77.0" source = { virtual = "." } dependencies = [ { name = "certifi" },