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

## Pluggable channels: never fake a missing signal

`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.
`NullEmbeddingClient`, `NullAdjudicationClient`,
`NullKeymanExtractionClient`, and `NullEntityRelationshipClient` (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, invented Keyman, or guessed
relationship. A missing signal and a confidently-negative signal are
different things. Keyman extraction and entity-relationship
classification go through contextual-orchestrator the same way
adjudication does -- never a raw LLM API.

## Tests

Expand Down
15 changes: 15 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ flowchart LR
| `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 |
| `keyman_extraction.py` | Pluggable LLM extraction of two-sided (our-side/counterparty) person mentions + N:N org affiliations from a post |
| `entity_relationship_classification.py` | Pluggable LLM classification of a named organization's relationship to the post author (`rel_voc`/`rel_vom`/`rel_vop`/`rel_vocc`/`rel_voco`/`rel_vos`) |
| `corporate_hierarchy_resolution.py` | Similarity-based resolution of a free-text org name to an existing `corporate_entity` row (Bhattacharya & Getoor, 2007's candidate-generation stage) |
| `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 @@ -180,6 +183,18 @@ 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`.

Phase 3 adds `GET /api/posts/{post_id}/counterparties` (same RBAC+ABAC
gate) and extends `POST /api/posts/{post_id}/extract-keymen` to also
classify each extracted Keyman's affiliated organizations' relationship
to the post author's org (`lineageweave/entity_relationship_classification.py`,
persisted via `backend/app/entity_relationship_ingestion.py` into
`post_counterparty_entity`). Organization-name resolution into a real
`corporate_entity` row (both for Keyman affiliations and for the
relationship classifier's candidates) goes through
`lineageweave/corporate_hierarchy_resolution.py` instead of an exact
string match, so "Acme Electronics Korea Ltd." still resolves to the
same entity as "Acme Electronics Korea."

### Frontend (`frontend/`)

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

### Added

- Milestone 4, Phase 3: `lineageweave/entity_relationship_classification.py`
-- LLM classification (via contextual-orchestrator, `mode="route"`) of a
named organization's relationship to the post author's org into the
product's own six-way vocabulary (`rel_voc`/`rel_vom`/`rel_vop`/
`rel_vocc`/`rel_voco`/`rel_vos` -- `rel_`-prefixed because
`common_lookup_value.lookup_code` is unique globally across categories
and bare `voc`/`vom` were already claimed by `source_post.voc_type_code`'s own
category). Proven with a real LLM call against a genuinely hard fixture
(`fixtures.ambiguous_entity_relationship_post`): an organization that is
both a repeat customer and a newly-competing division in the same post.
- `lineageweave/corporate_hierarchy_resolution.py` -- similarity-based
resolution of a free-text organization name to an existing
`corporate_entity` row (Bhattacharya & Getoor, 2007's candidate-
generation/blocking stage of collective entity resolution, honestly
documented as that stage and not the full joint-inference version).
Wired into `backend/app/keyman_ingestion.py`, replacing the exact-
case-insensitive-match lookup it had before. `tests/test_corporate_hierarchy_resolution.py`
proves it against the same "Acme Group / Acme Electronics Korea / Acme
Electronics Gwangju Plant" hierarchy `tests/test_schema.py` already
uses: an abbreviation and a trailing legal suffix still resolve to the
right entity (not a sibling with a similar name), and a genuinely
unrelated organization resolves to `None`, not a guess.
- `tests/test_indirect_lineage_linking.py`: demonstrates the Knowledge
Graph layer finds a real relation `lineageweave.reconstruct` has no
mechanism to find at all -- two posts in different `reconstruct.py`
groups (structurally never compared against each other) still surface
as related once they share a Keyman, via `random_walk_with_restart`.
- Backend: `GET /api/posts/{post_id}/counterparties`, and
`POST /api/posts/{post_id}/extract-keymen` now also classifies and
persists each extracted Keyman's affiliated organizations'
relationships (`backend/app/entity_relationship_ingestion.py`), all on
the same RBAC+ABAC gate as the post/Keyman endpoints. Proven end to end
against a live Postgres + Keycloak + contextual-orchestrator stack.
- `docs/lineage-bi-research-notes.md`: added Zelenko, Aone, & Richardella
(2003) (relation extraction); moved Bhattacharya & Getoor (2007) from
"staged for later phases" into a real section now that it grounds
shipped code.

## [0.9.0] - 2026-08-13

