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
15 changes: 14 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ flowchart LR
| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
| `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread |
| `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres |
| `fixtures.py` | Synthetic demo dataset -- no real data ships in this repo |
| `server.py` | Stdlib HTTP server: `GET /api/lineage` (JSON graph) + static viewer |
| `web/index.html` | Self-contained SVG DAG viewer, no build step, no external script dependency |
Expand Down Expand Up @@ -165,7 +166,19 @@ excluded from the list and 403s on direct fetch), and proves a forged
token is rejected. `scripts/seed_demo_data.py` populates the docker-compose
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.
origin(s) (`FRONTEND_ORIGINS`), `GET` and `POST` (the extract-keymen
write), `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/`)

Expand Down
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,55 @@ 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
`cataloged_person` / `person_affiliation` / `post_person_mention` /
`knowledge_graph_edge`. Directed RWR sinks teleport remaining walk
mass back to the start node so relevance stays a distribution.
- `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

- Milestone 4, Phase 2 begins (Phase 1 complete): `lineageweave/knowledge_graph.py`
-- random walk with restart (Tong, Faloutsos, & Pan, 2006) giving every
node in a Knowledge Graph a continuous relevance score from a starting
node, plus `select_related_nodes`'s per-node adaptive relevance-ratio
cutoff. No hop-count constant appears anywhere in the algorithm -- the
same ratio threshold naturally yields a larger related-set for a
well-connected node than a sparse one, which is the actual product
requirement ("각 Node와 Node마다 Depth가 다를 수 있다").
- `tests/test_knowledge_graph.py`: proves this against a synthetic graph
built so the correct answer is known by construction -- a disconnected
node scores exactly 0, symmetric spokes score identically, and a
well-connected "hub" node's adaptive related-set (5 nodes) is
measurably larger than a sparse "loner" node's (1 node) under the same
threshold.
- `docs/lineage-bi-research-notes.md`: Tong et al. (2006) moved from
"staged for later phases" into a real "Knowledge Graph traversal"
section now that it grounds shipped code.

## [0.7.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 @@ -28,6 +28,11 @@ class Settings:
# Exact browser origins allowed by CORS. Comma-separated FRONTEND_ORIGINS;
# never a wildcard -- the backend only serves the product UI.
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 Down Expand Up @@ -55,4 +60,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", ""),
)
87 changes: 87 additions & 0 deletions backend/app/keyman_ingestion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Runs a `KeymanExtractionClient` over a post and persists the result:
`cataloged_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:
"""Return the matching ``corporate_entity`` id, or None if the name is free text."""
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:
"""Reuse a same-name, same-side row so re-extraction does not duplicate."""
row = await conn.fetchrow(
"select person_id from cataloged_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 cataloged_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
Loading