Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[])
Expand Down
142 changes: 134 additions & 8 deletions backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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(
"""
Expand Down Expand Up @@ -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,
Expand All @@ -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]

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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()]
Expand All @@ -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)
Expand Down Expand Up @@ -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)
31 changes: 31 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading