diff --git a/AGENTS.md b/AGENTS.md index e81f9965a..07715a34e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a46d44418..3bfdd82fb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 | @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b92b91b..ed0157f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py new file mode 100644 index 000000000..5ca79bdee --- /dev/null +++ b/backend/app/entity_relationship_ingestion.py @@ -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 diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index cc0f98245..e5451a9bc 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -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: @@ -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) @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 87b814f66..f5fe2b73b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, @@ -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, @@ -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": @@ -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, @@ -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), @@ -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 + ], } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 08b643cbb..af1f6b76c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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) " @@ -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( diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index fb4297c37..d2fbd0a43 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -233,6 +233,8 @@ WHATWG. (2026). *HTML Living Standard — sections 4.3 (sectioning content) and Zawinski, J. (1997). *Message threading* [Design note]. jwz.org. https://www.jwz.org/doc/threading.html +Zelenko, D., Aone, C., & Richardella, A. (2003). Kernel methods for relation extraction. *Journal of Machine Learning Research*, *3*, 1083-1106. + Additional context on the Fugu / Conductor / TRINITY test-time-compute-allocation research the `llm` channel's design follows is maintained in [contextual-orchestrator's own literature register](https://github.com/ContextualWisdomLab/contextual-orchestrator/blob/main/docs/architecture.md) rather than duplicated here, so the two repos do not drift out of sync. @@ -265,10 +267,31 @@ threshold yields a five-node related-set from a well-connected "hub" node and a one-node related-set from a sparsely-connected node, with no hop-count constant anywhere in the algorithm or the test. -## Staged for later phases (cited now so they are not lost) - -- **Bhattacharya & Getoor (2007)** -- collective entity resolution -- - backs the corporate-hierarchy-tree feature (resolving "Acme Group" / - "Acme Electronics Korea" / "Acme Electronics Gwangju Plant" as related - entities in one collective inference pass rather than independent - string-matching per pair). Staged for Phase 3. +## Entity-relationship classification and corporate hierarchy resolution (Phase 3) + +`lineageweave/entity_relationship_classification.py` treats "is this named +organization a customer, competitor, partner, ...?" as relation +extraction (Zelenko, Aone, & Richardella, 2003): the relationship between +the post author's own organization and each named entity, not a property +of the string alone -- the same organization can be classified differently +across two different posts (or, per the fixture used in its real-provider +test, could plausibly be read either way within one post, e.g. a current +customer whose new division has started competing). The org's own +`voc`/`vom`/`vop`/`vocc`/`voco`/`vos` vocabulary maps directly onto this as +the classifier's closed output set; an unrecognized code from the model is +dropped rather than guessed, same discipline as Keyman extraction. + +`lineageweave/corporate_hierarchy_resolution.py` implements the candidate- +generation and similarity-scoring stage of collective entity resolution +(Bhattacharya & Getoor, 2007) -- resolving "Acme Electronics Korea Ltd." or +"Acme Elec Korea" mentioned in free text to the existing `corporate_entity` +row a human would recognize it as, via normalized string similarity, with +an explicit `None` (not a guess) when nothing clears the similarity +threshold. This is honestly the *first* stage of what Bhattacharya & +Getoor call collective resolution, not the full joint-inference version: +a genuinely collective resolver would also weigh which other entities are +co-mentioned in the same post against each candidate's own known +affiliates, and could resolve two different ambiguous mentions in one post +jointly. That joint step is a documented upgrade path, not yet needed -- +nothing in this product's real usage so far has shown single-mention +similarity scoring under- or over-resolving. diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index c2a1d7a1f..1adf35fbc 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -7,17 +7,21 @@ for the literature this design is grounded in. """ +from .corporate_hierarchy_resolution import resolve_corporate_entity +from .entity_relationship_classification import OrganizationRelationship from .knowledge_graph import random_walk_with_restart, select_related_nodes from .models import Edge, Record, Tree from .reconstruct import reconstruct __all__ = [ "Edge", + "OrganizationRelationship", "Record", "Tree", "random_walk_with_restart", "reconstruct", + "resolve_corporate_entity", "select_related_nodes", ] -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/lineageweave/corporate_hierarchy_resolution.py b/lineageweave/corporate_hierarchy_resolution.py new file mode 100644 index 000000000..1fa480cc3 --- /dev/null +++ b/lineageweave/corporate_hierarchy_resolution.py @@ -0,0 +1,86 @@ +"""Resolves a free-text organization name mentioned in a post to an +existing `corporate_entity` row, even when the string doesn't match +exactly -- an abbreviation ("Acme Elec Korea"), a trailing legal suffix +("Acme Electronics Korea Ltd."), or a subsidiary's own trading name should +still resolve to the same entity a human would recognize. + +Grounded in Bhattacharya & Getoor (2007): collective entity resolution +argues that ambiguous references are best resolved using relational +context (which other entities/records co-occur with this one), not string +similarity in isolation. This module implements the practical first stage +most collective-ER pipelines still need -- candidate generation and +similarity-based scoring against known entities -- documented honestly as +that stage, not the full joint/collective inference: a genuinely collective +resolver would also weigh which OTHER organizations and people are +co-mentioned in the same post against the target entity's own known +affiliates, and could resolve two *different* ambiguous mentions in the +same post jointly rather than independently. That joint step is a real +upgrade path once real usage shows single-mention similarity scoring +under- or over-resolving in practice -- it is not implemented here because +nothing yet demonstrates the need for it over this simpler, cheaper stage. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from dataclasses import dataclass +from difflib import SequenceMatcher + +DEFAULT_MIN_SIMILARITY = 0.6 + +# Legal-entity suffixes stripped before comparison so "Acme Electronics +# Korea Ltd." and "Acme Electronics Korea" don't get penalized for a +# difference that carries no identifying information. +_CORPORATE_SUFFIXES = ("inc", "llc", "corp", "co", "ltd", "gmbh", "plc", "kk") +_SUFFIX_PATTERN = re.compile(r"\b(?:" + "|".join(_CORPORATE_SUFFIXES) + r")\b") +_PUNCTUATION_PATTERN = re.compile(r"[.,]") +_WHITESPACE_PATTERN = re.compile(r"\s+") + + +def normalize_organization_name(name: str) -> str: + """Lowercases, strips punctuation and common legal-entity suffixes, and + collapses whitespace -- the normalization both sides of a similarity + comparison go through. + """ + lowered = _PUNCTUATION_PATTERN.sub("", name.strip().lower()) + lowered = _SUFFIX_PATTERN.sub("", lowered) + return _WHITESPACE_PATTERN.sub(" ", lowered).strip() + + +@dataclass(frozen=True) +class CorporateEntityCandidate: + """One existing `corporate_entity` row eligible to resolve against.""" + + corporate_entity_id: str + entity_name: str + + +def resolve_corporate_entity( + mentioned_name: str, + candidates: Sequence[CorporateEntityCandidate], + min_similarity: float = DEFAULT_MIN_SIMILARITY, +) -> str | None: + """Returns the best-matching candidate's `corporate_entity_id`, or + `None` if no candidate clears `min_similarity`. + + Returning `None` for a genuine non-match is the point, not a failure + case to work around: a wrong hierarchy link corrupts every downstream + Knowledge Graph traversal through it, so "no confident match" must + stay a real, distinguishable outcome from "matched entity X." + """ + normalized_mention = normalize_organization_name(mentioned_name) + if not normalized_mention: + return None + + best_id: str | None = None + best_score = 0.0 + for candidate in candidates: + score = SequenceMatcher( + None, normalized_mention, normalize_organization_name(candidate.entity_name) + ).ratio() + if score > best_score: + best_score = score + best_id = candidate.corporate_entity_id + + return best_id if best_score >= min_similarity else None diff --git a/lineageweave/entity_relationship_classification.py b/lineageweave/entity_relationship_classification.py new file mode 100644 index 000000000..d1a2056ca --- /dev/null +++ b/lineageweave/entity_relationship_classification.py @@ -0,0 +1,193 @@ +"""Pluggable entity-relationship classification: for each organization +named in a post's text, what is that organization's relationship to the +post author's own organization -- partner, competitor, customer, +customer's-customer, market, or supplier? + +Grounded in relation extraction from text (Zelenko, Aone, & Richardella, +2003): classifying the semantic relation between a document's subject and +a named entity mentioned in it, rather than treating the entity as an +undifferentiated string. This maps onto the product's own six-way +vocabulary -- ``rel_voc``/``rel_vom``/``rel_vop``/``rel_vocc``/``rel_voco``/ +``rel_vos`` (``rel_`` prefixed: ``common_lookup_value.lookup_code`` is +unique GLOBALLY across categories, and bare ``voc``/``vom`` are already +claimed by ``source_post.voc_type_code``'s own category) -- which in practice +collapses to "customer" and "competitor" most of the time; ``rel_vos`` +(supplier) is the unusual case that still needs to classify correctly +because it is rare, not because it never happens. + +Same pluggable-client, never-fake-a-missing-channel discipline as +``keyman_extraction``: :class:`NullEntityRelationshipClient` makes the +channel unavailable, never guesses a relationship type. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Protocol + +from .http_client import post_json + +# post_counterparty_entity.relationship_type_code values (common_lookup_value, +# category "entity_relationship_type"). VOC/VOM/VOP/VOCC/VOCO are the +# vocabulary the org already has but rarely applies consistently; VOS is +# the "edge case some situations present" the product brief calls out. +# Prefixed "rel_" because common_lookup_value.lookup_code is unique GLOBALLY +# across every category (documented in migrations/0001_initial_schema.sql), +# and bare "voc"/"vom" are already claimed by source_post.voc_type_code's own +# "voc_type" category -- a post's own type and a specific counterparty's +# relationship type are different columns that happen to draw on the same +# VOC/VOM-style abbreviations, so they need distinct literal codes. +VOC = "rel_voc" # Voice of Customer -- this org buys from the post author's org +VOM = "rel_vom" # Voice of Market -- a general market signal, no single counterparty +VOP = "rel_vop" # Voice of Partner +VOCC = "rel_vocc" # Voice of Customer's Customer -- one hop further down the chain +VOCO = "rel_voco" # Voice of Competitor +VOS = "rel_vos" # Voice of Supplier -- the uncommon edge case +_VALID_RELATIONSHIP_CODES = frozenset({VOC, VOM, VOP, VOCC, VOCO, VOS}) + + +@dataclass(frozen=True) +class OrganizationRelationship: + """One organization's classified relationship to the post author's org.""" + + organization_name: str + relationship_type_code: str + + +class EntityRelationshipClient(Protocol): + """Classifies each named organization's relationship to the post author.""" + + available: bool + + def classify( + self, post_title: str, post_body: str, organization_names: list[str] + ) -> list[OrganizationRelationship]: + """Return one relationship code per named organization. + + Implementations must raise if they cannot classify. Protocol stubs + raise ``NotImplementedError`` so a no-op body is never treated as + a successful empty result (a missing signal is not zero relations). + """ + raise NotImplementedError + + +class NullEntityRelationshipClient: + """No LLM orchestrator configured -- relationship classification is unavailable.""" + + available = False + + def classify( + self, post_title: str, post_body: str, organization_names: list[str] + ) -> list[OrganizationRelationship]: + raise RuntimeError( + "NullEntityRelationshipClient cannot classify; check .available first" + ) + + +_CLASSIFICATION_PROMPT_TEMPLATE = """\ +Read the post below. For each organization named in the list, classify its +relationship to the post author's own organization using EXACTLY one of +these codes: + rel_voc = this organization is a customer buying from the post author's org + rel_vom = the mention is a general market signal, not a specific counterparty + rel_vop = this organization is a partner + rel_vocc = this organization is a customer OF one of the post author's own + customers (one hop further down the chain), not a direct customer + rel_voco = this organization is a competitor + rel_vos = this organization is a supplier to the post author's org + +An organization can genuinely be more than one of these across different +parts of its business (e.g. a current customer that also competes with the +post author in a different product line) -- pick whichever relationship +the text actually describes for THIS post, not a general assumption about +the organization. + +Reply with ONLY a JSON array (no markdown fences, no prose), where each +element has exactly these fields: + "organization_name": exactly one of the names from the list below + "relationship_type_code": one of rel_voc, rel_vom, rel_vop, rel_vocc, rel_voco, rel_vos + +Organizations to classify: {organization_names} + +Post title: {title} +Post body: {body} +""" + +_CODE_FENCE_PATTERN = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) + + +def _strip_code_fence(content: str) -> str: + match = _CODE_FENCE_PATTERN.search(content) + return match.group(1) if match else content + + +def parse_classification_response( + content: str, organization_names: list[str] +) -> list[OrganizationRelationship]: + """Parses the LLM's JSON array response into `OrganizationRelationship`s. + + An entry naming an organization not in `organization_names`, or with a + `relationship_type_code` outside the six valid codes, is skipped rather + than guessed at -- same discipline as `keyman_extraction.parse_keyman_response`: + a wrong classification corrupts downstream Knowledge Graph edges, so a + dropped entry is safer than an invented one. + """ + try: + parsed = json.loads(_strip_code_fence(content).strip()) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + + valid_names = set(organization_names) + results: list[OrganizationRelationship] = [] + for entry in parsed: + if not isinstance(entry, dict): + continue + name = entry.get("organization_name") + code = entry.get("relationship_type_code") + if not isinstance(name, str) or name not in valid_names: + continue + if code not in _VALID_RELATIONSHIP_CODES: + continue + results.append(OrganizationRelationship(organization_name=name, relationship_type_code=code)) + return results + + +class ContextualOrchestratorEntityRelationshipClient: + """Calls ``POST {base_url}/v1/chat/completions`` with ``mode="route"``.""" + + available = True + + def __init__( + self, base_url: str, api_key: str, *, reasoning_effort: str = "medium", timeout: float = 60.0 + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._reasoning_effort = reasoning_effort + self._timeout = timeout + + def classify( + self, post_title: str, post_body: str, organization_names: list[str] + ) -> list[OrganizationRelationship]: + if not organization_names: + return [] + prompt = _CLASSIFICATION_PROMPT_TEMPLATE.format( + organization_names=json.dumps(organization_names, ensure_ascii=False), + title=post_title, + body=post_body, + ) + body = post_json( + f"{self._base_url}/v1/chat/completions", + { + "messages": [{"role": "user", "content": prompt}], + "mode": "route", + "reasoning_effort": self._reasoning_effort, + }, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + content = body["choices"][0]["message"]["content"] + return parse_classification_response(content, organization_names) diff --git a/lineageweave/fixtures.py b/lineageweave/fixtures.py index c7fe59111..e97c878b3 100644 --- a/lineageweave/fixtures.py +++ b/lineageweave/fixtures.py @@ -59,3 +59,28 @@ def ambiguous_keyman_post() -> tuple[str, str]: "minutes later." ) return title, body + + +def ambiguous_entity_relationship_post() -> tuple[str, str, list[str]]: + """A synthetic post where a keyword-matcher would get the relationship + classification wrong: the same organization is both a current customer + (in one product line) and a known competitor (in another), described + in running prose rather than a labeled list. Also names a supplier + (the uncommon "vos" case) and a pure market-signal mention with no + single counterparty. + + Returns (title, body, organization_names). + """ + title = "Meridian account review and switchgear market note" + body = ( + "Meridian Utilities placed a repeat order for our transformer line " + "this quarter -- their third since the original installation -- but " + "their newly-acquired switchgear division has started bidding " + "directly against us on two municipal contracts, undercutting our " + "quote by double digits. Separately, Colby Insulation shipped the " + "replacement gasket stock a week early, which kept the Meridian " + "order from slipping. Industry chatter at the regional utilities " + "conference suggested overall grid-modernization spending is up " + "this year, though no specific buyer named that directly." + ) + return title, body, ["Meridian Utilities", "Colby Insulation"] diff --git a/pyproject.toml b/pyproject.toml index aea6038d2..043669c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.9.0" +version = "0.10.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" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index ac38112bd..15e70ec0a 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -86,7 +86,13 @@ def seed(postgres_dsn: str, subjects: dict[str, str]) -> None: ('node_type', 'node_post', 'Post', 2), ('edge_type', 'edge_mention', 'Mentioned in', 0), ('edge_type', 'edge_affiliation', 'Affiliated with', 1), - ('edge_type', 'edge_co_mention', 'Co-mentioned', 2) + ('edge_type', 'edge_co_mention', 'Co-mentioned', 2), + ('entity_relationship_type', 'rel_voc', 'Voice of Customer', 0), + ('entity_relationship_type', 'rel_vom', 'Voice of Market', 1), + ('entity_relationship_type', 'rel_vop', 'Voice of Partner', 2), + ('entity_relationship_type', 'rel_vocc', 'Voice of Customer''s Customer', 3), + ('entity_relationship_type', 'rel_voco', 'Voice of Competitor', 4), + ('entity_relationship_type', 'rel_vos', 'Voice of Supplier', 5) on conflict (lookup_code) do nothing """ ) diff --git a/tests/test_corporate_hierarchy_resolution.py b/tests/test_corporate_hierarchy_resolution.py new file mode 100644 index 000000000..218fd4e00 --- /dev/null +++ b/tests/test_corporate_hierarchy_resolution.py @@ -0,0 +1,65 @@ +"""Tests for lineageweave.corporate_hierarchy_resolution, against a +synthetic hierarchy fixture structurally identical to the one already used +in tests/test_schema.py's real-database test (Acme Group -> Acme +Electronics Korea -> Acme Electronics Gwangju Plant), so the correct +resolution is known by construction: an abbreviation or trailing legal +suffix of one of these three names must resolve to it, and an unrelated +organization name must not resolve to anything. +""" + +from __future__ import annotations + +from lineageweave.corporate_hierarchy_resolution import ( + CorporateEntityCandidate, + normalize_organization_name, + resolve_corporate_entity, +) + +_CANDIDATES = [ + CorporateEntityCandidate("group-id", "Acme Group"), + CorporateEntityCandidate("korea-id", "Acme Electronics Korea"), + CorporateEntityCandidate("gwangju-id", "Acme Electronics Gwangju Plant"), +] + + +def test_exact_name_resolves() -> None: + assert resolve_corporate_entity("Acme Electronics Korea", _CANDIDATES) == "korea-id" + + +def test_trailing_legal_suffix_still_resolves() -> None: + assert resolve_corporate_entity("Acme Electronics Korea Ltd.", _CANDIDATES) == "korea-id" + + +def test_abbreviation_still_resolves() -> None: + assert resolve_corporate_entity("Acme Elec Korea", _CANDIDATES) == "korea-id" + + +def test_resolves_to_the_correct_sibling_not_a_different_one() -> None: + """The whole point of similarity scoring over "any partial match": + a mention close to the Gwangju plant must resolve to the plant, not + accidentally to the parent "Acme Electronics Korea" it shares most of + its name with. + """ + assert resolve_corporate_entity("Acme Gwangju Plant", _CANDIDATES) == "gwangju-id" + + +def test_unrelated_organization_does_not_resolve() -> None: + """A genuine non-match must return None, not the closest-available + guess -- a wrong hierarchy link corrupts every downstream Knowledge + Graph traversal through it. + """ + assert resolve_corporate_entity("Totally Different Company", _CANDIDATES) is None + + +def test_empty_mention_does_not_resolve() -> None: + assert resolve_corporate_entity("", _CANDIDATES) is None + assert resolve_corporate_entity(" ", _CANDIDATES) is None + + +def test_no_candidates_does_not_resolve() -> None: + assert resolve_corporate_entity("Acme Electronics Korea", []) is None + + +def test_normalize_strips_suffix_punctuation_and_case() -> None: + assert normalize_organization_name("Acme Electronics Korea, Ltd.") == "acme electronics korea" + assert normalize_organization_name(" ACME Group ") == "acme group" diff --git a/tests/test_entity_relationship_classification.py b/tests/test_entity_relationship_classification.py new file mode 100644 index 000000000..e8f6802ff --- /dev/null +++ b/tests/test_entity_relationship_classification.py @@ -0,0 +1,97 @@ +"""Tests for lineageweave.entity_relationship_classification. + +parse_classification_response's tests need no live provider. The +real-provider test is skipped unless LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL/ +_API_KEY are set (same env vars keyman_extraction's real-provider test +uses), and runs against fixtures.ambiguous_entity_relationship_post() -- +an organization that is genuinely both a customer and a competitor in the +same post, so a keyword-matcher would get it wrong. +""" + +from __future__ import annotations + +import os + +import pytest + +from lineageweave.entity_relationship_classification import ( + VOC, + VOCO, + VOS, + ContextualOrchestratorEntityRelationshipClient, + NullEntityRelationshipClient, + parse_classification_response, +) + + +def test_null_relationship_client_is_unavailable_not_empty_relations() -> None: + client = NullEntityRelationshipClient() + assert client.available is False + with pytest.raises(RuntimeError): + client.classify("any title", "any body", ["Acme Corp"]) +from lineageweave.fixtures import ambiguous_entity_relationship_post + + +def test_parses_a_well_formed_json_array() -> None: + content = ( + '[{"organization_name": "Acme Corp", "relationship_type_code": "rel_voc"}, ' + '{"organization_name": "Bolt Supply", "relationship_type_code": "rel_vos"}]' + ) + results = parse_classification_response(content, ["Acme Corp", "Bolt Supply"]) + assert {r.organization_name: r.relationship_type_code for r in results} == { + "Acme Corp": "rel_voc", + "Bolt Supply": "rel_vos", + } + + +def test_entry_naming_an_organization_not_in_the_input_list_is_skipped() -> None: + content = '[{"organization_name": "Unlisted Corp", "relationship_type_code": "rel_voc"}]' + assert parse_classification_response(content, ["Acme Corp"]) == [] + + +def test_entry_with_invalid_relationship_code_is_skipped() -> None: + content = '[{"organization_name": "Acme Corp", "relationship_type_code": "not_a_real_code"}]' + assert parse_classification_response(content, ["Acme Corp"]) == [] + + +def test_empty_array_is_no_relationships() -> None: + assert parse_classification_response("[]", ["Acme Corp"]) == [] + + +def test_invalid_json_returns_empty_list() -> None: + assert parse_classification_response("not json", ["Acme Corp"]) == [] + + +_ORCHESTRATOR_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL") +_ORCHESTRATOR_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY") + + +@pytest.mark.skipif( + not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY), + reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run", +) +def test_contextual_orchestrator_classifies_a_dual_role_organization() -> None: + """A real LLM call against a genuinely hard case: Meridian Utilities is + both a repeat customer (transformer line) and a new competitor + (switchgear division bidding against us) in the SAME post -- a + keyword-matcher scanning for "customer" vs. "competitor" language + would likely pick whichever word appears, not reason about which + relationship the post is actually about. Colby Insulation is the + unambiguous supplier case (the "vos" edge case the product brief + calls out as uncommon but real). + """ + client = ContextualOrchestratorEntityRelationshipClient( + base_url=_ORCHESTRATOR_BASE_URL, api_key=_ORCHESTRATOR_API_KEY + ) + title, body, organization_names = ambiguous_entity_relationship_post() + + results = client.classify(title, body, organization_names) + + by_name = {r.organization_name: r.relationship_type_code for r in results} + assert set(by_name) == {"Meridian Utilities", "Colby Insulation"} + # Meridian is defensibly either reading (customer-of-record placing the + # order, or the newly-competing division) -- the real assertion is that + # the model picked ONE of the two relationships the text actually + # describes, not something unrelated. + assert by_name["Meridian Utilities"] in {VOC, VOCO} + assert by_name["Colby Insulation"] == VOS diff --git a/tests/test_indirect_lineage_linking.py b/tests/test_indirect_lineage_linking.py new file mode 100644 index 000000000..b20e990b6 --- /dev/null +++ b/tests/test_indirect_lineage_linking.py @@ -0,0 +1,91 @@ +"""Demonstrates that the Knowledge Graph layer (lineageweave.knowledge_graph) +finds a real, useful relation between two posts that lineageweave.reconstruct +has no mechanism to find at all -- not just a weaker one. + +Two posts about entirely different topics, in different reconstruct.py +groups (so reconstruct() never even compares them against each other -- +it only fuses candidates within one group_key), still share the same +Keyman. A human reviewing "what else touches this Keyman" would want both +posts surfaced; only the KG layer can do that, because it indexes by +shared entities (people, organizations) rather than by topical/temporal +proximity within one thread. +""" + +from __future__ import annotations + +from datetime import datetime + +from lineageweave.knowledge_graph import ( + NODE_POST, + adjacency_from_edges, + knowledge_graph_edges_for_post, + node_key, + random_walk_with_restart, + select_related_nodes, +) +from lineageweave.models import Record +from lineageweave.reconstruct import reconstruct + +_SHARED_PERSON_ID = "person-shared-keyman" + + +def _unrelated_posts() -> list[Record]: + """Two posts in different groups, far apart in time, about unrelated + topics -- exactly the shape reconstruct.py should NOT link, and by + construction cannot: reconstruct() only ever compares records that + share a group_key. + """ + return [ + Record( + "post-a", + "group-transformers", + "Transformer bid workshop follow-up", + datetime(2026, 1, 1), + "proj-transformers", + ), + Record( + "post-b", + "group-switchgear", + "Switchgear maintenance schedule change", + datetime(2026, 6, 1), + "proj-switchgear", + ), + ] + + +def test_reconstruct_never_links_posts_in_different_groups() -> None: + """Not a weak link, no probabilistic near-miss -- reconstruct.py's + grouping means posts in different groups are structurally never + compared, so there is categorically no edge between them, by design. + """ + records = _unrelated_posts() + trees = reconstruct(records) + + tree_by_group = {tree.group_key: tree for tree in trees} + assert set(tree_by_group) == {"group-transformers", "group-switchgear"} + all_edges = [edge for tree in trees for edge in tree.edges] + assert all(edge.child_id not in {"post-a", "post-b"} or edge.parent_id not in {"post-a", "post-b"} for edge in all_edges) + # Neither post ever appears as the other's parent or child -- there is + # no cross-group edge to even check for a specific pair here, which is + # the point: the two records were never candidates for each other. + linked_pairs = {(edge.parent_id, edge.child_id) for edge in all_edges} + assert ("post-a", "post-b") not in linked_pairs + assert ("post-b", "post-a") not in linked_pairs + + +def test_knowledge_graph_surfaces_the_shared_keyman_connection() -> None: + """The same two posts, indexed by a Keyman mentioned in both -- the + Knowledge Graph layer finds the relation reconstruct() structurally + cannot, because it operates on a different signal (shared entities, + not topical/temporal proximity within one thread). + """ + edges = knowledge_graph_edges_for_post("post-a", [_SHARED_PERSON_ID]) + edges += knowledge_graph_edges_for_post("post-b", [_SHARED_PERSON_ID]) + + adjacency = adjacency_from_edges(edges) + start = node_key(NODE_POST, "post-a") + scores = random_walk_with_restart(adjacency, start_node=start) + related = dict(select_related_nodes(scores, start_node=start)) + + assert node_key(NODE_POST, "post-b") in related + assert related[node_key(NODE_POST, "post-b")] > 0 diff --git a/uv.lock b/uv.lock index ee688e932..0efcf15aa 100644 --- a/uv.lock +++ b/uv.lock @@ -438,7 +438,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.9.0" +version = "0.10.0" source = { virtual = "." } dependencies = [ { name = "certifi" },