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
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel
(direct vs. indirect links; a link opens that post), the Keyman
affiliate tree (resolved ancestors plus unresolved org roots), Keyman +
counterparty panels (a Keyman click loads RWR related nodes;
`post_admin` can extract), and an in-popup chat whose cited sources
a related corporate-entity node continues the same walk via
`GET /api/corporate-entities/{id}/related`; `post_admin` can extract),
and an in-popup chat whose cited sources
open a sliding evidence panel (`EvidencePanel`, CSS
`slide-in-from-right`) showing that source post's actual content. Built from the product
brief's text, not the referenced Figma frame's pixel layout -- see
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [0.64.0] - 2026-08-14

### Added

- Related corporate-entity nodes in the Keyman walk are the same
RWR as a person. Clicking `Demo Corp` / `Test Corp` loads
`GET /api/corporate-entities/{id}/related` so the buyer can
continue the walk from the org. An entity with no visible
affiliation stays 403 -- never a guessed neighborhood.

## [0.63.0] - 2026-08-14

### Added
Expand Down
58 changes: 54 additions & 4 deletions backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@ async def person_exists(conn: asyncpg.Connection, person_id: str) -> bool:
return row is not None


async def corporate_entity_exists(conn: asyncpg.Connection, entity_id: str) -> bool:
"""True when ``entity_id`` is a UUID that exists in ``corporate_entity``."""
try:
UUID(entity_id)
except ValueError:
return False
row = await conn.fetchrow(
"select 1 from corporate_entity where corporate_entity_id = $1", entity_id
)
return row is not None


