-
Notifications
You must be signed in to change notification settings - Fork 1
feat(ui): make plural affiliation chips actionable and multilingual #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bdae447
c5903bd
27a3648
b7c9f01
c1f6e43
5b1f6da
eb6822e
2c1b82a
172898e
8814291
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -449,6 +451,65 @@ 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: int | ||
| display_name: str | None = None | ||
|
|
||
| @property | ||
| def ambiguous(self) -> bool: | ||
| """Return whether more than one organization identity is known.""" | ||
| return self.identity_count > 1 | ||
|
|
||
|
|
||
| def compact_affiliation_summaries( | ||
| rows: list[Mapping[str, Any]], | ||
| ) -> dict[str, CompactAffiliation]: | ||
| """Summarize affiliations without inventing a primary organization.""" | ||
| 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: | ||
| display_name = next(iter(leftover_names), None) | ||
| if display_name is None and labels_by_id: | ||
| display_name = next(iter(labels_by_id.values())) | ||
| summaries[person_id] = CompactAffiliation(identity_count, display_name) | ||
| return summaries | ||
|
|
||
|
|
||
| async def hydrate_related_nodes( | ||
| conn: asyncpg.Connection, | ||
| related: list[tuple[str, float]], | ||
|
|
@@ -457,6 +518,8 @@ 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 and organization nodes carry decision-relevant affiliation and | ||
| entity-level labels when the catalog provides them. | ||
| """ | ||
| person_ids: list[str] = [] | ||
| post_ids: list[str] = [] | ||
|
|
@@ -482,6 +545,20 @@ 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 {} | ||
|
Comment on lines
+548
to
+561
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Affiliation summary query is not ABAC-filtered by visible posts In knowledge_graph.py, Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| posts = { | ||
| str(row["post_id"]): row | ||
| # Safe SQL: the eligibility predicate is an immutable schema fragment; post ids are bound. | ||
|
|
@@ -496,7 +573,8 @@ 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 {} | ||
|
|
@@ -511,6 +589,9 @@ async def hydrate_related_nodes( | |
| 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: | ||
|
|
@@ -525,12 +606,21 @@ async def hydrate_related_nodes( | |
| item["label"] = people[node_id]["person_name"] | ||
| 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"] | ||
| item["post_body_excerpt"] = posts[node_id]["post_body_excerpt"] | ||
| item["post_body_truncated"] = posts[node_id]["post_body_truncated"] | ||
| elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps: | ||
| item["label"] = corps[node_id]["entity_name"] | ||
| level = corps[node_id]["entity_level_code"] | ||
| item["entity_level_code"] = level | ||
| item["entity_level_label"] = level_labels.get(level, level) | ||
| elif node_type_code == NODE_TEAM and node_id in teams: | ||
| item["label"] = teams[node_id]["team_name"] | ||
| else: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # ADR 0106: Related-node chips use business context, not ontology class | ||
|
|
||
| - Status: Accepted | ||
| - Date: 2026-08-20 | ||
|
|
||
| ## Context | ||
|
|
||
| The related-node walk is a buyer decision surface. Showing only `Person`, | ||
| `Organization`, or `Post` does not identify the next useful action. A person | ||
| may have several memberships, and `person_affiliation` has no primary marker; | ||
| choosing the first sorted row would invent a primary organization. | ||
|
|
||
| ## Decision | ||
|
|
||
| - Use the authorized side label and a unique organization only when one | ||
| identity remains after catalog-id and case-folded alias reconciliation. | ||
| - Mark more than one identity as `affiliation_ambiguous` and render | ||
| `multiple organizations`; never expose a guessed primary. | ||
| - Use the cataloged entity level for organization chips and the source title | ||
| only for post chips. | ||
| - Keep the full affiliation list on the Keyman surface. The related panel | ||
| tells the buyer to read that list, or extract Keymen first, before clicking | ||
| the chip to continue the walk. | ||
| - Reuse `RelatedNodeChip` and `--related-node-*` design tokens for every | ||
| repeated walk control. Visible captions are included in accessible names. | ||
|
|
||
| ## Consequences | ||
|
|
||
| The compact walk remains scannable while retaining multiple-membership truth. | ||
| An unavailable or unresolved affiliation remains unavailable; it is never | ||
| converted into a plausible-sounding company name. Temporal membership | ||
| intervals remain a follow-up schema decision and are not inferred here. | ||
|
|
||
| ## References | ||
|
|
||
| See [RELATED_NODE_AFFILIATION_REFERENCES.md](../doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Related-node affiliation references | ||
|
|
||
| APA 7th sources for [ADR 0106](../adr/0106-related-node-business-captions.md). | ||
|
|
||
| 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 | ||
|
|
||
| World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines | ||
| (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ | ||
|
|
||
| W3C Design Tokens Community Group. (2025). *Design Tokens Format Module*. | ||
| https://www.w3.org/community/design-tokens/ | ||
|
|
||
| Singer, J. D., & Willett, J. B. (2003). *Applied longitudinal data analysis: | ||
| Modeling change and event occurrence*. Oxford University Press. | ||
|
|
||
| MMMC grounds the no-invented-primary rule; WCAG 2.2 grounds accessible names | ||
| that contain visible chip captions; the Design Tokens Format grounds the | ||
| shared repeated-control tokens. Singer and Willett document why a future | ||
| time-bounded affiliation schema must distinguish a former membership from a | ||
| current one. Until that schema exists, this feature counts stored identities | ||
| without asserting that they are current. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,16 @@ in [ADR 0064](adr/0064-lineage-evidence-and-tree-assembly.md), [ADR 0062](adr/00 | |
| and the existing channel-specific ADRs; update this file as literature and | ||
| validation evidence changes, not as an untracked architecture decision. | ||
|
|
||
| ## Related-node business captions | ||
|
|
||
| ADR 0103 keeps compact graph navigation truthful for multiple-membership | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Docs cite wrong ADR number for this feature The added text attributes the related-node caption feature to "ADR 0103", but ADR 0103 is the semantic-document-evidence-contract; this feature's ADR is 0106 (0106-related-node-business-captions.md), which the same line even links to. The number contradicts its own link and appears again at line 304. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| people: the UI uses an authorized unique affiliation only when one identity | ||
| remains and otherwise says `multiple organizations`. The full N:N evidence | ||
| stays on the Keyman surface, and the panel gives the buyer the next action. | ||
| The implementation and APA 7th sources are recorded in | ||
| [`docs/adr/0106-related-node-business-captions.md`](adr/0106-related-node-business-captions.md) | ||
| and [`docs/doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md`](doctoring/RELATED_NODE_AFFILIATION_REFERENCES.md). | ||
|
|
||
| ## The problem this is answering | ||
|
|
||
| Given a pile of short, timestamped records that are only loosely grouped | ||
|
|
@@ -261,10 +271,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 | ||
| 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 adaptive reasoning-effort allocation stays centralized with the | ||
| adjudication channel. Proven for real during development against | ||
| 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. | ||
|
|
||
|
|
@@ -279,7 +300,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 0103) 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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| @import "./relatedNodeTokens.css"; | ||
|
|
||
| /* App-level Shell Layout */ | ||
| .app-shell { | ||
| display: flex; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: compact_affiliation_summaries identity counting is conservative and well-tested
compact_affiliation_summaries(knowledge_graph.py) counts one identity per distinctaffiliated_corporate_entity_idplus each unresolved raw name whose case-folded form does not match a resolved catalog label. Notable behaviors verified against the new unit tests: two distinct catalog ids that happen to share the sameentity_nameare treated as two identities (ambiguous), a nameless resolved catalog id yields identity_count==1 with no display_name (side-only, not ambiguous), and unresolved aliases matching a catalog label collapse. The display_name when identity_count==1 depends on row iteration order only when a single catalog id carries multiple differing labels, which is a benign edge case. No inconsistency with the emittedaffiliation_ambiguous/affiliation_organization_namepayload contract.Was this helpful? React with 👍 or 👎 to provide feedback.