From b983f558121324c4c7b3c9ad4f24ce01aa348ad5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:34:53 +0000 Subject: [PATCH] feat: walk team and organization related nodes (v0.86.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related-node RWR now loads team and organization mention edges, so a team-only follow-up is no longer an island. R&R team names become buttons. Thread-group run lists honor knowledge_cutoff. ADR 0018 — #125 already used ADR 0017 for POST /api/analysis-runs. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 18 +- .../0.86.0-related-nodes-team-org-walk.md | 2 + CHANGELOG.md | 18 ++ backend/app/analysis_run_ingestion.py | 1 + backend/app/knowledge_graph.py | 142 +++++++++++- backend/app/main.py | 31 +++ backend/app/post_summary_ingestion.py | 52 ++++- backend/tests/test_api.py | 156 +++++++++++++ ...016-analysis-run-knowledge-cutoff-posts.md | 3 + docs/adr/0018-related-nodes-team-org-walk.md | 67 ++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 19 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 58 +++++ frontend/src/App.tsx | 217 +++++++++++++----- frontend/src/api.ts | 17 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_person_mention_projection.py | 88 ++++++- uv.lock | 2 +- 19 files changed, 806 insertions(+), 91 deletions(-) create mode 100644 CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md create mode 100644 docs/adr/0018-related-nodes-team-org-walk.md create mode 100644 docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da35cc779..aa557bd7a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -256,11 +256,14 @@ pattern and then hide the action button so it cannot 503 again. `find_linked_post_ids` first expands to every post sharing a mentioned person before calling `backend/app/knowledge_graph.py::load_visible_subgraph` -- that function -only loads edges among an *already-known* post set (its other caller, -`related_for_person`, pre-resolves the full set itself), it does not -discover new posts on its own; a real bug from calling it with only the -single starting post was caught while building this and is now -regression-tested (`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`). +only loads edges among an *already-known* post set (its other callers, +`related_for_person` / `related_for_entity` / `related_for_team`, +pre-resolve the full set themselves), it does not discover new posts on +its own; a real bug from calling it with only the single starting post +was caught while building this and is now regression-tested +(`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`). +Person, team, and organization mention channels load independently +(ADR 0018): a team-only or organization-only post still walks. ### Frontend (`frontend/`) @@ -276,8 +279,9 @@ summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel affiliate tree (resolved ancestors plus unresolved org roots), Keyman + counterparty panels (a Keyman click loads RWR related nodes; a related corporate-entity node, a resolved Keyman affiliation, -or a classified name that resolves to a cataloged org continues -the same walk via `GET /api/corporate-entities/{id}/related`; +a classified name that resolves to a cataloged org, or an R&R team +continues the same walk via `GET /api/corporate-entities/{id}/related` +or `GET /api/teams/{id}/related`; `post_admin` can extract), and an in-popup chat whose cited sources open a sliding evidence panel (`EvidencePanel`, CSS diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md new file mode 100644 index 000000000..4efa8100f --- /dev/null +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -0,0 +1,2 @@ +Related-node walks include team and organization mention edges. Click an +R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. diff --git a/CHANGELOG.md b/CHANGELOG.md index fec2c509b..434c4d63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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.86.0] - 2026-08-16 + +### Added + +- Related-node walks now include team and organization mention edges. + After `make seed` and a summary that names 설계팀 on two posts, open + either post, click the R&R team, and open the sibling post (ADR 0018). + A team-only follow-up is no longer an island. +- `GET /api/teams/{team_id}/related` starts the same RWR walk Keyman + and corporate-entity related already use. Related team chips are + buttons. + +### Fixed + +- Thread-group analysis-run *lists* now require an in-cutoff visible + post. A later public post in that thread group no longer surfaces a + January run the account was not allowed to know. + ## [0.85.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 6a1449818..d26eb6f6e 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -52,6 +52,7 @@ and exists ( select 1 from source_post p where p.thread_group_key = scope.scope_key + and p.created_at <= run.knowledge_cutoff and ( p.visibility_code = 'public' or p.corporate_entity_id = any($2::uuid[]) diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 4035134c0..ce7289bb5 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -18,9 +18,13 @@ EDGE_AFFILIATION, EDGE_CO_MENTION, EDGE_MENTION, + EDGE_MENTION_ORGANIZATION, + EDGE_MENTION_TEAM, + EDGE_TEAM_AFFILIATION, NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, + NODE_TEAM, KnowledgeGraphEdgeSpec, adjacency_from_edges, knowledge_graph_edges_for_post, @@ -235,6 +239,16 @@ async def corporate_entity_exists(conn: asyncpg.Connection, entity_id: str) -> b return row is not None +async def team_exists(conn: asyncpg.Connection, team_id: str) -> bool: + """True when ``team_id`` is a UUID that exists in ``cataloged_team``.""" + try: + UUID(team_id) + except ValueError: + return False + row = await conn.fetchrow("select 1 from cataloged_team where team_id = $1", team_id) + return row is not None + + async def visible_mention_post_ids( conn: asyncpg.Connection, person_id: str, @@ -258,27 +272,57 @@ async def visible_affiliation_post_ids( entity_id: str, can_see_post, ) -> list[str]: - """Visible posts whose Keyman or R&R people affiliate with an entity.""" + """Visible posts that mention an entity via a person or a direct org mention.""" rows = await conn.fetch( """ select distinct post.post_id, post.visibility_code, post.corporate_entity_id, post.created_at - from person_affiliation affiliation - join combined_post_person_mention mention - on mention.person_id = affiliation.person_id - join source_post post on post.post_id = mention.post_id - where affiliation.affiliated_corporate_entity_id = $1 + from source_post post + where post.post_id in ( + select mention.post_id + from person_affiliation affiliation + join combined_post_person_mention mention + on mention.person_id = affiliation.person_id + where affiliation.affiliated_corporate_entity_id = $1 + union + select org_mention.post_id + from post_organization_mention org_mention + where org_mention.corporate_entity_id = $1 + ) order by post.created_at, post.post_id """, entity_id, ) return [str(row["post_id"]) for row in rows if can_see_post(row)] + +async def visible_team_mention_post_ids( + conn: asyncpg.Connection, + team_id: str, + can_see_post, +) -> list[str]: + """Visible post ids supported by a cataloged team mention.""" + rows = await conn.fetch( + """ + select post.post_id, post.visibility_code, post.corporate_entity_id + from post_team_mention mention + join source_post post on post.post_id = mention.post_id + where mention.team_id = $1 + order by post.created_at, post.post_id + """, + team_id, + ) + return [str(row["post_id"]) for row in rows if can_see_post(row)] + async def load_visible_subgraph( conn: asyncpg.Connection, visible_post_ids: list[str], ) -> list[KnowledgeGraphEdgeSpec]: - """Edges supported by at least one post the account may already see.""" + """Edges supported by at least one post the account may already see. + + Person, team, and organization mention channels are independent. A + team-only or organization-only post must still walk (ADR 0018). + """ if not visible_post_ids: return [] person_rows = await conn.fetch( @@ -287,7 +331,19 @@ async def load_visible_subgraph( visible_post_ids, ) person_ids = [row["person_id"] for row in person_rows] - if not person_ids: + team_rows = await conn.fetch( + "select distinct team_id from post_team_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + team_ids = [row["team_id"] for row in team_rows] + organization_rows = await conn.fetch( + "select distinct corporate_entity_id from post_organization_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + organization_ids = [row["corporate_entity_id"] for row in organization_rows] + if not person_ids and not team_ids and not organization_ids: return [] rows = await conn.fetch( """ @@ -326,6 +382,48 @@ async def load_visible_subgraph( and edge.target_node_id = any($2::uuid[])) ) ) + or ( + edge.edge_type_code = $8 + and ( + (edge.source_node_type_code = $4 + and edge.source_node_id = any($1::uuid[])) + or + (edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[])) + or + (edge.source_node_type_code = $9 + and edge.source_node_id = any($10::uuid[])) + or + (edge.target_node_type_code = $9 + and edge.target_node_id = any($10::uuid[])) + ) + ) + or ( + edge.edge_type_code = $11 + and ( + (edge.source_node_type_code = $9 + and edge.source_node_id = any($10::uuid[])) + or + (edge.target_node_type_code = $9 + and edge.target_node_id = any($10::uuid[])) + ) + ) + or ( + edge.edge_type_code = $12 + and ( + (edge.source_node_type_code = $4 + and edge.source_node_id = any($1::uuid[])) + or + (edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[])) + or + (edge.source_node_type_code = $13 + and edge.source_node_id = any($14::uuid[])) + or + (edge.target_node_type_code = $13 + and edge.target_node_id = any($14::uuid[])) + ) + ) """, visible_post_ids, person_ids, @@ -334,6 +432,13 @@ async def load_visible_subgraph( EDGE_CO_MENTION, NODE_PERSON, EDGE_AFFILIATION, + EDGE_MENTION_TEAM, + NODE_TEAM, + team_ids, + EDGE_TEAM_AFFILIATION, + EDGE_MENTION_ORGANIZATION, + NODE_CORPORATE_ENTITY, + organization_ids, ) return [edge_spec_from_row(row) for row in rows] @@ -349,6 +454,7 @@ async def hydrate_related_nodes( person_ids: list[str] = [] post_ids: list[str] = [] corp_ids: list[str] = [] + team_ids: list[str] = [] parsed: list[tuple[str, str, float]] = [] for key, score in related: node_type_code, node_id = parse_node_key(key) @@ -359,6 +465,8 @@ async def hydrate_related_nodes( post_ids.append(node_id) elif node_type_code == NODE_CORPORATE_ENTITY: corp_ids.append(node_id) + elif node_type_code == NODE_TEAM: + team_ids.append(node_id) people = { str(row["person_id"]): row @@ -381,6 +489,13 @@ async def hydrate_related_nodes( corp_ids, ) } if corp_ids else {} + teams = { + str(row["team_id"]): row + for row in await conn.fetch( + "select team_id, team_name from cataloged_team where team_id = any($1::uuid[])", + team_ids, + ) + } if team_ids else {} side_labels = await labels_for_codes( conn, [row["person_side_code"] for row in people.values()] @@ -403,6 +518,8 @@ async def hydrate_related_nodes( item["label"] = posts[node_id]["post_title"] elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps: item["label"] = corps[node_id]["entity_name"] + elif node_type_code == NODE_TEAM and node_id in teams: + item["label"] = teams[node_id]["team_name"] else: continue payload.append(item) @@ -439,3 +556,12 @@ async def related_for_entity( ) -> list[dict[str, Any]]: """Run RWR from ``entity_id`` over the account's visible subgraph.""" return await related_for_start(conn, NODE_CORPORATE_ENTITY, entity_id, visible_post_ids) + + +async def related_for_team( + conn: asyncpg.Connection, + team_id: str, + visible_post_ids: list[str], +) -> list[dict[str, Any]]: + """Run RWR from ``team_id`` over the account's visible subgraph.""" + return await related_for_start(conn, NODE_TEAM, team_id, visible_post_ids) diff --git a/backend/app/main.py b/backend/app/main.py index de06a1167..adb7a20a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -116,8 +116,11 @@ persist_edges_for_post, related_for_entity, related_for_person, + related_for_team, + team_exists, visible_affiliation_post_ids, visible_mention_post_ids, + visible_team_mention_post_ids, ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph from backend.app.post_chat_ingestion import ( @@ -473,6 +476,34 @@ async def read_related_corporate_entity( } +@app.get("/api/teams/{team_id}/related") +async def read_related_team( + team_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RWR-ranked related nodes from one cataloged team, hiding unseen posts.""" + _require_post_read(account) + async with pool.acquire() as conn: + if not await team_exists(conn, team_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found") + visible_post_ids = await visible_team_mention_post_ids( + conn, team_id, lambda row: _can_see_post(account, row) + ) + if not visible_post_ids: + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") + team = await conn.fetchrow( + "select team_id, team_name from cataloged_team where team_id = $1", + team_id, + ) + related = await related_for_team(conn, team_id, visible_post_ids) + return { + "team_id": str(team["team_id"]), + "team_name": team["team_name"], + "related": related, + } + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index c79d036d2..36d4fadae 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -34,6 +34,7 @@ NullCorporateHierarchyInferenceClient, ) from lineageweave.fixtures import fixture_thread_cast +from lineageweave.knowledge_graph import NODE_CORPORATE_ENTITY, NODE_TEAM from lineageweave.ontology import ontology_annotations from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, @@ -68,24 +69,57 @@ async def fetch_persisted_summary( post_id, ) roles = await conn.fetch( - "select actor_name, responsibility, actor_type_code, affiliated_organization_name " - "from post_summary_role where post_id = $1 order by actor_name", + """ + select role.actor_name, role.responsibility, role.actor_type_code, + role.affiliated_organization_name, + team_mention.team_id, + org_mention.corporate_entity_id + from post_summary_role role + left join cataloged_team team + on role.actor_type_code = 'prov_team' + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name + left join post_team_mention team_mention + on team_mention.post_id = role.post_id + and team_mention.team_id = team.team_id + left join corporate_entity org + on role.actor_type_code = 'prov_organization' + and org.entity_name = role.actor_name + left join post_organization_mention org_mention + on org_mention.post_id = role.post_id + and org_mention.corporate_entity_id = org.corporate_entity_id + where role.post_id = $1 + order by role.actor_name + """, post_id, ) - return { - "post_id": post_id, - "korean_summary": header["korean_summary"], - "key_events": [row["event_text"] for row in events], - "roles_and_responsibilities": [ + payload_roles: list[dict[str, Any]] = [] + for row in roles: + catalog_node_id = None + catalog_node_type_code = None + if row["team_id"] is not None: + catalog_node_id = str(row["team_id"]) + catalog_node_type_code = NODE_TEAM + elif row["corporate_entity_id"] is not None: + catalog_node_id = str(row["corporate_entity_id"]) + catalog_node_type_code = NODE_CORPORATE_ENTITY + payload_roles.append( { "actor_name": row["actor_name"], "responsibility": row["responsibility"], "actor_type_code": row["actor_type_code"], "affiliated_organization_name": row["affiliated_organization_name"], + "catalog_node_id": catalog_node_id, + "catalog_node_type_code": catalog_node_type_code, **ontology_annotations(row["actor_type_code"]), } - for row in roles - ], + ) + return { + "post_id": post_id, + "korean_summary": header["korean_summary"], + "key_events": [row["event_text"] for row in events], + "roles_and_responsibilities": payload_roles, } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e62ff0fa1..bff7f7c64 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -22,6 +22,7 @@ import redis from lineageweave.http_client import HttpClientError, get_json, post_form +from lineageweave.knowledge_graph import knowledge_graph_edges_for_post _POSTGRES_ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -1450,6 +1451,161 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: assert mentioning_post_count == 2, "both posts must link to the single cataloged team" assert team_mention_edge_count == 2, "each post's mention must become a real KG edge" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute("select team_id from cataloged_team where team_name = '설계팀'") + team_id = str(cur.fetchone()[0]) + finally: + admin_conn.close() + + related = client.get( + f"/api/teams/{team_id}/related", + headers=headers, + ) + assert related.status_code == 200, related.text + related_ids = {node["node_id"] for node in related.json()["related"]} + assert set(post_ids) <= related_ids + summaries = [ + client.get(f"/api/posts/{post_id}/summary", headers=headers).json() + for post_id in post_ids + ] + for body in summaries: + role = body["roles_and_responsibilities"][0] + assert role["catalog_node_id"] == team_id + assert role["catalog_node_type_code"] == "node_team" + + +def test_organization_mention_only_posts_appear_in_entity_related( + client, demo_analyst_token, seeded_db +) -> None: + """An org mentioned with no affiliated person must still start a related walk.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + ("Org-only mention", "Test Corp was named without a person.", seeded_db["own_private_post_id"]), + ) + org_only_post_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (org_only_post_id, seeded_db["own_corp_id"]), + ) + for edge in knowledge_graph_edges_for_post( + org_only_post_id, + [], + organization_corporate_entity_ids=[seeded_db["own_corp_id"]], + ): + cur.execute( + "insert into knowledge_graph_edge (" + "source_node_type_code, source_node_id, target_node_type_code, " + "target_node_id, edge_type_code, edge_weight" + ") values (%s, %s, %s, %s, %s, %s) " + "on conflict do nothing", + ( + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.edge_weight, + ), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + related_ids = {node["node_id"] for node in response.json()["related"]} + assert org_only_post_id in related_ids + + +def test_thread_group_run_list_honors_knowledge_cutoff( + client, demo_analyst_token, seeded_db +) -> None: + """A later public post must not surface a previously hidden thread-group run.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, thread_group_key, created_at) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public', %s, %s " + "from source_post where post_id = %s", + ( + "Late thread-group post", + "Written after the January cutoff.", + "late-thread-group", + "2026-01-20T12:00:00Z", + seeded_db["own_private_post_id"], + ), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("f" * 64,), + ) + snapshot_id = cur.fetchone()[0] + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', %s, + (select user_account_id from user_account + where email_address = 'other.analyst@example.test'), + '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, "hidden-late-thread", "b" * 64, "c" * 40), + ) + run_id = str(cur.fetchone()[0]) + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, scope_key) + values (%s, 'analysis_scope_thread_group', 'late-thread-group') + """, + (run_id,), + ) + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z') + """, + (run_id,), + ) + finally: + admin_conn.close() + + listed = client.get( + "/api/analysis-runs", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert listed.status_code == 200 + ids = {run["analysis_run_id"] for run in listed.json()["analysis_runs"]} + assert run_id not in ids + assert seeded_db["visible_run_id"] in ids + def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity( client, demo_analyst_token, seeded_db, monkeypatch diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index f9c82a86d..089443374 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -49,6 +49,9 @@ run. - Hover a digest prefix to read the full code or configuration digest when you need to match the API payload. - Post-body versioning at the cutoff remains future work. +- Thread-group *run list* visibility now uses the same cutoff + (ADR 0018). A later public post cannot surface a previously hidden + thread-group run. ## References diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md new file mode 100644 index 000000000..17e4236a6 --- /dev/null +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -0,0 +1,67 @@ +# ADR 0018 — Related-node walks include team and organization mention edges + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0009 persists `edge_mention_team`, `edge_team_affiliation`, and +`edge_mention_organization` so a cataloged team or organization can +become a cross-post Knowledge Graph clue. The buyer-visible related-node +walk (`load_visible_subgraph` + Tong et al., 2006 random walk with +restart) still loaded only person mention, co-mention, and affiliation +edges, and returned an empty graph when a visible post had no people. +A team-only follow-up therefore never appeared as a related node, and +clicking an R&R team name had no catalog id to start a walk. + +The same temporal honesty ADR 0016 applied to run *detail* posts was +still missing from thread-group *run list* visibility: a later public +post in that thread group could surface a run the account was not +allowed to know at `knowledge_cutoff`. + +ADR 0017 already records an authorized Pending analysis-run write. +This decision is the related-node walk, not that create path. + +## Decision + +`load_visible_subgraph` loads person, team, and organization mention +channels independently. Empty person evidence is not a reason to drop +team or organization edges. `hydrate_related_nodes` labels +`cataloged_team` rows. `GET /api/teams/{team_id}/related` starts the +same RWR walk Keyman and corporate-entity related already use. +`visible_affiliation_post_ids` unions direct `post_organization_mention` +rows with person-affiliation posts so an org-only mention can start a +walk. + +The summary payload exposes `catalog_node_id` / `catalog_node_type_code` +when the R&R actor resolved to a team or organization mention on that +post. The popup turns that name into a related-node button. + +Thread-group run list visibility requires at least one ABAC-visible +`source_post` whose `created_at` is at or before `knowledge_cutoff`. + +## Consequences + +- Open a post whose R&R names 설계팀, then click the team. Sibling posts + that mention the same cataloged team appear as related nodes. +- Click a related team chip the same way you already click a person or + organization chip. +- A later public post in a thread group no longer lists a January run + that could not have known that post. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with +restart and its applications. *Proceedings of the Sixth International +Conference on Data Mining (ICDM'06)*, 613–622. +https://doi.org/10.1109/ICDM.2006.70 + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md new file mode 100644 index 000000000..4ecb8fe82 --- /dev/null +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -0,0 +1,19 @@ +# Related-node team and organization walk — doctoring + +These are the standards and papers that ground ADR 0018. Cite them in +APA 7th when you extend the walk or the catalog identity layer. + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with +restart and its applications. *Proceedings of the Sixth International +Conference on Data Mining (ICDM'06)*, 613–622. +https://doi.org/10.1109/ICDM.2006.70 + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index c8f67bc8d..dac241738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.85.0", + "version": "0.86.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dae8e1674..934f150e4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -649,6 +649,14 @@ describe("App, authenticated", () => { actor_type_code: "prov_organization", affiliated_organization_name: null, }, + { + actor_name: "설계팀", + responsibility: "도면 검토", + actor_type_code: "prov_team", + affiliated_organization_name: "Demo Corp", + catalog_node_id: "team-1", + catalog_node_type_code: "node_team", + }, ], }), ); @@ -750,6 +758,32 @@ describe("App, authenticated", () => { label: "Demo Corp", relevance: 0.2, }, + { + node_id: "team-1", + node_type_code: "node_team", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Team", + ontology_label: "Team", + label: "설계팀", + relevance: 0.15, + }, + ], + }), + ); + } + if (url.endsWith("/api/teams/team-1/related")) { + return Promise.resolve( + jsonResponse({ + team_id: "team-1", + team_name: "설계팀", + related: [ + { + node_id: "post-2", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Linked post", + relevance: 0.6, + }, ], }), ); @@ -1201,6 +1235,30 @@ describe("App, authenticated", () => { ); }); + it("opens related nodes from an R&R team", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "R&R team: 설계팀" })); + await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); + expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( + "Linked post", + ); + }); + + it("opens related nodes from a related team chip", async () => { + stubBackend(); + render(); + 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 설계팀" })); + await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); + expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( + "Linked post", + ); + }); + it("opens related nodes from a related corporate entity", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ddce1ac..83fb88860 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, @@ -30,6 +30,7 @@ import { fetchPosts, fetchRelatedEntity, fetchRelatedKeymen, + fetchRelatedTeam, rebuildLineage, rebuildPeriodReports, updateTicketStatus, @@ -54,6 +55,7 @@ import { type PostLineage, type PostSummary, type RelatedNode, + type RelatedNodeType, type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; @@ -470,6 +472,15 @@ function VocEvidenceSection({ } const NODE_PERSON = "node_person"; +const NODE_POST = "node_post"; +const NODE_CORPORATE_ENTITY = "node_corporate_entity"; +const NODE_TEAM = "node_team"; + +const KNOWN_RELATED_NODE_TYPES = [NODE_PERSON, NODE_POST, NODE_CORPORATE_ENTITY, NODE_TEAM] as const; + +function isKnownRelatedNodeType(code: string): code is RelatedNodeType { + return (KNOWN_RELATED_NODE_TYPES as readonly string[]).includes(code); +} function relatedNodeCaption(node: RelatedNode): string { const name = node.label ?? node.node_id; @@ -482,9 +493,6 @@ function relatedNodeCaption(node: RelatedNode): string { return `${name} (${node.ontology_label ?? node.node_type_code})`; } -const NODE_POST = "node_post"; -const NODE_CORPORATE_ENTITY = "node_corporate_entity"; - const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -539,6 +547,7 @@ function KeymanPanel({ onSelectPost, focusPerson, focusEntity, + focusTeam, }: { postId: string; accessToken: string; @@ -548,6 +557,7 @@ function KeymanPanel({ onSelectPost?: (postId: string) => void; focusPerson?: { personId: string; personName: string } | null; focusEntity?: { entityId: string; entityName: string } | null; + focusTeam?: { teamId: string; teamName: string } | null; }) { const [related, setRelated] = useState(null); const [selectedName, setSelectedName] = useState(null); @@ -585,6 +595,18 @@ function KeymanPanel({ } } + async function handleSelectTeam(teamId: string, teamName: string) { + const requestId = ++relatedRequest.current; + setSelectedName(teamName); + setRelated(null); + try { + const result = await fetchRelatedTeam(accessToken, teamId); + if (requestId === relatedRequest.current) setRelated(result.related); + } catch { + if (requestId === relatedRequest.current) setRelated([]); + } + } + useEffect(() => { if (!focusPerson) return; const requestId = ++relatedRequest.current; @@ -613,6 +635,20 @@ function KeymanPanel({ }); }, [accessToken, focusEntity]); + useEffect(() => { + if (!focusTeam) return; + const requestId = ++relatedRequest.current; + setSelectedName(focusTeam.teamName); + setRelated(null); + fetchRelatedTeam(accessToken, focusTeam.teamId) + .then((result) => { + if (requestId === relatedRequest.current) setRelated(result.related); + }) + .catch(() => { + if (requestId === relatedRequest.current) setRelated([]); + }); + }, [accessToken, focusTeam]); + async function handleExtract() { setExtracting(true); setError(null); @@ -700,48 +736,67 @@ function KeymanPanel({
    {related.map((node) => { const caption = relatedNodeCaption(node); - if (node.node_type_code === NODE_POST && onSelectPost) { - return ( -
  • - -
  • - ); + const key = `${node.node_type_code}:${node.node_id}`; + if (!isKnownRelatedNodeType(node.node_type_code)) { + return
  • {caption}
  • ; } - if (node.node_type_code === NODE_PERSON) { - return ( -
  • - -
  • - ); - } - if (node.node_type_code === NODE_CORPORATE_ENTITY) { - return ( -
  • - -
  • - ); + switch (node.node_type_code) { + case NODE_POST: + if (!onSelectPost) { + return
  • {caption}
  • ; + } + return ( +
  • + +
  • + ); + case NODE_PERSON: + return ( +
  • + +
  • + ); + case NODE_CORPORATE_ENTITY: + return ( +
  • + +
  • + ); + case NODE_TEAM: + return ( +
  • + +
  • + ); + default: { + const _exhaustive: never = node.node_type_code; + return
  • {_exhaustive}
  • ; + } } - return ( -
  • {caption}
  • - ); })}
)} @@ -1128,6 +1183,7 @@ function PostDetailPopup({ const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); + const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); function reloadKeymen() { fetchPostKeymen(accessToken, postId).then((r) => setKeymen(r.keymen)).catch(() => setKeymen([])); @@ -1156,6 +1212,7 @@ function PostDetailPopup({ setEvaluation(null); setFocusPerson(null); setFocusEntity(null); + setFocusTeam(null); fetchPost(accessToken, postId).then(setPost).catch((err) => setError(String(err))); fetchPostEvaluation(accessToken, postId) .then((r) => setEvaluation(r.responses)) @@ -1220,28 +1277,61 @@ function PostDetailPopup({ const person = isPerson ? keymen?.find((row) => row.person_name === rr.actor_name) : undefined; + const catalogId = rr.catalog_node_id; + const catalogType = rr.catalog_node_type_code; + let actorName: ReactNode = {rr.actor_name}; + if (person) { + actorName = ( + + ); + } else if (catalogType === NODE_TEAM && catalogId) { + actorName = ( + + ); + } else if (catalogType === NODE_CORPORATE_ENTITY && catalogId) { + actorName = ( + + ); + } return (
  • {actorTypeLabel} {" "} - {person ? ( - - ) : ( - {rr.actor_name} - )} + {actorName} {rr.affiliated_organization_name && ( ({rr.affiliated_organization_name}) )} @@ -1271,6 +1361,7 @@ function PostDetailPopup({ affiliateTrees={affiliateTrees} onSelectPerson={(personId, personName) => { setFocusEntity(null); + setFocusTeam(null); setFocusPerson({ personId, personName }); }} /> @@ -1299,10 +1390,12 @@ function PostDetailPopup({ node={node} onSelectPerson={(personId, personName) => { setFocusEntity(null); + setFocusTeam(null); setFocusPerson({ personId, personName }); }} onSelectEntity={(entityId, entityName) => { setFocusPerson(null); + setFocusTeam(null); setFocusEntity({ entityId, entityName }); }} /> @@ -1320,6 +1413,7 @@ function PostDetailPopup({ onSelectPost={onSelectPost} focusPerson={focusPerson} focusEntity={focusEntity} + focusTeam={focusTeam} /> {counterparties && counterparties.length > 0 && ( @@ -1331,6 +1425,7 @@ function PostDetailPopup({ onVerified={reloadCounterparties} onSelectEntity={(entityId, entityName) => { setFocusPerson(null); + setFocusTeam(null); setFocusEntity({ entityId, entityName }); }} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e35bcf3ed..f9bd4068e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -73,9 +73,15 @@ export interface VocEvidence { counterparties: VocEvidenceCounterparty[]; } +export type RelatedNodeType = + | "node_person" + | "node_post" + | "node_corporate_entity" + | "node_team"; + export interface RelatedNode { node_id: string; - node_type_code: string; + node_type_code: RelatedNodeType | string; relevance: number; label?: string; person_side_code?: string; @@ -89,6 +95,8 @@ export interface PostRoleResponsibility { responsibility: string; actor_type_code: string; affiliated_organization_name: string | null; + catalog_node_id?: string | null; + catalog_node_type_code?: string | null; } export interface PostAiSummary { @@ -284,6 +292,13 @@ export function fetchRelatedEntity( return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken); } +export function fetchRelatedTeam( + accessToken: string, + teamId: string, +): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> { + return backendFetch(`/api/teams/${teamId}/related`, accessToken); +} + export function extractPostKeymen( accessToken: string, postId: string, diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5e05ef4ff..5f70c6064 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.85.0" +__version__ = "0.86.0" diff --git a/pyproject.toml b/pyproject.toml index 8750ae3e3..0393b774a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.85.0" +version = "0.86.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_person_mention_projection.py b/tests/test_person_mention_projection.py index 8aad4f6a8..5353c2a91 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -21,13 +21,21 @@ from backend.app.keyman_ingestion import ingest_post_keymen from backend.app.knowledge_graph import ( + hydrate_related_nodes, load_visible_subgraph, persist_edges_for_post, + related_for_start, visible_mention_post_ids, ) from backend.app.post_summary_ingestion import persist_post_summary from lineageweave.keyman_extraction import OUR_SIDE, PersonMention -from lineageweave.knowledge_graph import EDGE_MENTION, NODE_PERSON, NODE_POST +from lineageweave.knowledge_graph import ( + EDGE_MENTION, + EDGE_MENTION_TEAM, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) from lineageweave.post_summary import PostSummary, RoleResponsibility _ADMIN_DSN = os.environ.get( @@ -365,3 +373,81 @@ def test_cross_post_identity_upgrade_keeps_keyman_mention_context( assert keyman_row is not None assert keyman_row[0] == "Keyman extracted this mention from the synthetic body" assert summary_count == 1 + + +async def _exercise_team_only_related_walk( + database_dsn: str, + first_post_id: str, +) -> None: + """A team mentioned on two posts must walk even when one post has no people.""" + + connection = await asyncpg.connect(database_dsn) + try: + author_id, corporate_entity_id = await connection.fetchrow( + "select author_account_id, corporate_entity_id from source_post where post_id = $1", + first_post_id, + ) + second_post_id = str( + await connection.fetchval( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code) + values ($1, $2, 'Team-only follow-up', '설계팀이 도면을 재검토했다.', + 'voc', 'public') + returning post_id + """, + author_id, + corporate_entity_id, + ) + ) + team_id = str( + await connection.fetchval( + """ + insert into cataloged_team (team_name, affiliated_organization_name) + values ('설계팀', 'Synthetic Corp') + returning team_id + """ + ) + ) + await connection.execute( + """ + insert into post_team_mention (post_id, team_id) + values ($1, $2), ($3, $2) + """, + first_post_id, + team_id, + second_post_id, + ) + async with connection.transaction(): + await persist_edges_for_post(connection, first_post_id) + await persist_edges_for_post(connection, second_post_id) + + team_only_edges = await load_visible_subgraph(connection, [second_post_id]) + assert any( + edge.edge_type_code == EDGE_MENTION_TEAM + and edge.source_node_id == team_id + and edge.target_node_id == second_post_id + for edge in team_only_edges + ), "a team-only post must still load its mention edge" + + related = await related_for_start( + connection, NODE_TEAM, team_id, [first_post_id, second_post_id] + ) + related_ids = {node["node_id"] for node in related} + assert first_post_id in related_ids + assert second_post_id in related_ids + hydrated = await hydrate_related_nodes( + connection, [(f"{NODE_TEAM}:{team_id}", 1.0)] + ) + assert hydrated[0]["label"] == "설계팀" + assert hydrated[0]["node_type_code"] == NODE_TEAM + finally: + await connection.close() + + +def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: + """ADR 0018: team mention edges must participate in the visible RWR walk.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index 20f9a8793..b302b1250 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.85.0" +version = "0.86.0" source = { virtual = "." } dependencies = [ { name = "certifi" },