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
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,14 @@ does it (`gh repo list ContextualWisdomLab`).

## Pluggable channels: never fake a missing signal

`NullEmbeddingClient` and `NullAdjudicationClient` (and any new channel
client you add) must set `available = False` and make their channel dropped
+ renormalized (`reconstruct.active_weights`), never silently return a
placeholder score. A missing signal and a confidently-negative signal are
different things.
`NullEmbeddingClient`, `NullAdjudicationClient`, and
`NullKeymanExtractionClient` (and any new channel client you add) must
set `available = False` and make their channel dropped + renormalized
(`reconstruct.active_weights`), never silently return a placeholder
score or invented Keyman. A missing signal and a confidently-negative
signal are different things. Keyman extraction goes through
contextual-orchestrator the same way adjudication does -- never a raw
LLM API.

## Tests

Expand Down
11 changes: 11 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ stack itself with the same shape of synthetic data for manual/frontend use.
`CORSMiddleware` (`backend/app/main.py`) allows exactly the frontend's
origin(s) (`FRONTEND_ORIGINS`), `GET` only, `Authorization` header only.

Phase 2 adds two more GET endpoints on the same RBAC+ABAC gate plus one
write: `GET /api/posts/{post_id}/keymen` (people extracted or seeded for
that post, with N:N affiliations), `GET /api/keymen/{person_id}/related`
(RWR from that person over `knowledge_graph_edge` rows, adaptive
relevance cutoff, never a fixed hop count), and
`POST /api/posts/{post_id}/extract-keymen` (`post_admin` only -- a write
with a real LLM-call cost). A Keyman who is only mentioned on a post the
account cannot see is 403, matching the post deny path. Extraction
lives in `lineageweave/keyman_extraction.py` and talks to
contextual-orchestrator; persist is `backend/app/keyman_ingestion.py`.

### Frontend (`frontend/`)

React + Vite + TypeScript, pinned Node via `mise.toml`, pnpm via Corepack.
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ 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.9.0] - 2026-08-13

### Added

- Milestone 4, Phase 2 continues: `lineageweave/keyman_extraction.py`
extracts two-sided Keymen (our-side vs counterparty, 0..N organization
affiliations) from a post's title+body. The live client calls
contextual-orchestrator with `mode="route"` -- never a raw LLM API.
`NullKeymanExtractionClient` stays unavailable rather than inventing
mentions.
- `lineageweave.knowledge_graph.knowledge_graph_edges_for_post` populates
the three Phase 2 edge kinds (person<->post mention, person<->corporate
entity affiliation, person<->person co-mention) as typed
`knowledge_graph_edge` specs. RWR then runs on that graph.
- Backend endpoints `GET /api/posts/{post_id}/keymen`,
`GET /api/keymen/{person_id}/related`, and
`POST /api/posts/{post_id}/extract-keymen`, RBAC+ABAC-gated the same
way as post detail: a Keyman who is only mentioned on another corp's
private post 403s, related-node traversal never returns those hidden
posts, and extraction is `post_admin` (a write with a real LLM cost).
Persist goes through `backend/app/keyman_ingestion.py` into
`person` / `person_affiliation` / `post_person_mention` /
`knowledge_graph_edge`.
- `fixtures.ambiguous_keyman_post`: a synthetic, non-templated workshop
follow-up used by the parser tests and the real-orchestrator Keyman test.

## [0.8.0] - 2026-08-13