### Added
Expand Down
49 changes: 49 additions & 0 deletions backend/app/entity_relationship_ingestion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Runs an `EntityRelationshipClient` over a post's already-known
organization names (typically the union of Keyman affiliations from
`keyman_ingestion.ingest_post_keymen`) and persists the classification to
`post_counterparty_entity`.
"""

from __future__ import annotations

import asyncpg

from lineageweave.entity_relationship_classification import (
EntityRelationshipClient,
OrganizationRelationship,
)


async def ingest_post_entity_relationships(
conn: asyncpg.Connection,
client: EntityRelationshipClient,
post_id: str,
post_title: str,
post_body: str,
organization_names: list[str],
) -> list[OrganizationRelationship]:
"""Classifies and persists each named organization's relationship to
the post author's org. Raises whatever `client.classify` raises (a
`NullEntityRelationshipClient` raises `RuntimeError`) -- callers
should check `client.available` first, same discipline as every other
pluggable channel in this repo.
"""
if not organization_names:
return []

relationships = client.classify(post_title, post_body, organization_names)

for relationship in relationships:
await conn.execute(
"""
insert into post_counterparty_entity (post_id, counterparty_entity_name, relationship_type_code)
values ($1, $2, $3)
on conflict (post_id, counterparty_entity_name)
do update set relationship_type_code = excluded.relationship_type_code
""",
post_id,
relationship.organization_name,
relationship.relationship_type_code,
)

return relationships
32 changes: 19 additions & 13 deletions backend/app/keyman_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,34 @@
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.
`person_affiliation` (N:N, matched to a real `corporate_entity` via
similarity-based resolution -- see
`lineageweave.corporate_hierarchy_resolution`, so an abbreviation or
trailing legal suffix still resolves, not just an exact string match),
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.corporate_hierarchy_resolution import (
CorporateEntityCandidate,
resolve_corporate_entity,
)
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 _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[CorporateEntityCandidate]:
"""All cataloged orgs, so affiliation names can resolve by similarity."""
rows = await conn.fetch("select corporate_entity_id, entity_name from corporate_entity")
return [
CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"]) for row in rows
]


async def _upsert_person(conn: asyncpg.Connection, mention: PersonMention) -> str:
Expand Down Expand Up @@ -59,6 +64,7 @@ async def ingest_post_keymen(
first, same discipline as every other pluggable channel in this repo.
"""
mentions = client.extract(post_title, post_body)
candidates = await _load_corporate_entity_candidates(conn)