async def visible_mention_post_ids(
conn: asyncpg.Connection,
person_id: str,
Expand All @@ -190,6 +202,25 @@ async def visible_mention_post_ids(
return [str(row["post_id"]) for row in rows if can_see_post(row)]


async def visible_affiliation_post_ids(
conn: asyncpg.Connection,
entity_id: str,
can_see_post,
) -> list[str]:
"""Post ids that mention someone affiliated with ``entity_id`` and pass ABAC."""
rows = await conn.fetch(
"""
select distinct p.post_id, p.visibility_code, p.corporate_entity_id
from person_affiliation pa
join post_person_mention ppm on ppm.person_id = pa.person_id
join source_post p on p.post_id = ppm.post_id
where pa.affiliated_corporate_entity_id = $1
""",
entity_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],
Expand Down Expand Up @@ -310,14 +341,33 @@ async def hydrate_related_nodes(
return payload


async def related_for_person(
async def related_for_start(
conn: asyncpg.Connection,
person_id: str,
node_type_code: str,
node_id: str,
visible_post_ids: list[str],
) -> list[dict[str, Any]]:
"""Run RWR from ``person_id`` over the account's visible subgraph."""
"""Run RWR from one node over the account's visible subgraph."""
edges = await load_visible_subgraph(conn, visible_post_ids)
start = node_key(NODE_PERSON, person_id)
start = node_key(node_type_code, node_id)
scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start)
related = select_related_nodes(scores, start_node=start)
return await hydrate_related_nodes(conn, related)


async def related_for_person(
conn: asyncpg.Connection,
person_id: str,
visible_post_ids: list[str],
) -> list[dict[str, Any]]:
"""Run RWR from ``person_id`` over the account's visible subgraph."""
return await related_for_start(conn, NODE_PERSON, person_id, visible_post_ids)


async def related_for_entity(
conn: asyncpg.Connection,
entity_id: str,
visible_post_ids: list[str],
) -> 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)
32 changes: 32 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,13 @@
)
from backend.app.keyman_ingestion import ingest_post_keymen
from backend.app.knowledge_graph import (
corporate_entity_exists,
fetch_post_keymen,
labels_for_codes,
person_exists,
related_for_entity,
related_for_person,
visible_affiliation_post_ids,
visible_mention_post_ids,
)
from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph
Expand Down Expand Up @@ -400,6 +403,35 @@ async def read_related_keymen(
}


@app.get("/api/corporate-entities/{entity_id}/related")
async def read_related_corporate_entity(
entity_id: str,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""RWR-ranked related nodes from one corporate entity, hiding unseen posts."""
_require_post_read(account)
async with pool.acquire() as conn:
if not await corporate_entity_exists(conn, entity_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "corporate entity not found")
visible_post_ids = await visible_affiliation_post_ids(
conn, entity_id, lambda row: _can_see_post(account, row)
)
if not visible_post_ids:
raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this entity")
entity = await conn.fetchrow(
"select corporate_entity_id, entity_name from corporate_entity "
"where corporate_entity_id = $1",
entity_id,
)
related = await related_for_entity(conn, entity_id, visible_post_ids)
return {
"corporate_entity_id": str(entity["corporate_entity_id"]),
"entity_name": entity["entity_name"],
"related": related,
}


@app.get("/api/posts/{post_id}/counterparties")
async def read_post_counterparties(
post_id: str,
Expand Down
41 changes: 41 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st
"public_post_id": public_post_id,
"own_group_id": str(own_group_id),
"own_corp_id": str(own_corp_id),
"other_corp_id": str(other_corp_id),
"own_private_post_id": own_private_post_id,
"other_private_post_id": other_private_post_id,
"our_person_id": our_person_id,
Expand Down Expand Up @@ -886,6 +887,46 @@ def test_related_keymen_use_rwr_and_hide_invisible_posts(client, demo_analyst_to
assert own_post["ontology_label"] == "Post"


def test_related_corporate_entity_uses_rwr_and_hides_invisible_posts(
client, demo_analyst_token, seeded_db
) -> None:
"""GET /api/corporate-entities/{id}/related must walk from the org
the same way Keyman related walks from a person.
"""
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
body = response.json()
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
assert seeded_db["other_private_post_id"] not in related_ids
assert seeded_db["hidden_person_id"] not in related_ids


def test_corporate_entity_with_no_visible_affiliation_is_forbidden(
client, demo_analyst_token, seeded_db
) -> None:
"""Other Corp exists but no visible person is affiliated with it."""
response = client.get(
f"/api/corporate-entities/{seeded_db['other_corp_id']}/related",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert response.status_code == 403


def test_unknown_corporate_entity_related_is_not_found(
client, demo_analyst_token, seeded_db
) -> None:
response = client.get(
f"/api/corporate-entities/{uuid.uuid4()}/related",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert response.status_code == 404
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def test_keyman_only_on_other_corp_private_post_is_forbidden(client, demo_analyst_token, seeded_db) -> None:
response = client.get(
f"/api/keymen/{seeded_db['hidden_person_id']}/related",
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.63.0",
"version": "0.64.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,32 @@ describe("App, authenticated", () => {
label: "Linked post",
relevance: 0.3,
},
{
node_id: "corp-1",
node_type_code: "node_corporate_entity",
ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Organization",
ontology_label: "Organization",
label: "Demo Corp",
relevance: 0.2,
},
],
}),
);
}
if (url.endsWith("/api/corporate-entities/corp-1/related")) {
return Promise.resolve(
jsonResponse({
corporate_entity_id: "corp-1",
entity_name: "Demo Corp",
related: [
{
node_id: "person-ada",
node_type_code: "node_person",
ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person",
ontology_label: "Person",
label: "Ada West",
relevance: 0.5,
},
],
}),
);
Expand Down Expand Up @@ -947,6 +973,17 @@ describe("App, authenticated", () => {
expect(screen.getByText("Priya Nair (Person)")).toBeInTheDocument();
});

it("opens related nodes from a related corporate entity", async () => {
stubBackend();
render(<App />);
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" }));
await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument());
expect(screen.getByText("Ada West (Person)")).toBeInTheDocument();
});

it("opens related Keyman nodes from a VOC counterparty organization", async () => {
stubBackend();
render(<App />);
Expand Down
44 changes: 39 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import {
askPostChat,
Expand All @@ -25,6 +25,7 @@ import {
fetchPeriodReportIndex,
fetchPeriodReports,
fetchPosts,
fetchRelatedEntity,
fetchRelatedKeymen,
rebuildLineage,
rebuildPeriodReports,
Expand Down Expand Up @@ -439,6 +440,7 @@ function VocEvidenceSection({

const NODE_PERSON = "node_person";
const NODE_POST = "node_post";
const NODE_CORPORATE_ENTITY = "node_corporate_entity";

const VERIFICATION_BADGE: Record<string, string> = {
verify_pending: "Not yet checked",
Expand Down Expand Up @@ -507,30 +509,49 @@ function KeymanPanel({
const [extracting, setExtracting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orchestratorOff, setOrchestratorOff] = useState(false);
const relatedRequest = useRef(0);

useEffect(() => {
setOrchestratorOff(false);
setError(null);
}, [postId]);

async function handleSelect(personId: string, personName: string) {
const requestId = ++relatedRequest.current;
setSelectedName(personName);
setRelated(null);
try {
const result = await fetchRelatedKeymen(accessToken, personId);
setRelated(result.related);
if (requestId === relatedRequest.current) setRelated(result.related);
} catch {
setRelated([]);
if (requestId === relatedRequest.current) setRelated([]);
}
}

async function handleSelectEntity(entityId: string, entityName: string) {
const requestId = ++relatedRequest.current;
setSelectedName(entityName);
setRelated(null);
try {
const result = await fetchRelatedEntity(accessToken, entityId);
if (requestId === relatedRequest.current) setRelated(result.related);
} catch {
if (requestId === relatedRequest.current) setRelated([]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

useEffect(() => {
if (!focusPerson) return;
const requestId = ++relatedRequest.current;
setSelectedName(focusPerson.personName);
setRelated(null);
fetchRelatedKeymen(accessToken, focusPerson.personId)
.then((result) => setRelated(result.related))
.catch(() => setRelated([]));
.then((result) => {
if (requestId === relatedRequest.current) setRelated(result.related);
})
.catch(() => {
if (requestId === relatedRequest.current) setRelated([]);
});
}, [accessToken, focusPerson]);

async function handleExtract() {
Expand Down Expand Up @@ -620,6 +641,19 @@ function KeymanPanel({
</li>
);
}
if (node.node_type_code === NODE_CORPORATE_ENTITY) {
return (
<li key={`${node.node_type_code}:${node.node_id}`}>
<button
className="keyman-select"
aria-label={`Related nodes for ${node.label ?? node.node_id}`}
onClick={() => handleSelectEntity(node.node_id, node.label ?? node.node_id)}
>
{caption}
</button>
</li>
);
}
return (
<li key={`${node.node_type_code}:${node.node_id}`}>{caption}</li>
);
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,13 @@ export function fetchRelatedKeymen(
return backendFetch(`/api/keymen/${personId}/related`, accessToken);
}

export function fetchRelatedEntity(
accessToken: string,
entityId: string,
): Promise<{ corporate_entity_id: string; entity_name: string; related: RelatedNode[] }> {
return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken);
}

export function extractPostKeymen(
accessToken: string,
postId: string,
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@
"sentence_excerpts",
]

__version__ = "0.63.0"
__version__ = "0.64.0"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
version = "0.63.0"
version = "0.64.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" }
Expand Down
Loading
Loading