### Added
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,17 @@ make seed # scripts/seed_demo_data.py: inserts synthetic corp/account/post
curl http://localhost:18420/healthz
```

`GET /api/posts` and `GET /api/posts/{post_id}` require a real bearer token
(RBAC: the account's role must grant `post_read`; ABAC: a private post is
only visible to accounts affiliated with its owning corporate entity --
`backend/app/main.py`). `backend/tests/test_api.py` proves both the allow
and the deny path against a live Keycloak + throwaway Postgres database,
including that a private post scoped to a *different* corporate entity is
excluded from the list and 403s on direct fetch.
`GET /api/posts`, `GET /api/posts/{post_id}`,
`GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`,
and `POST /api/posts/{post_id}/extract-keymen`
require a real bearer token (RBAC: the account's role must grant
`post_read`; ABAC: a private post is only visible to accounts affiliated
with its owning corporate entity -- `backend/app/main.py`). A Keyman who
is only mentioned on a post the account cannot see is 403, same deny
path. `backend/tests/test_api.py` proves both the allow and the deny
path against a live Keycloak + throwaway Postgres database, including
that a private post scoped to a *different* corporate entity is excluded
from the list and 403s on direct fetch.

`frontend/` (React + Vite + TypeScript, `docker compose`'s fourth service)
is a real client, not mocked or static: `react-oidc-context` drives an
Expand Down
7 changes: 7 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class Settings:
# host-published port a browser actually hits).
keycloak_issuer: str
frontend_origins: list[str]
# Keyman extraction is a hard dependency of POST /api/posts/{id}/extract-keymen
# only -- every other endpoint works with these unset. Empty string, not
# a fabricated default, when unconfigured (see keyman_ingestion.py).
orchestrator_base_url: str
orchestrator_api_key: str

@property
def keycloak_jwks_uri(self) -> str:
Expand All @@ -51,4 +56,6 @@ def load_settings() -> Settings:
for origin in os.environ.get("FRONTEND_ORIGINS", "http://localhost:5173").split(",")
if origin.strip()
],
orchestrator_base_url=os.environ.get("ORCHESTRATOR_BASE_URL", ""),
orchestrator_api_key=os.environ.get("ORCHESTRATOR_API_KEY", ""),
)
84 changes: 84 additions & 0 deletions backend/app/keyman_ingestion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Runs a `KeymanExtractionClient` over a post and persists the result:
`person` (upserted by name + side -- the schema has no unique constraint
on person_name alone, since two different real people can share a name,
but re-running extraction on the same post should not keep creating
duplicate rows for the same extracted mention), `person_affiliation`
(N:N, matched to a real `corporate_entity` by exact case-insensitive name
where possible), and `post_person_mention`. Finishes by calling
`knowledge_graph.persist_edges_for_post` so the Knowledge Graph edges are
computed from the same write, not a separate manual step.
"""

from __future__ import annotations

import asyncpg

from lineageweave.keyman_extraction import KeymanExtractionClient, PersonMention

from .knowledge_graph import persist_edges_for_post


async def _resolve_corporate_entity_id(conn: asyncpg.Connection, organization_name: str) -> str | None:
row = await conn.fetchrow(
"select corporate_entity_id from corporate_entity where lower(entity_name) = lower($1)",
organization_name,
)
return str(row["corporate_entity_id"]) if row is not None else None


async def _upsert_person(conn: asyncpg.Connection, mention: PersonMention) -> str:
row = await conn.fetchrow(
"select person_id from person where person_name = $1 and person_side_code = $2",
mention.person_name,
mention.person_side_code,
)
if row is not None:
return str(row["person_id"])
row = await conn.fetchrow(
"insert into person (person_name, person_side_code) values ($1, $2) returning person_id",
mention.person_name,
mention.person_side_code,
)
return str(row["person_id"])


async def ingest_post_keymen(
conn: asyncpg.Connection,
client: KeymanExtractionClient,
post_id: str,
post_title: str,
post_body: str,
) -> list[PersonMention]:
"""Extracts, persists, and returns the `PersonMention`s found in one post.

Raises whatever `client.extract` raises (e.g. a `NullKeymanExtractionClient`
would raise `RuntimeError`) -- callers should check `client.available`
first, same discipline as every other pluggable channel in this repo.
"""
mentions = client.extract(post_title, post_body)

for mention in mentions:
person_id = await _upsert_person(conn, mention)
await conn.execute(
"insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing",
post_id,
person_id,
)
for organization_name in mention.affiliated_organization_names:
corporate_entity_id = await _resolve_corporate_entity_id(conn, organization_name)
await conn.execute(
"""
insert into person_affiliation (person_id, affiliated_organization_name, affiliated_corporate_entity_id)
values ($1, $2, $3)
on conflict (person_id, affiliated_organization_name)
do update set affiliated_corporate_entity_id = excluded.affiliated_corporate_entity_id
""",
person_id,
organization_name,
corporate_entity_id,
)

if mentions:
await persist_edges_for_post(conn, post_id)

return mentions
Loading