From 94d86ea3a0d8bc504772e89c52a2c6208d484154 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:43:42 +0000 Subject: [PATCH 01/10] fix(ai): rebase adaptive defaults onto current main Keep ADR-0013 and drop the colliding ADR-0005 copies. Record one Unreleased changelog entry, restore the runtime-adapter and post-evaluation transport regressions, and correct the leftover post-chat docstring so it no longer describes a forced route. Co-authored-by: Seongho Bae --- CHANGELOG.md | 10 ++++ lineageweave/post_chat.py | 2 +- tests/test_adaptive_orchestrator_default.py | 49 +++++++++++++++++++ ..._contextual_orchestrator_default_policy.py | 40 +++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/test_adaptive_orchestrator_default.py create mode 100644 tests/test_contextual_orchestrator_default_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0096828a2..709e76880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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] + +### 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; the explicit adjudication `verify` contract + remains unchanged. + ## [0.71.0] - 2026-08-14 ### Added diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 7ec67e943..d23624bc1 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -187,7 +187,7 @@ class ContextualOrchestratorPostChatClient: ``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. + single-pass ``mode="auto"`` structured extraction. """ available = True diff --git a/tests/test_adaptive_orchestrator_default.py b/tests/test_adaptive_orchestrator_default.py new file mode 100644 index 000000000..3de1dd412 --- /dev/null +++ b/tests/test_adaptive_orchestrator_default.py @@ -0,0 +1,49 @@ +"""LineageWeave delegates product-default LLM execution to auto policy.""" + +from __future__ import annotations + +from pathlib import Path + +from lineageweave import post_evaluation + + +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_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..6c5eb5d7c --- /dev/null +++ b/tests/test_contextual_orchestrator_default_policy.py @@ -0,0 +1,40 @@ +"""Contract tests for adaptive contextual-orchestrator consumer defaults.""" +from __future__ import annotations + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +ACTIVE_CLIENTS = ( + "lineageweave/post_summary.py", + "lineageweave/post_evaluation.py", + "lineageweave/keyman_extraction.py", + "lineageweave/commitment_extraction.py", + "lineageweave/post_chat.py", + "lineageweave/entity_relationship_classification.py", +) + + +class AdaptiveOrchestratorDefaultTest(unittest.TestCase): + """Protect production clients from regressing to forced one-model routing.""" + + def test_active_clients_use_auto_and_never_force_route(self) -> None: + for relative in ACTIVE_CLIENTS: + source = (ROOT / relative).read_text(encoding="utf-8") + with self.subTest(path=relative): + self.assertNotIn('"mode": "route"', source) + self.assertNotIn('mode="route"', source) + self.assertNotIn('mode: str = "route"', source) + self.assertTrue( + '"mode": "auto"' in source + or 'mode="auto"' in source + or 'mode: str = "auto"' in source + ) + + def test_high_stakes_adjudication_retains_explicit_checked_override(self) -> None: + source = (ROOT / "lineageweave/adjudication_client.py").read_text(encoding="utf-8") + self.assertIn('"mode": "verify"', source) + + +if __name__ == "__main__": + unittest.main() From 9ff7ad1cc0192a427ffc58b1cd861ac2f434157d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:59:07 +0000 Subject: [PATCH 02/10] test(ai): lock auto and verify orchestrator transport contracts Keep post-chat and adjudication off the auto source-scan list so a docstring mention cannot satisfy the product default. Assert both checked-judgment clients send mode=verify on the wire. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 14 ++-- CHANGELOG.md | 4 +- tests/test_adaptive_orchestrator_default.py | 64 +++++++++++++++++- ..._contextual_orchestrator_default_policy.py | 65 +++++++++++++++---- 4 files changed, 123 insertions(+), 24 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95d..094a0aeec 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 709e76880..d4b13c04d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ All notable changes to this project are documented here. Format follows - 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; the explicit adjudication `verify` contract - remains unchanged. + conducted workflow. Citation-bearing post-chat and lineage + adjudication keep their explicit `verify` contracts. ## [0.71.0] - 2026-08-14 diff --git a/tests/test_adaptive_orchestrator_default.py b/tests/test_adaptive_orchestrator_default.py index 3de1dd412..6b88197d3 100644 --- a/tests/test_adaptive_orchestrator_default.py +++ b/tests/test_adaptive_orchestrator_default.py @@ -4,7 +4,8 @@ from pathlib import Path -from lineageweave import post_evaluation +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: @@ -35,6 +36,67 @@ def test_post_evaluation_judge_uses_auto_by_default() -> None: 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] = [] diff --git a/tests/test_contextual_orchestrator_default_policy.py b/tests/test_contextual_orchestrator_default_policy.py index 6c5eb5d7c..24a023e1a 100644 --- a/tests/test_contextual_orchestrator_default_policy.py +++ b/tests/test_contextual_orchestrator_default_policy.py @@ -1,39 +1,76 @@ """Contract tests for adaptive contextual-orchestrator consumer defaults.""" + from __future__ import annotations from pathlib import Path import unittest ROOT = Path(__file__).resolve().parents[1] -ACTIVE_CLIENTS = ( +AUTO_CLIENTS = ( "lineageweave/post_summary.py", "lineageweave/post_evaluation.py", "lineageweave/keyman_extraction.py", "lineageweave/commitment_extraction.py", - "lineageweave/post_chat.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"', +) +_AUTO_MARKERS = ( + '"mode": "auto"', + 'mode="auto"', + 'mode: str = "auto"', +) +_VERIFY_MARKERS = ( + '"mode": "verify"', + 'mode="verify"', + 'mode: str = "verify"', +) + + +def _source(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def _contains_any(source: str, markers: tuple[str, ...]) -> bool: + return any(marker in source for marker in markers) class AdaptiveOrchestratorDefaultTest(unittest.TestCase): """Protect production clients from regressing to forced one-model routing.""" - def test_active_clients_use_auto_and_never_force_route(self) -> None: - for relative in ACTIVE_CLIENTS: - source = (ROOT / relative).read_text(encoding="utf-8") + 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): - self.assertNotIn('"mode": "route"', source) - self.assertNotIn('mode="route"', source) - self.assertNotIn('mode: str = "route"', source) + for marker in _ROUTE_MARKERS: + self.assertNotIn(marker, source) self.assertTrue( - '"mode": "auto"' in source - or 'mode="auto"' in source - or 'mode: str = "auto"' in source + _contains_any(source, _AUTO_MARKERS), + f"{relative} must request mode=auto in executable source", ) - def test_high_stakes_adjudication_retains_explicit_checked_override(self) -> None: - source = (ROOT / "lineageweave/adjudication_client.py").read_text(encoding="utf-8") - self.assertIn('"mode": "verify"', source) + 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.assertTrue( + _contains_any(source, _VERIFY_MARKERS), + f"{relative} must request mode=verify in executable source", + ) + self.assertNotIn( + '"mode": "auto"', + source, + f"{relative} must send verify, not a payload-level auto default", + ) if __name__ == "__main__": From 1b00bf407e1ee9a42164ef77c1d2b7da8584656a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:00:20 +0000 Subject: [PATCH 03/10] docs: describe Keyman extraction as auto, not forced route The live client already sends mode=auto. Keep the research note aligned with ADR-0013 so operators do not reintroduce route. Co-authored-by: Seongho Bae --- docs/lineage-bi-research-notes.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index acd55620b..07079230b 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -254,9 +254,10 @@ 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 +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. From 7c7d0d97dfa4f2e24d9cf145b76b1e9ad9420938 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:15:40 +0000 Subject: [PATCH 04/10] test(ai): require payload literals for auto and verify modes Source scans now demand the executable "mode": "auto" / "mode": "verify" literals so a docstring mention cannot satisfy ADR-0013. Rephrase the post-chat contrast sentence so it no longer contains mode="auto". Co-authored-by: Seongho Bae --- CHANGELOG.md | 19 ++++++++++- lineageweave/post_chat.py | 4 +-- ..._contextual_orchestrator_default_policy.py | 32 +++++++------------ 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b13c04d..f2e08bb44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,24 @@ All notable changes to this project are documented here. Format follows `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. + 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 + affiliations. Walking from Demo Corp shows + `Ada West, Demo Corp (Our side)` and `Demo Corp (Company)`. A person + with exactly one affiliation adds that org; multiple affiliations + keep the side-only caption. Post chips show the title only. Click + the chip to continue the walk, or open the Keyman/affiliate surfaces + when you need the full affiliation list. ## [0.71.0] - 2026-08-14 diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index d23624bc1..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="auto"`` structured extraction. + uses, not the ordinary adapters' single-pass adaptive + structured extraction. """ available = True diff --git a/tests/test_contextual_orchestrator_default_policy.py b/tests/test_contextual_orchestrator_default_policy.py index 24a023e1a..0b2472989 100644 --- a/tests/test_contextual_orchestrator_default_policy.py +++ b/tests/test_contextual_orchestrator_default_policy.py @@ -22,26 +22,12 @@ 'mode="route"', 'mode: str = "route"', ) -_AUTO_MARKERS = ( - '"mode": "auto"', - 'mode="auto"', - 'mode: str = "auto"', -) -_VERIFY_MARKERS = ( - '"mode": "verify"', - 'mode="verify"', - 'mode: str = "verify"', -) def _source(relative: str) -> str: return (ROOT / relative).read_text(encoding="utf-8") -def _contains_any(source: str, markers: tuple[str, ...]) -> bool: - return any(marker in source for marker in markers) - - class AdaptiveOrchestratorDefaultTest(unittest.TestCase): """Protect production clients from regressing to forced one-model routing.""" @@ -51,9 +37,14 @@ def test_auto_clients_request_auto_and_never_force_route(self) -> None: with self.subTest(path=relative): for marker in _ROUTE_MARKERS: self.assertNotIn(marker, source) - self.assertTrue( - _contains_any(source, _AUTO_MARKERS), - f"{relative} must request mode=auto in executable 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: @@ -62,9 +53,10 @@ def test_verify_clients_keep_checked_judgment_and_never_force_route(self) -> Non with self.subTest(path=relative): for marker in _ROUTE_MARKERS: self.assertNotIn(marker, source) - self.assertTrue( - _contains_any(source, _VERIFY_MARKERS), - f"{relative} must request mode=verify in executable source", + self.assertIn( + '"mode": "verify"', + source, + f"{relative} must send a payload-level mode=verify literal", ) self.assertNotIn( '"mode": "auto"', From 6c29b2f969c980b8026e1a325546c021734009d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:15:48 +0000 Subject: [PATCH 05/10] feat(ui): show business context on related-node chips (v0.75.0) Related-node chips use side and entity-level labels instead of ontology class names. A person chip adds an organization only when exactly one affiliation is known, so Priya Nair stays Counterparty-only rather than inventing a Northridge Grid primary. Post chips show the title only. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 10 ++- backend/app/knowledge_graph.py | 45 ++++++++++++- backend/tests/test_api.py | 22 +++++++ ...test_related_node_affiliation_ambiguity.py | 66 +++++++++++++++++++ .../0014-related-node-business-captions.md | 46 +++++++++++++ docs/lineage-bi-research-notes.md | 12 +++- frontend/package.json | 2 +- frontend/src/App.test.tsx | 59 ++++++++++++++--- frontend/src/App.tsx | 30 ++++++++- frontend/src/api.ts | 4 ++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 13 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_related_node_affiliation_ambiguity.py create mode 100644 docs/adr/0014-related-node-business-captions.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 094a0aeec..550f6d41d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -320,7 +320,15 @@ 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 an affiliation only when exactly one distinct +non-empty affiliation is known (`Ada West, Demo Corp (Our side)`), +not the ontology class (`Ada West (Person)`). Multiple affiliations +are omitted rather than collapsed into an invented primary +organization -- open the Keyman list to see every affiliation. 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/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index bb398d141..ce51b4c3c 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -283,6 +283,9 @@ 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 non-empty affiliation is known; ambiguous affiliations are + omitted rather than collapsed into an invented primary organization. """ person_ids: list[str] = [] post_ids: list[str] = [] @@ -305,6 +308,28 @@ async def hydrate_related_nodes( person_ids, ) } if person_ids else {} + affiliation_names_by_person: dict[str, set[str]] = {} + if person_ids: + affiliation_rows = await conn.fetch( + """ + select person_id, affiliated_organization_name + from person_affiliation + where person_id = any($1::uuid[]) + order by person_id, affiliated_organization_name + """, + person_ids, + ) + for row in affiliation_rows: + person_id = str(row["person_id"]) + org = (row["affiliated_organization_name"] or "").strip() + if not org: + continue + affiliation_names_by_person.setdefault(person_id, set()).add(org) + affiliations = { + person_id: next(iter(organization_names)) + for person_id, organization_names in affiliation_names_by_person.items() + if len(organization_names) == 1 + } posts = { str(row["post_id"]): row for row in await conn.fetch( @@ -315,11 +340,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 +362,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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 74ab44701..62c7356fe 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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( @@ -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 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..998ba32bb --- /dev/null +++ b/backend/tests/test_related_node_affiliation_ambiguity.py @@ -0,0 +1,66 @@ +"""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" + + +class _FakeConnection: + """Return the minimum query results needed by ``hydrate_related_nodes``.""" + + def __init__(self, affiliations: list[str]) -> 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": organization_name, + "affiliated_corporate_entity_id": None, + } + for organization_name 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[str]) -> 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(["Northridge Grid"]) + assert node["affiliation_organization_name"] == "Northridge Grid" + assert node["person_side_label"] == "Counterparty" + + +def test_related_person_omits_affiliation_when_multiple_are_known() -> None: + """Multiple affiliations must not be collapsed into an invented primary one.""" + node = _hydrate(["Northridge Grid", "Northridge Holdings"]) + assert "affiliation_organization_name" not in node + assert node["person_side_label"] == "Counterparty" 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..a4f292d5e --- /dev/null +++ b/docs/adr/0014-related-node-business-captions.md @@ -0,0 +1,46 @@ +# 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 non-empty affiliation is known. +- Organization chips use `entity_level_label` (fallback: raw code). +- Post chips show the post title only. + +The same caption is the interactive control's accessible name. Full +affiliation lists stay on the Keyman and affiliate-tree surfaces. + +## Consequences + +Walking from Ada West shows `Priya Nair (Counterparty)` rather than +`Priya Nair, Northridge Grid (Counterparty)`. Walking from Demo Corp +shows `Ada West, Demo Corp (Our side)` and `Demo Corp (Company)`. +Click a chip to continue the walk. Open the Keyman list when you need +every affiliation. + +## References + +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 diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index 07079230b..7f0b8cfc7 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -253,7 +253,12 @@ 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 +the same post), not a second independent NER pass. Compact related-node +chips therefore add an organization only when the affiliation set has +cardinality one; collapsing several memberships into a sorted "primary" +would repeat the atomistic fallacy Browne et al. (2001) warn against +for multiple-membership structures. Open the Keyman list to read every +affiliation. 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 @@ -272,7 +277,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..575b7c586 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.71.0", + "version": "0.75.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 415e1419f..50991ad69 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,8 @@ 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", relevance: 0.4, }, { @@ -549,6 +554,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 +574,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 +979,18 @@ 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 (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("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 +1003,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 (Counterparty)" }), + ).toBeInTheDocument(); }); it("opens related nodes from a related corporate entity", async () => { @@ -991,9 +1014,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 +1053,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 +1064,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 +1075,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 +1086,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 +1098,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..af3d9e348 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -469,6 +469,30 @@ const NODE_PERSON = "node_person"; const NODE_POST = "node_post"; const NODE_CORPORATE_ENTITY = "node_corporate_entity"; +function relatedNodeCaption(node: RelatedNode): string { + const name = node.label ?? node.node_id; + if (node.node_type_code === NODE_PERSON) { + const side = node.person_side_label ?? node.person_side_code; + const org = node.affiliation_organization_name?.trim(); + if (side && org) { + return `${name}, ${org} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + } + if (node.node_type_code === NODE_CORPORATE_ENTITY) { + const level = node.entity_level_label ?? node.entity_level_code; + if (level) { + return `${name} (${level})`; + } + } + if (node.node_type_code === NODE_POST) { + return name; + } + return `${name} (${node.ontology_label ?? node.node_type_code})`; +} + const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -677,7 +701,7 @@ function KeymanPanel({ ) : (
    {related.map((node) => { - const caption = `${node.label ?? node.node_id} (${node.ontology_label ?? node.node_type_code})`; + const caption = relatedNodeCaption(node); if (node.node_type_code === NODE_POST && onSelectPost) { return (
  • @@ -696,7 +720,7 @@ function KeymanPanel({
  • -
  • - ); - } - if (node.node_type_code === NODE_PERSON) { - return ( -
  • - -
  • - ); - } - if (node.node_type_code === NODE_CORPORATE_ENTITY) { - return ( -
  • - -
  • - ); - } - return ( -
  • {caption}
  • - ); - })} + {related.map((node) => ( +
  • + { + if (selected.node_type_code === NODE_POST && onSelectPost) { + onSelectPost(selected.node_id); + return; + } + if (selected.node_type_code === NODE_PERSON) { + handleSelect(selected.node_id, selected.label ?? selected.node_id); + return; + } + if (selected.node_type_code === NODE_CORPORATE_ENTITY) { + handleSelectEntity(selected.node_id, selected.label ?? selected.node_id); + } + }} + /> +
  • + ))}
)} diff --git a/frontend/src/RelatedNodeChip.stories.tsx b/frontend/src/RelatedNodeChip.stories.tsx new file mode 100644 index 000000000..f4d8035de --- /dev/null +++ b/frontend/src/RelatedNodeChip.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +const meta = { + title: "Walk/RelatedNodeChip", + component: RelatedNodeChip, + parameters: { + docs: { + description: { + component: + "Click a person or organization chip to continue the Keyman walk. When the caption says multiple organizations, open the Keyman list. Click a post chip to open that source.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const UniqueAffiliation: Story = { + args: { + node: node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + onSelect: () => undefined, + }, +}; + +export const PluralAffiliations: Story = { + args: { + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }), + onSelect: () => undefined, + }, +}; + +export const MissingAffiliation: Story = { + args: { + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + onSelect: () => undefined, + }, +}; + +export const OrganizationLevel: Story = { + args: { + node: node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + }), + onSelect: () => undefined, + }, +}; + +export const RelatedPost: Story = { + args: { + node: node({ + node_type_code: "node_post", + label: "Linked post", + ontology_label: "Post", + }), + onSelect: () => undefined, + }, +}; diff --git a/frontend/src/RelatedNodeChip.test.tsx b/frontend/src/RelatedNodeChip.test.tsx new file mode 100644 index 000000000..3f902daba --- /dev/null +++ b/frontend/src/RelatedNodeChip.test.tsx @@ -0,0 +1,60 @@ +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 { relatedNodeAriaLabel, relatedNodeCaption } from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("RelatedNodeChip", () => { + it("keeps the visible caption inside the accessible name", () => { + const priya = node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + affiliation_ambiguous: true, + }); + const caption = relatedNodeCaption(priya); + expect(caption).toBe("Priya Nair, multiple organizations (Counterparty)"); + render( undefined} />); + expect( + screen.getByRole("button", { name: relatedNodeAriaLabel(priya, caption) }), + ).toHaveTextContent(caption); + }); + + it("continues the walk when the unique-org chip is clicked", async () => { + const ada = node({ + node_id: "ada-1", + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }); + const onSelect = vi.fn(); + render(); + await userEvent.click( + screen.getByRole("button", { name: "Related nodes for Ada West, Demo Corp (Our side)" }), + ); + expect(onSelect).toHaveBeenCalledWith(ada); + }); + + it("opens the related post from the title-only chip", async () => { + const post = node({ + node_id: "post-1", + node_type_code: "node_post", + label: "Linked post", + ontology_label: "Post", + }); + const onSelect = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); + expect(onSelect).toHaveBeenCalledWith(post); + }); +}); diff --git a/frontend/src/RelatedNodeChip.tsx b/frontend/src/RelatedNodeChip.tsx new file mode 100644 index 000000000..fae5dc248 --- /dev/null +++ b/frontend/src/RelatedNodeChip.tsx @@ -0,0 +1,41 @@ +import type { RelatedNode } from "./api"; +import { NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST } from "./nodeTypes"; +import { relatedNodeAriaLabel, relatedNodeCaption } from "./relatedNodeCaption"; + +function isWalkChip(node: RelatedNode): boolean { + return ( + node.node_type_code === NODE_PERSON || + node.node_type_code === NODE_CORPORATE_ENTITY || + node.node_type_code === NODE_POST + ); +} + +/** + * Compact related-node control used in the Keyman walk. + * + * Click a person or organization chip to continue the walk. Click a + * post chip to open that source. When the caption says + * "multiple organizations", open the Keyman list for every affiliation. + */ +export function RelatedNodeChip({ + node, + onSelect, +}: { + node: RelatedNode; + onSelect: (node: RelatedNode) => void; +}) { + const caption = relatedNodeCaption(node); + if (!isWalkChip(node)) { + return {caption}; + } + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb331302..e063a741e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,10 +1,12 @@ +@import "./tokens/tokens.css"; + :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; + --text: var(--color-text); + --text-h: var(--color-heading); + --bg: var(--color-canvas); + --border: var(--color-border); --code-bg: #f4f3ec; - --accent: #aa3bff; + --accent: var(--color-accent); --accent-bg: rgba(170, 59, 255, 0.1); --accent-border: rgba(170, 59, 255, 0.5); --social-bg: rgba(244, 243, 236, 0.5); @@ -32,12 +34,12 @@ @media (prefers-color-scheme: dark) { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; + --text: var(--color-text); + --text-h: var(--color-heading); + --bg: var(--color-canvas); + --border: var(--color-border); --code-bg: #1f2028; - --accent: #c084fc; + --accent: var(--color-accent); --accent-bg: rgba(192, 132, 252, 0.15); --accent-border: rgba(192, 132, 252, 0.5); --social-bg: rgba(47, 48, 58, 0.5); diff --git a/frontend/src/nodeTypes.ts b/frontend/src/nodeTypes.ts new file mode 100644 index 000000000..227ac9aef --- /dev/null +++ b/frontend/src/nodeTypes.ts @@ -0,0 +1,14 @@ +/** Graph node type codes used by related-node chips and walks. */ + +export const NODE_PERSON = "node_person"; +export const NODE_POST = "node_post"; +export const NODE_CORPORATE_ENTITY = "node_corporate_entity"; + +export type RelatedNodeKind = + | typeof NODE_PERSON + | typeof NODE_POST + | typeof NODE_CORPORATE_ENTITY; + +export function isRelatedNodeKind(code: string): code is RelatedNodeKind { + return code === NODE_PERSON || code === NODE_POST || code === NODE_CORPORATE_ENTITY; +} diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts index 406bbbf29..37024d983 100644 --- a/frontend/src/relatedNodeCaption.test.ts +++ b/frontend/src/relatedNodeCaption.test.ts @@ -38,6 +38,20 @@ describe("relatedNodeCaption", () => { ).toBe("Priya Nair, multiple organizations (Counterparty)"); }); + it("keeps a stale name-plus-ambiguous payload plural", () => { + 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("keeps a person with no affiliation side-only", () => { expect( relatedNodeCaption( diff --git a/frontend/src/relatedNodeCaption.ts b/frontend/src/relatedNodeCaption.ts index 7e231393f..081aa37d7 100644 --- a/frontend/src/relatedNodeCaption.ts +++ b/frontend/src/relatedNodeCaption.ts @@ -1,14 +1,10 @@ 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; -} +import { + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + isRelatedNodeKind, +} from "./nodeTypes"; /** * Decision-facing label for a related-node chip. @@ -17,10 +13,10 @@ function isRelatedNodeKind(code: string): code is RelatedNodeKind { * 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. + * 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; @@ -81,3 +77,11 @@ export function relatedAffiliationNextAction(hasKeymanList: boolean): string { "continue the walk." ); } + +/** Accessible name that contains the visible caption (WCAG 2.5.3). */ +export function relatedNodeAriaLabel(node: RelatedNode, caption: string): string { + if (node.node_type_code === NODE_POST) { + return `Open related post: ${caption}`; + } + return `Related nodes for ${caption}`; +} diff --git a/frontend/src/tokens/design-tokens.json b/frontend/src/tokens/design-tokens.json new file mode 100644 index 000000000..895d7c53b --- /dev/null +++ b/frontend/src/tokens/design-tokens.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://design-tokens.github.io/community-group/format/", + "color": { + "text": { + "$type": "color", + "$value": "#6b6375", + "$description": "Body copy on light canvas." + }, + "heading": { + "$type": "color", + "$value": "#08060d", + "$description": "Headings and chip labels." + }, + "canvas": { + "$type": "color", + "$value": "#ffffff", + "$description": "Page and panel background." + }, + "border": { + "$type": "color", + "$value": "#e5e4e7", + "$description": "Hairline around lists and chips." + }, + "accent": { + "$type": "color", + "$value": "#aa3bff", + "$description": "Focus and selected walk affordance." + }, + "danger": { + "$type": "color", + "$value": "#b91c1c", + "$description": "Recoverable error text that names the next action." + } + }, + "space": { + "chip-gap": { + "$type": "dimension", + "$value": "0.75rem", + "$description": "Gap between related-node chips in a walk list." + }, + "panel-inset": { + "$type": "dimension", + "$value": "2rem", + "$description": "Inner padding of the post popup and evidence panel." + } + }, + "radius": { + "chip-edge": { + "$type": "dimension", + "$value": "0.25rem", + "$description": "Related-node and citation chip corner radius." + }, + "panel-edge": { + "$type": "dimension", + "$value": "0.75rem", + "$description": "Popup and evidence panel corner radius." + } + } +} diff --git a/frontend/src/tokens/designTokens.test.ts b/frontend/src/tokens/designTokens.test.ts new file mode 100644 index 000000000..ed7cab2c1 --- /dev/null +++ b/frontend/src/tokens/designTokens.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const tokenDir = dirname(fileURLToPath(import.meta.url)); + +type TokenLeaf = { + $type: string; + $value: string; + $description?: string; +}; + +function isTokenLeaf(value: unknown): value is TokenLeaf { + if (value === null || typeof value !== "object") { + return false; + } + const record = value as Record; + return typeof record.$type === "string" && typeof record.$value === "string"; +} + +function walkTokens(node: unknown, prefix: string[]): Array<{ path: string; token: TokenLeaf }> { + if (isTokenLeaf(node)) { + return [{ path: prefix.join("."), token: node }]; + } + if (node === null || typeof node !== "object") { + return []; + } + const leaves: Array<{ path: string; token: TokenLeaf }> = []; + for (const [key, child] of Object.entries(node as Record)) { + if (key.startsWith("$")) { + continue; + } + leaves.push(...walkTokens(child, [...prefix, key])); + } + return leaves; +} + +describe("design tokens", () => { + it("keeps every token on a two-segment path and maps it to CSS", () => { + const catalog = JSON.parse(readFileSync(join(tokenDir, "design-tokens.json"), "utf8")) as Record< + string, + unknown + >; + const css = readFileSync(join(tokenDir, "tokens.css"), "utf8"); + const leaves = walkTokens(catalog, []); + expect(leaves.length).toBeGreaterThan(0); + for (const { path, token } of leaves) { + const segments = path.split("."); + expect(segments.length).toBeGreaterThanOrEqual(2); + expect(token.$value.trim()).not.toBe(""); + const cssName = `--${segments.join("-")}`; + expect(css).toContain(`${cssName}:`); + } + }); +}); diff --git a/frontend/src/tokens/tokens.css b/frontend/src/tokens/tokens.css new file mode 100644 index 000000000..944bd768a --- /dev/null +++ b/frontend/src/tokens/tokens.css @@ -0,0 +1,34 @@ +/** + * Runtime aliases for frontend/src/tokens/design-tokens.json. + * Token paths are two-word groups (color.text, space.chip-gap) so CSS + * custom properties stay `--color-text`, `--space-chip-gap`. + */ +:root { + --color-text: #6b6375; + --color-heading: #08060d; + --color-canvas: #fff; + --color-border: #e5e4e7; + --color-accent: #aa3bff; + --color-danger: #b91c1c; + --space-chip-gap: 0.75rem; + --space-panel-inset: 2rem; + --radius-chip-edge: 0.25rem; + --radius-panel-edge: 0.75rem; + + --text: var(--color-text); + --text-h: var(--color-heading); + --bg: var(--color-canvas); + --border: var(--color-border); + --accent: var(--color-accent); +} + +@media (prefers-color-scheme: dark) { + :root { + --color-text: #9ca3af; + --color-heading: #f3f4f6; + --color-canvas: #16171d; + --color-border: #2e303a; + --color-accent: #c084fc; + --color-danger: #fca5a5; + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 6830b6f75..78c9bcbb7 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "node"], "allowArbitraryExtensions": true, "skipLibCheck": true, @@ -22,5 +22,6 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.stories.tsx"] } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index b5a55a249..2f70d5a2a 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.77.0" +__version__ = "0.78.0" diff --git a/pyproject.toml b/pyproject.toml index fcb40baf6..aec6c3d75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.77.0" +version = "0.78.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/uv.lock b/uv.lock index 21f1182cd..d6714c6ae 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.77.0" +version = "0.78.0" source = { virtual = "." } dependencies = [ { name = "certifi" },