LineageWeave reconstructs a plausible thread structure over records that already exist somewhere else. It does not ingest, own, or persist any source-of-truth data, and it does not perform calibrated statistical estimation. Both of those are explicitly out of scope and pushed to other repos in the ecosystem:
- Ingestion of raw exports into governed tables is mhtml-etl-gateway's job.
- Calibrated psychometric/temporal measurement (latent trait scores, trajectories, uncertainty-quantified estimates) is TEPP's job.
This is why the org-wide rule that mathematical/psychometrics computation
layers must be Rust with GPU + CPU multithreading does not apply to this
repo: LineageWeave does no such computation. Its heaviest per-request work
is fusing a handful of [0, 1] channel scores over a bounded candidate
window (reconstruct.DEFAULT_CANDIDATE_WINDOW, default 50) -- a scheduling
and orchestration problem, not a numerical-estimation one. If a future
version added real statistical inference (e.g. estimating thread-assignment
uncertainty), that layer would move into TEPP rather than being built here,
consistent with the dependency direction the ecosystem's own architecture
docs already establish (psychometrics-commons's TRD explicitly forbids a
downstream product from reimplementing a measurement engine's model).
flowchart LR
subgraph Source
R[Records<br/>id, group_key, label,<br/>occurred_at, secondary_key]
end
subgraph LineageWeave
G[Group by group_key]
C[Candidate window<br/>most recent N priors]
CH[Channels<br/>temporal · secondary_key · text · llm]
F[RankWeave<br/>weighted_convex_fuse]
T[ThreadWeave<br/>thread_messages]
end
subgraph External services, all optional
EMB[Embedding provider<br/>swap in for the text channel]
ORC[contextual-orchestrator<br/>mode=verify, llm channel]
TEPP[TEPP<br/>AnalysisRunRequest v1,<br/>calibrated measurement]
end
R --> G --> C --> CH --> F --> T --> OUT[Tree per group:<br/>roots, edges, branch points]
CH -.text channel.-> EMB
CH -.llm channel.-> ORC
OUT -.optional export.-> TEPP
| Module | Responsibility |
|---|---|
models.py |
Record, Edge, Tree -- source-agnostic data shapes |
channels.py |
Independent [0, 1] scoring functions |
chunking.py |
Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order |
embedding_client.py |
Pluggable text-embedding channel (Null default, OpenAiCompatible real impl) + chunked_max_similarity |
adjudication_client.py |
Pluggable LLM-judgment channel (Null default, ContextualOrchestrator real impl) |
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 |
rankweave_client.py |
Fail-closed RankWeave ranking port (weighted_reciprocal_rank_fuse in-process; never invent a fused score or a theta) |
reconstruct.py |
The pipeline: group → candidate window → score → fuse → thread |
lineage_persistence.py |
Flattens reconstruct trees into post_lineage_edge row specs (parent, child, fused_score) |
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) |
affiliate_tree.py |
Ancestor forest of the organizations a post's Keymen touch -- resolved rows walk parent_entity_id, unresolved names stay roots |
voc_evidence.py |
Extractive VOC excerpts: sentences that name a classified organization, or empty |
post_summary.py |
Pluggable LLM Korean summary + key events + R&R derivation for a post |
post_chat.py |
Pluggable in-popup chat's reason-and-cite step (retrieve step lives in backend/app/post_chat_ingestion.py) |
commitment_extraction.py |
Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; Null default, ContextualOrchestrator real impl |
ontology.py |
Loads docs/ontology/lineageweave-kg.ttl, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) |
period_report.py |
Fit GRM/GPCM on persisted IRT rows, FIPC-select, EAP-score a period (ADR 0003 slice 3; Bock & Mislevy, 1982) |
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 |
Known local-test-environment limitation:
adjudication_client.py'smode="verify"call depends on contextual-orchestrator'sTaskOrchestrator.route_and_verify, which as of this writing is still an open, unmerged upstream PR (ContextualWisdomLab/contextual-orchestrator#149). Until it merges, the four adjudication/chat tests that exercisemode="verify"against a real orchestrator fail withinvalid_mode(the deployedmainonly acceptsauto/route/conduct) -- confirmed by reproducing the same400directly against the orchestrator's own/v1/chat/completions, not caused by anything in this repo.mode="route"(every other pluggable client) is unaffected.
- Pluggable, never faked, channels.
NullEmbeddingClientandNullAdjudicationClientmake a channel unavailable (dropped and renormalized inactive_weights()), never silently scored as 0. A missing signal and a confidently-negative signal are different things and must not be conflated. - A minimum fused-score floor (
DEFAULT_MIN_FUSED_SCORE). Without it, every record after the first in a group gets some parent even when every candidate is a weak match -- wrong more often than useful. Seefixtures.sample_records()'s intentionally-unrelatedrec-006and its test intests/test_reconstruct.py. - A bounded candidate window (
DEFAULT_CANDIDATE_WINDOW, default 50).ponytail-tagged inreconstruct.py: keeps per-group costO(n * window)instead ofO(n^2)for large groups; raise it if recall against a labeled set ever shows true parents falling outside the window. - TEPP is a wire contract, not an import.
tepp_client.py's default transport raisesTeppNotAvailablerather than silently no-op'ing, because TEPP has no live HTTP endpoint yet; the shape is validated (AnalysisRunRequest.to_json()mirrors TEPP's published JSON Schema exactly,additionalProperties: falseand all) so wiring in a real transport is additive, not a rewrite. - RankWeave is an in-process library, not an HTTP host.
rankweave_client.py's default transport raisesRankWeaveNotAvailable.GET /api/rankingsthen returnsrankweave_not_availableand an empty ranking list. Hidden posts are omitted from every channel. See ADR 0024.
See docs/lineage-bi-research-notes.md
for the full APA 7th reference list this design is grounded in.
lineageweave's reconstruction pipeline above is being wrapped in a real
product: corp/PU-code accounts, ABAC/RBAC, posts, Keyman extraction, a
Knowledge Graph, corporate hierarchy, and issue tickets. See
docs/adr/0001-demo-identity-and-data-boundary.md
for the identity/data scope decision (real infrastructure, synthetic
identities and content) and migrations/0001_initial_schema.sql for the
3NF PostgreSQL schema (common_lookup_value, corporate_entity,
process_unit, user_account, account_affiliation, access_role /
role_permission / account_role_assignment, abac_policy, post /
post_counterparty_entity, person / person_affiliation /
post_person_mention, knowledge_graph_edge, issue_ticket,
post_lineage_edge). Real-database tests: tests/test_schema.py
(skipped without a reachable PostgreSQL server, same pattern as the
real-provider LLM tests).
docker-compose.yml runs PostgreSQL, Valkey, and a real Keycloak OIDC
provider (docker/keycloak/realm-export.json seeds a lineageweave-demo
realm with synthetic demo accounts carrying corp_code / pu_code as
custom token claims -- see README).
scripts/smoke_test_oidc.py proves the round-trip is real: it logs in as
the synthetic demo user, fetches Keycloak's live JWKS, and cryptographically
verifies the returned JWT's RS256 signature rather than just checking for an
HTTP 200. Both Postgres (docker/postgres-init/) and Keycloak
(docker/keycloak/) are build: targets that COPY their seed files in,
not bind mounts -- self-contained images that don't depend on any particular
host filesystem layout being reachable from the Docker daemon, which also
makes them reproducible in CI. Valkey is the Phase 2+ event queue (not a
traditional MQ) for asynchronous work like Keyman/Knowledge-Graph
recomputation once posts change. Postgres's app database is auto-migrated
on first boot from the same migrations/0001_initial_schema.sql file
tests/test_schema.py applies -- one schema file, no drift between what's
tested and what ships.
A FastAPI app (backend/app/main.py) over a direct asyncpg connection
pool (backend/app/db.py) -- no ORM, no file-backed database. Login is
OIDC bearer-token verification against Keycloak's live JWKS
(backend/app/auth.py): the token's sub resolves to a user_account
row, and corp_code/pu_code are read back from that account's
account_affiliation rows in Postgres, never trusted directly off the
token, matching the schema's design intent. Two authorization layers
compose per request:
- RBAC (coarse): the account's roles must grant the
post_readpermission at all, viaaccount_role_assignment->role_permission. - ABAC (row-level, on top of RBAC): a post is visible if it is public,
or if it is private and the account is affiliated with the post's
corporate_entity_id.abac_policy.condition_expressionis reserved for a richer per-policy DSL later; Phase 1 implements exactly this one fixed rule directly in Python (backend/app/main.py::_can_see_post) since it is the only rule the product currently needs -- documented there rather than over-built as a generic evaluator nothing yet exercises differently.
backend/tests/test_api.py is a real-integration test, not a mocked one:
it fetches a genuine access token from a live Keycloak, verifies the
allow/deny ABAC boundary against a throwaway migrated Postgres database
(a private post scoped to a different corporate entity is proven
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 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.
GET /api/lineage returns the ABAC-filtered reconstruct graph
({nodes, edges}) from persisted post_lineage_edge rows. Each node
includes group from the same reconstruct_group_key() rebuild uses
(persisted thread_group_key, else process unit, else corp).
POST /api/lineage/rebuild (post_admin) re-runs reconstruct() over
every source_post and rewrites those edges. Reconstruct grouping is
stored on the post as thread_group_key / secondary_grouping_key
(not derived from process unit or voc type).
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." A resolved name on
GET /counterparties carries corporate_entity_id so the popup can
open GET /api/corporate-entities/{id}/related; an unresolved name
stays null.
Phase 4 adds GET /api/posts/{post_id}/lineage (direct post_lineage_edge
links and indirect Knowledge-Graph links, kept as two separate lists --
backend/app/post_chat_ingestion.py::find_linked_post_ids),
GET /api/posts/{post_id}/summary (lineageweave/post_summary.py,
persisted in post_summary_result so a seeded demo -- including
A-100/B-200 Event Lineage nodes and the calendar commitment -- is
not empty without a live LLM; A-100/B-200 casts also persist R&R so
the popup list is not empty, and a matching Keyman name starts the
same related-node walk), and
GET/POST /api/posts/{post_id}/chat (lineageweave/post_chat.py's
reason-and-cite step over gather_chat_sources' retrieve step -- both
Event-Lineage link kinds feed the chat's context, ABAC-rechecked per
candidate post). Seeded fixture answers live in post_chat_result so
Ask is useful without a live LLM -- each fixture stores "What
happened between these events?", "Who is involved?" (Keymen, or
an explicit no-Keyman sentence), and "What is the next commitment?"
(the seeded Calendar ticket title plus due date, or an explicit
no-commitment sentence on rec-006). A missing orchestrator and no stored
match is 503; the popup shows
Chat unavailable (LLM orchestrator not configured) rather than a
raw HTTP status. After that 503 the free-text Ask box is hidden and
only seeded question chips remain -- never a fabricated answer. Evaluate, Extract
Keymen, Derive commitment, and Verify use the same 503 empty-state
pattern and then hide the action button so it cannot 503 again.
find_linked_post_ids first expands to every post
sharing a mentioned person before calling
backend/app/knowledge_graph.py::load_visible_subgraph -- that function
only loads edges among an already-known post set (its other caller,
related_for_person, pre-resolves the full set itself), it does not
discover new posts on its own; a real bug from calling it with only the
single starting post was caught while building this and is now
regression-tested (test_post_chat_cites_a_post_linked_only_via_a_shared_keyman).
React + Vite + TypeScript, pinned Node via mise.toml, pnpm via Corepack.
react-oidc-context drives a real Authorization Code redirect through
Keycloak (src/main.tsx's AuthProvider) -- no mocked auth, no static
HTML. src/api.ts calls the FastAPI backend directly with the token
Keycloak issued; src/App.tsx renders a git-branch SVG of
GET /api/lineage (click a node to open that post; post_admin can
rebuild), the post list, and a full detail popup: Korean
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;
a related corporate-entity node, a resolved Keyman affiliation,
or a classified name that resolves to a cataloged org 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
ADR 0002 for why. Served in
docker compose via a two-stage build (frontend/Dockerfile):
pnpm run build then nginx serving the static bundle, with VITE_*
config baked in at build time from the same .env ports every other
service uses (Vite embeds import.meta.env.VITE_* at build time, not
runtime, so these are Docker build args, not container env vars).
src/App.test.tsx mocks react-oidc-context's useAuth to test the
component's own render logic (login button -> signinRedirect(); the
A-100 fork DAG shows a branch point and rec-006 as its own root;
post_admin can rebuild; fetch posts with the token -> render list ->
click -> popup shows the fetched body and every panel; ask a chat
question -> click a citation -> the evidence panel shows exactly that
source post's content) -- the real
OIDC cryptography and the real LLM calls are proven elsewhere
(scripts/smoke_test_oidc.py, backend/tests/test_api.py), so this test
isn't re-proving that, only that the UI wires the pieces together
correctly. src/setupTests.ts registers @testing-library/react's
cleanup explicitly in an afterEach -- this project's vite.config.ts
deliberately runs without test.globals, which is also why RTL's own
auto-cleanup (which only self-registers when afterEach is already a
global) silently never ran before this; a real bug this phase's larger
test file surfaced (stale DOM from one test bleeding into the next).
GET /api/posts/{post_id}/affiliate-tree walks
corporate_entity.parent_entity_id for every organization a post's
Keymen are affiliated with (lineageweave/affiliate_tree.py, loaded by
backend/app/affiliate_tree_ingestion.py). The forest is the ancestor
set of those leaves, not the whole company directory -- a sibling the
post never mentions is omitted. People on the tree are buttons that
reuse GET /api/keymen/{person_id}/related so the popup Keyman walk
starts from the affiliation the buyer clicked. A resolved organization
is the same walk via GET /api/corporate-entities/{id}/related. An affiliation that did not resolve to
a corporate_entity row stays as its own root (resolved=false); that
is the same never-guess-a-parent rule
corporate_hierarchy_resolution already applies. Entity levels and
Keyman sides are labeled from common_lookup_value (Our side,
Plant, Company) so the popup never shows raw our_side / plant
codes when a label exists.
GET /api/posts and GET /api/posts/{post_id} include
voc_type_label / visibility_label from common_lookup_value so
the list badge and popup meta show Voice of Customer / Public
instead of raw codes.
GET /api/posts/{post_id}/voc-evidence returns the
common_lookup_value label for the post's voc_type_code plus the
sentences in the post body that name a counterparty or affiliated
organization (lineageweave/voc_evidence.py). A name that does not
appear yields no excerpt. Each counterparty also carries
verification_status_code / verification_evidence_url so the VOC
panel shows the same Searxng badge as Counterparties. A counterparty
name that already sits on the affiliate tree is a button that starts
the same Keyman related-node walk (Northridge Grid -> Priya Nair).
make seed writes Ada West / Priya Nair /
Northridge Grid onto A-100 proj-alpha Event Lineage posts, Jordan Hale
/ Westfield Power onto B-200, and Riverbend onto the calendar
commitment so those panels are not empty without a live extractor.
rec-006 stays uncast. The popup also wires
the already-shipped GET /api/keymen/{person_id}/related (click a
Keyman) and POST /api/posts/{post_id}/extract-keymen (post_admin).
backend/app/issue_ticket_ingestion.py + three endpoints
(GET/POST /api/posts/{post_id}/tickets, PATCH /api/tickets/{id})
close the one product-brief item with a schema table (issue_ticket)
but no implementation through Phase 4. Deliberately plain CRUD, not a
pluggable-LLM channel like keyman_ingestion.py -- ticket status is a
closed enum in common_lookup_value, and opening or updating a ticket
is a direct user action, not something extracted from text.
frontend/src/App.tsx's IssueTicketPanel is the popup's real
list/create/status-update UI for it. Status options show
common_lookup_value labels (Open / In progress / Closed)
instead of raw codes. make seed opens tickets on the
A-100 follow-up and delivery fixtures and the B-200 specification
revision so a report-member click is not "No tickets yet."
Found and fixed a real deployment bug while verifying this end to end
against the actual Docker-built stack: frontend/Dockerfile's earlier
non-root-USER hardening sed-replaced /var/run/nginx.pid, but the
real nginx:1.27-alpine base image's config uses /run/nginx.pid (no
/var prefix) -- the sed silently matched nothing, so the frontend
container never actually started. pytest alone would never have
caught this, since nothing in the Python test suite exercises the built
Docker image; this is why this project's discipline of also curling the
real Docker-built stack, not just running tests, keeps mattering.
docker-compose.yml has run a valkey service since Phase 1 -- the
brief explicitly asked for an event queue on Valkey rather than a
traditional MQ -- but nothing in the codebase ever published or
consumed an event through it; it was dead infrastructure until this
phase. backend/app/activity_stream.py closes that gap with the
smallest slice that makes Valkey load-bearing: publish_activity_event
XADDs onto a per-post stream key (activity:{post_id}, approximately
trimmed to the most recent 1000 entries so one very active post can't
grow the stream without bound), and read_activity_events reads it
straight back with XREVRANGE. Deliberately no consumer group and no
background worker -- the read path (GET /api/posts/{post_id}/activity)
queries the stream directly, which is the smaller real design for a
single reader; a consumer group is the natural next step if a second,
independent reader (e.g. a notification worker) is ever needed.
Wired into the two ticket-mutation endpoints (ticket_created on
create, ticket_status_changed on a status-changing PATCH) as the
first real producer, and surfaced in the popup as an ActivityPanel
(list + manual refresh) as the first real consumer. make seed XADDs
ticket_created for the seeded A-100 and calendar tickets so Activity
is not empty after a report-member click. Verified against
the actual Docker Compose network, not just pytest: created and
patched a ticket through the real backend container talking to the
real valkey container over the internal redis://valkey:6379/0 DNS
name, confirmed the events on the activity endpoint, and independently
confirmed the stream's existence and length with valkey-cli directly
against the valkey container.
The brief asked for two separate-sounding things: issues auto-registered
as a to-do/calendar entry with LLM-authored content, and an LLM that
derives customer commitments from a post's text. Treated as one design,
not two: a derived commitment is the ticket that appears on the
calendar (issue_ticket.due_date + commitment_summary), reusing the
Phase 5 ticket infrastructure rather than inventing a parallel "to-do"
concept (ponytail: extend, don't duplicate).
lineageweave/commitment_extraction.py is the pluggable channel --
same discipline as keyman_extraction.py/post_summary.py:
NullCommitmentExtractionClient makes the channel unavailable, never
invents a commitment. A commitment specifically needs a resolved
deadline, so the prompt is given a reference date and asked to resolve
relative phrases ("by next Friday") against it -- closer to
temporal-expression normalization (Chambers & Jurafsky, 2008) than to
ACE-style key-event extraction (Doddington et al., 2004), which is why
it is its own client rather than a field bolted onto post_summary's
key events. has_commitment: false is a legitimate result, not a parse
failure, the same missing-vs-empty discipline every parser in this repo
already keeps.
POST /api/posts/{post_id}/derive-commitment (post_admin, a real
LLM-call write action) persists the result as an issue_ticket. The
reference date handed to the client is the post's created_at (TimeML
document creation time), not wall-clock now -- otherwise a January
post's "by next Friday" lands on the Friday after the operator clicked
Derive. Re-deriving the same post updates the existing open commitment
ticket instead of stacking a duplicate calendar row. GET /api/calendar
lists every dated, not-closed ticket the account may see across all
posts, soonest first, ABAC-filtered per row the same way
read_post_lineage filters cross-post candidates. due_date is a
calendar date, not a timestamptz: a "by Friday" commitment is a
day, and binding a Python date into timestamptz midnight is an
off-by-one in any session whose TZ is not UTC. A malformed
YYYY-MM-DD is a 422, not a 500.
scripts/seed_demo_data.py inserts fixtures.ambiguous_commitment_post
(created_at 2026-01-05) and one open issue_ticket due 2026-01-09 so
GET /api/calendar is not empty on a freshly seeded stack. The same
seed writes the A-100 pricing ticket (Send Northridge Grid the revised quote, due 2026-01-12) and the B-200 revision ticket (Send Westfield Power the revised specification, due 2026-01-14) so home Calendar
lists the same dated tickets the period-report members already show.
Re-seed is idempotent. The empty-state copy is only for accounts that
truly have no dated open tickets.
First of three staged slices toward the brief's weekly/monthly
PU/team/project reports (see
ADR 0003 for the
full reasoning). fast-mlsirm already implements the LLM-as-a-Judge
-> IRT-row -> Fixed-Item Parameter Calibration pipeline the brief asks
for, provider-neutrally against contextual-orchestrator -- this slice
makes it buildable and importable in this repo, with zero product
behavior change yet.
Pinned as a git dependency (pyproject.toml, same commit-pin pattern
as rankweave). It ships a PyO3/maturin Rust core with no PyPI wheel,
so requires-python moved from >=3.10 to >=3.12 (its own floor)
and backend/Dockerfile's build stage gained a pinned, non-interactive
rustup install (build-essential for the C linker maturin needs,
--profile minimal since the build image needs rustc/cargo only,
not docs or clippy). The same pin is installed in the pytest CI job,
or pip install -e ".[backend]" cannot compile the extension. This
is the IRT compute library the brief names -- not a second TEPP, and
not a fork of TEPP's temporal engine (tepp_client.py is unchanged).
Verified both locally (import fast_mlsirm._core resolves to the
compiled extension, not the NumPy parity fallback) and against a
freshly built backend Docker image.
Slice 2 of the report pipeline: a pluggable PostEvaluationClient
(Null / ContextualOrchestrator wrapping
fast_mlsirm.ContextualOrchestratorJudge) scores a post against a
versioned three-criterion rubric (constructive stance, negative
stance, sales-lead specificity). The only persist path is
LLMJudgeResult.to_irt_row() into post_evaluation_response (one
row per post per criterion, common_lookup_value-backed codes).
POST /api/posts/{id}/evaluate is an explicit post_admin action;
GET /api/posts/{id}/evaluation is the read. The post popup shows
the persisted categories and an "Evaluate post" button. make seed
writes constructed (not judged) rubric cells for the demo public
post and A-100/B-200 fixtures so the panel is not empty without an
LLM. Thetas still come only from calibrate_period_report.
lineageweave/period_report.py assembles the stored IRT matrix,
fits GRM and GPCM via fast_mlsirm.fit_polytomous (Rust EM),
EAP-scores with score_polytomous (Bock & Mislevy, 1982), and
selects the model with fixed_item_calibration_diagnostics.
The first period free-calibrates a shared item bank
(shared_metric / all) on the pooled posts; every process unit,
corporate entity, and thread group is then FIPC-scored on that bank
so PU/team/project thetas stay on one metric. Later periods EAP-score
on those same fixed parameters (Kim, 2006 FIPC). After scoring,
information_polytomous ranks the shared-bank items by Fisher
information at the group's mean θ (Lord, 1980 max-info CAT). Rankings
persist to report_item_information. After those IRT main effects,
residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to
report_leftover_pair. Results persist to
report_period_score / report_member_score.
GET /api/reports/{grouping} lists the trend;
GET /api/reports/{grouping}/{period} is ABAC-filtered;
GET /api/reports/compare/{period} is the home-page grouping strip;
POST .../rebuild scores every grouping kind (post_admin). make seed
folds A-100/B-200 Event Lineage fixtures (and the Riverbend calendar
post) that already have constructed IRT cells into the same shared
bank as the dummy high/low band rows, so comparison-strip click
through opens those DAG posts. Report members include the earliest
open ticket title, status lookup label, and due date when one exists. The home page renders
the actual mean θ, the FIPC delta, the CAT-selected item, leftover
closest/farthest pairs above the member list, and the
PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.
The brief's latest revision marks every Knowledge Graph use (Keyman
traversal, the customer/corporate hierarchy tree, entity-relationship
classification, indirect lineage linking, in-popup chat evidence) as
requiring a real Ontology and Semantic Layer, "FULL 표준." See
ADR 0004 for the full
reasoning; in short: knowledge_graph_edge was already, structurally,
an RDF triple (subject/predicate/object) -- the gap was that its
vocabulary had never been published as a real, machine-checkable
ontology, so nothing could verify the relational schema's controlled
vocabulary (node_type, edge_type, entity_relationship_type,
person_side, corporate_entity_level) actually matches what the
Ontology/Semantic-Layer claim implies.
docs/ontology/lineageweave-kg.ttl is a real OWL 2 / RDFS / SKOS
ontology in Turtle syntax: classes for Post/Person/CorporateEntity
(with OurSidePerson/CounterpartyPerson subclasses), object
properties for each edge_type_code and entity_relationship_type
code with declared rdfs:domain/rdfs:range, and the corporate
hierarchy level ladder (Group -> Company -> Plant) as a proper SKOS
concept scheme with skos:broader/skos:narrower -- SKOS being the
W3C standard specifically for organizational/concept hierarchies, as
distinct from OWL class subsumption. PostgreSQL stays the source of
record for actual graph data; the ontology is the published semantic
specification over it, in the same sense W3C's own stack uses "semantic
layer" (RDFS/OWL as the governed conceptual layer over raw data), not a
separate BI-metrics product and not a parallel triple store.
lineageweave/ontology.py parses the Turtle file once with rdflib
(pure Python, no Rust toolchain, unlike fast-mlsirm) and exposes the
vocabulary as importable IRI constants, so application code has one
canonical name per class/property instead of re-typing lookup codes as
bare strings. GET /api/keymen/{id}/related spreads
ontology_annotations(node_type_code) onto each hydrated node so the
popup can render the class label (Person, Post, Corporate entity)
instead of the raw lookup code. tests/test_ontology.py is the real correctness check --
not just "does the file parse," but a round-trip against
scripts/seed_demo_data.py's own committed SQL, in both directions:
every lookup code the seed script inserts (for the categories this
ontology covers) must have a matching ontology term, and the ontology
must not declare a term for a code nothing actually seeds. This is the
enforcement mechanism: a future PR that adds a new edge_type or
entity_relationship_type code without updating the ontology fails
this test, not just a docstring's word.
The brief's latest revision calls out, explicitly, that a post body mixing
HTML tags and base64-embedded images needs care before Knowledge Graph
derivation: raw tags degrade an embedding model (Cai, Yu, Wen, & Ma,
2003 -- VIPS's premise that a DOM's visual/structural cues, e.g. a
block's tag and inline style, carry real segmentation signal and should
be extracted as metadata, not left inline to dilute the text an
embedding or LLM call actually reads), and font color/alignment/bullet/size
information needs to be stored separately rather than dropped. Auditing
every backend endpoint that reads source_post.post_body found this gap
was real, not hypothetical: chunking.py (DOM-aware chunking) and
image_content.py (vision-model description of embedded images) already
existed and were already tested, but no backend/app/*.py endpoint
imported either one -- extract-keymen, the summary endpoint, commitment
derivation, and chat source retrieval all sent the raw post_body column
straight to an LLM call, HTML tags, base64 image payloads, and all.
lineageweave/post_content_normalization.py closes that gap with one
function, normalize_post_body(body, vision_client=None). For a body
that isn't HTML (_looks_like_html -- a real tag such as <p>/<img>,
not a comparison like qty < 50 and price > 10), it is returned
unchanged; there is no cost to imposing DOM parsing on a plain-text VOC
record. For HTML, it reuses chunk_by_dom and, per chunk: text
becomes a text_parts entry (with its style attribute, if any,
recorded as a separate FormattingHint(chunk_index, tag, style) --
never appended into the text a model reads); an image chunk is described
through vision_client.describe() and replaced in-place with
[image: <caption> | text: <ocr>] at its original document position
(OCR text is kept -- it is what the vision call paid for, and a name
or figure in a screenshot is otherwise lost). Position matters: an
image before or after a given paragraph changes what it is evidence
for. A vision-provider exception is caught per-image so one bad
call degrades to [image: content unavailable] instead of losing the
rest of the post; vision_client=None behaves the same way by default
(NullImageContentClient, available=False), so the function is always
safe to call without a live provider configured.
chunking.py gained the actual DOM-level capture this depends on:
_DOM_BLOCK_TAGS now includes h1-h6 (a heading's tag name is itself
a VIPS-style importance cue, not just more paragraph text), and
Chunk.style carries a block's style attribute (None, not "",
when absent) alongside its text -- _BlockTextExtractor tracks it
through the existing start/end-tag stack rather than adding a second
pass over the document.
Wiring: backend/app/config.py gained Settings.vision_model (env
VISION_MODEL) -- empty means the vision channel is unavailable, the
same "no fake channel" discipline as every other pluggable client, not a
guessed default model. backend/app/main.py's _vision_client() factory
returns a real OpenAiCompatibleVisionClient (via
orchestrator_vision_client, which appends /v1 so the same
ORCHESTRATOR_BASE_URL other channels use lands on
/v1/chat/completions) only when base URL, API key, and model are all
set, else NullImageContentClient(); it is
called at all three raw-post_body-reading endpoints (extract-keymen,
post summary, commitment derivation) and threaded through
post_chat_ingestion.gather_chat_sources() so every RAG source document
in a chat answer -- not just the post the popup is currently open on --
is normalized before the reason-and-cite LLM call sees it.
GET /api/posts/{id} (the plain post-detail read) is deliberately left
untouched: the frontend renders the post as-authored, and normalizing
that response would mean users never see their own formatting.
Proven against a real orchestrator instance, not just unit tests: an
HTML-wrapped, base64-image-embedded version of the existing
ambiguous_keyman_post() fixture still correctly extracts the same real
people through the live /extract-keymen endpoint
(test_extract_keymen_normalizes_html_and_embedded_image_content).
The brief requires an external web/internal search agent to check the
truthfulness of Knowledge Graph relation inferences (Searxng named as an
acceptable implementation) -- see
ADR 0005 for the full
reasoning. entity_relationship_classification.py's LLM output (an
organization name plus a VOC/VOM/VOP/VOCC/VOCO/VOS relationship) is the
concrete target: both the organization and the relationship are the
model's inference, and nothing previously checked whether the named
organization has any real-world footprint at all.
lineageweave/relation_verification.py is grounded in FEVER-style
open-domain claim verification (Thorne, Vlachos, Christodoulopoulos, &
Mittal, 2018): retrieve external evidence, then classify the claim
against it. The implemented subset is deliberately coarse --
presence/absence of any search result (verify_corroborated /
verify_uncorroborated), catching the failure mode actually observed
(a hallucinated organization with zero web footprint), not full
NLI-based entailment scoring against retrieved passages. The real
client, SearxngRelationVerificationClient, queries a self-hosted
Searxng instance (docker/searxng/, a new Docker Compose service on a
non-default host port like every other service here) -- never a
third-party hosted search API requiring its own key, and never a
"channel unavailable" report where Docker Compose can instead genuinely
run the dependency.
post_counterparty_entity gained verification_status_code
(common_lookup_value category relation_verification_status),
verification_evidence_url, and verification_checked_at
(migrations/0001_initial_schema.sql, with 0004_relation_verification.sql
as the idempotent upgrade path). A re-classification resets these back
to verify_pending -- a prior verification was checked against the OLD
relationship label. Trigger: a separate, explicitly-invoked
POST /api/posts/{id}/verify-relations, matching this repo's existing
pattern for real-cost actions (summary, commitment derivation) that the
user triggers rather than a hidden side effect of extraction. The
post-detail popup's Counterparties section (new CounterpartyPanel
component, frontend/src/App.tsx) renders a status badge per row --
linked to the evidence URL when corroborated -- with a "Verify against
web search" action while any row is still pending.
Proven against a real, self-hosted Searxng instance, not a mocked
search client: test_verify_relations_persists_real_search_outcomes
checks a well-known public foundation name ("Mozilla Foundation")
against a deliberately fabricated one in the same request, asserting
the former comes back verify_corroborated with a real evidence URL
and the latter verify_uncorroborated with none.