for mention in mentions:
person_id = await _upsert_person(conn, mention)
Expand All @@ -68,7 +74,7 @@ async def ingest_post_keymen(
person_id,
)
for organization_name in mention.affiliated_organization_names:
corporate_entity_id = await _resolve_corporate_entity_id(conn, organization_name)
corporate_entity_id = resolve_corporate_entity(organization_name, candidates)
await conn.execute(
"""
insert into person_affiliation (person_id, affiliated_organization_name, affiliated_corporate_entity_id)
Expand Down
65 changes: 60 additions & 5 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware

from lineageweave.entity_relationship_classification import (
ContextualOrchestratorEntityRelationshipClient,
NullEntityRelationshipClient,
)
from lineageweave.keyman_extraction import (
ContextualOrchestratorKeymanExtractionClient,
NullKeymanExtractionClient,
Expand All @@ -34,6 +38,7 @@
from backend.app.auth import CurrentAccount, get_current_account
from backend.app.config import load_settings
from backend.app.db import create_pool, get_pool
from backend.app.entity_relationship_ingestion import ingest_post_entity_relationships
from backend.app.keyman_ingestion import ingest_post_keymen
from backend.app.knowledge_graph import (
fetch_post_keymen,
Expand Down Expand Up @@ -88,6 +93,16 @@ def _keyman_extraction_client():
)


def _entity_relationship_client():
"""Live orchestrator client when configured; otherwise the unavailable null."""
settings = load_settings()
if not (settings.orchestrator_base_url and settings.orchestrator_api_key):
return NullEntityRelationshipClient()
return ContextualOrchestratorEntityRelationshipClient(
base_url=settings.orchestrator_base_url, api_key=settings.orchestrator_api_key
)


def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:
"""ABAC: public rows are visible; private rows require same-corp affiliation."""
if post["visibility_code"] == "public":
Expand Down Expand Up @@ -218,6 +233,26 @@ async def read_related_keymen(
}


@app.get("/api/posts/{post_id}/counterparties")
async def read_post_counterparties(
post_id: str,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Classified counterparty orgs for one visible post."""
post = await _load_visible_post(post_id, account, pool)
async with pool.acquire() as conn:
rows = await conn.fetch(
"select counterparty_entity_name, relationship_type_code "
"from post_counterparty_entity where post_id = $1 order by counterparty_entity_name",
post_id,
)
return {
"post_id": str(post["post_id"]),
"counterparties": [dict(row) for row in rows],
}


@app.post("/api/posts/{post_id}/extract-keymen")
async def extract_post_keymen(
post_id: str,
Expand All @@ -226,21 +261,34 @@ async def extract_post_keymen(
) -> dict[str, Any]:
"""Runs Keyman extraction over a post's own title+body and persists the
result (cataloged_person / person_affiliation / post_person_mention /
knowledge_graph_edge). Gated by post_admin, not post_read: this is a
write action with a real LLM-call cost, not a read.
knowledge_graph_edge), then classifies each affiliated organization's
relationship to the post author's org (post_counterparty_entity).
Gated by post_admin, not post_read: this is a write action with a
real LLM-call cost, not a read.
"""
_require_post_admin(account)
post = await _load_visible_post(post_id, account, pool)
client = _keyman_extraction_client()
if not client.available:
keyman_client = _keyman_extraction_client()
if not keyman_client.available:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Keyman extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY",
)
relationship_client = _entity_relationship_client()
async with pool.acquire() as conn:
body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id)
post_body = "" if body_row is None else body_row["post_body"]
async with conn.transaction():
mentions = await ingest_post_keymen(conn, client, post_id, post["post_title"], body_row["post_body"])
mentions = await ingest_post_keymen(conn, keyman_client, post_id, post["post_title"], post_body)
organization_names = sorted(
{name for mention in mentions for name in mention.affiliated_organization_names}
)
# relationship_client is gated by the same settings check as
# keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY),
# so reaching here means it is available too.
relationships = await ingest_post_entity_relationships(
conn, relationship_client, post_id, post["post_title"], post_body, organization_names
)
return {
"post_id": str(post["post_id"]),
"extracted_count": len(mentions),
Expand All @@ -252,4 +300,11 @@ async def extract_post_keymen(
}
for mention in mentions
],
"counterparties": [
{
"organization_name": relationship.organization_name,
"relationship_type_code": relationship.relationship_type_code,
}
for relationship in relationships
],
}
43 changes: 42 additions & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ def seeded_db(demo_analyst_token):
"('node_type', 'node_post', 'Post'), "
"('edge_type', 'edge_mention', 'Mentioned in'), "
"('edge_type', 'edge_affiliation', 'Affiliated with'), "
"('edge_type', 'edge_co_mention', 'Co-mentioned')"
"('edge_type', 'edge_co_mention', 'Co-mentioned'), "
"('entity_relationship_type', 'rel_voc', 'Voice of Customer'), "
"('entity_relationship_type', 'rel_vom', 'Voice of Market'), "
"('entity_relationship_type', 'rel_vop', 'Voice of Partner'), "
"('entity_relationship_type', 'rel_vocc', 'Voice of Customer''s Customer'), "
"('entity_relationship_type', 'rel_voco', 'Voice of Competitor'), "
"('entity_relationship_type', 'rel_vos', 'Voice of Supplier')"
)
cur.execute(
"insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) "
Expand Down Expand Up @@ -435,6 +441,41 @@ def test_extract_keymen_persists_a_real_llm_extraction(client, demo_analyst_toke
persisted_names = {person["person_name"] for person in keymen_response.json()["keymen"]}
assert persisted_names == names

# ambiguous_keyman_post's Priya Nair is affiliated with "Northridge
# Grid" and "Northridge Holdings" -- extract-keymen should have fed
# those into the entity-relationship classifier and persisted a real
# classification for each, readable back via the counterparties endpoint.
counterparty_names = {c["organization_name"] for c in body_json["counterparties"]}
assert counterparty_names == {"Northridge Grid", "Northridge Holdings"}
valid_codes = {"rel_voc", "rel_vom", "rel_vop", "rel_vocc", "rel_voco", "rel_vos"}
assert all(c["relationship_type_code"] in valid_codes for c in body_json["counterparties"])

counterparties_response = client.get(
f"/api/posts/{new_post_id}/counterparties", headers={"Authorization": f"Bearer {demo_analyst_token}"}
)
assert counterparties_response.status_code == 200
persisted_counterparty_names = {
c["counterparty_entity_name"] for c in counterparties_response.json()["counterparties"]
}
assert persisted_counterparty_names == counterparty_names


def test_counterparties_endpoint_is_empty_before_extraction(client, demo_analyst_token, seeded_db) -> None:
response = client.get(
f"/api/posts/{seeded_db['own_private_post_id']}/counterparties",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert response.status_code == 200
assert response.json()["counterparties"] == []


def test_other_corp_private_post_counterparties_are_forbidden(client, demo_analyst_token, seeded_db) -> None:
response = client.get(
f"/api/posts/{seeded_db['other_private_post_id']}/counterparties",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert response.status_code == 403


def test_unknown_keyman_is_not_found(client, demo_analyst_token) -> None:
response = client.get(
Expand Down
Loading
Loading