From b65ee744c23343f78d77fbe733940e9a835f1505 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 16:53:00 +0900 Subject: [PATCH 01/27] feat(lineage): require TEPP criterion anchor for weights --- backend/app/analysis_run_start.py | 56 ++++++++++- backend/app/lineage_ingestion.py | 96 +++++++++++++++---- ...-psychometric-channel-weight-estimation.md | 1 + .../adr/0200-channel-weight-reconciliation.md | 9 +- docs/adr/0205-tepp-lineage-anchor.md | 56 +++++++++++ .../0207_lineage_weight_tepp_anchor.sql | 21 ++++ .../0207_lineage_weight_tepp_anchor.sql | 2 + tests/test_analysis_run_start.py | 67 ++++++++++++- tests/test_lineage_ingestion.py | 84 ++++++++++++++-- 9 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0205-tepp-lineage-anchor.md create mode 100644 migrations/0207_lineage_weight_tepp_anchor.sql create mode 100644 migrations/rollback/0207_lineage_weight_tepp_anchor.sql diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 54a50f733..3a9d1a7d6 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -45,8 +45,9 @@ _RUNNING = "analysis_status_running" _SUCCEEDED = "analysis_status_succeeded" _FAILED = "analysis_status_failed" -_TEPP_MODEL_CONTRACT = "tepp-analysis-run-v1" -_TEPP_OUTPUT_PROFILE = "calibrated_event_measurement" +_TEPP_MODEL_CONTRACT = "tepp-lineage-criterion-v1" +_TEPP_OUTPUT_PROFILE = "lineage_pair_criterion_anchor" +_TEPP_LINEAGE_ANCHOR_SCHEMA = "tepp.lineage_criterion_anchor.v1" _TOPIC_LINEAGE_MODEL_CONTRACT = "tepp-topic-lineage-v1" _TOPIC_LINEAGE_OUTPUT_PROFILE = "topic_identity_lineage" @@ -286,8 +287,10 @@ async def _persist_tepp_result( *, analysis_run_id: str, envelope: dict[str, Any], + expected_snapshot_sha256: str, + expected_knowledge_cutoff: datetime, ) -> bool: - """Persist only a validated, remote-completed TEPP envelope.""" + """Persist a completed TEPP envelope and any exact lineage anchor projection.""" remote_run_id = envelope.get("analysis_run_id") or envelope.get("run_id") if not isinstance(remote_run_id, str) or not remote_run_id.strip(): return False @@ -307,6 +310,51 @@ async def _persist_tepp_result( result_json, result_sha256, ) + anchor = envelope.get("result") + if ( + envelope.get("result_schema_version") == _TEPP_LINEAGE_ANCHOR_SCHEMA + and isinstance(anchor, dict) + ): + try: + estimation_run_id = str(UUID(str(anchor["estimation_run_id"]))) + anchor_cutoff = datetime.fromisoformat( + str(anchor["knowledge_cutoff"]).replace("Z", "+00:00") + ) + except (KeyError, TypeError, ValueError): + anchor = None + expected_cutoff = expected_knowledge_cutoff + if expected_cutoff.tzinfo is None: + expected_cutoff = expected_cutoff.replace(tzinfo=timezone.utc) + if anchor is not None and ( + anchor.get("anchor_kind_code") != "lineage_pair_criterion" + or anchor.get("contract_version") != 1 + or anchor.get("source_snapshot_sha256") != expected_snapshot_sha256 + or anchor_cutoff != expected_cutoff + or anchor.get("criterion_validity_status") != "accepted" + or type(anchor.get("validated_pair_count")) is not int + or anchor["validated_pair_count"] <= 0 + ): + anchor = None + if anchor is not None: + await conn.execute( + """ + insert into lineage_weight_tepp_anchor + (estimation_run_id, tepp_analysis_run_id, + anchor_kind_code, anchor_contract_version, + source_snapshot_sha256, knowledge_cutoff, + criterion_validity_status_code, validated_pair_count) + values ($1, $2, $3, $4, $5, $6, $7, $8) + on conflict (estimation_run_id) do nothing + """, + estimation_run_id, + analysis_run_id, + anchor["anchor_kind_code"], + anchor["contract_version"], + anchor["source_snapshot_sha256"], + anchor_cutoff, + anchor["criterion_validity_status"], + anchor["validated_pair_count"], + ) except (asyncpg.PostgresError, TypeError, ValueError): return False return True @@ -966,6 +1014,8 @@ async def _deliver_tepp_measurement( conn, analysis_run_id=analysis_run_id, envelope=envelope, + expected_snapshot_sha256=str(locked["snapshot_sha256"]), + expected_knowledge_cutoff=locked["knowledge_cutoff"], ): status_code = _FAILED failure_code = "tepp_result_not_persisted" diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index 7d3107db1..b0f6c4e28 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -28,14 +28,9 @@ from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.models import Edge, Record -# Accepted ADR 0200 (points 2-3) authorizes exactly one anchor method: -# expected-information estimates honestly labeled as validated by the -# channels' internal response structure only, pending the TEPP -# criterion-validity gate. When that gate exists, a set that fails it is -# retired and this stays the only place an anchor method is ever added -# -- ADR-first, per ADR 0145's original condition. +# ADR 0205 authorizes only a completed, persisted TEPP criterion anchor. _SUPPORTED_ANCHOR_METHOD_CODES: frozenset[str] = frozenset( - {"unanchored_internal_structure"} + {"tepp_lineage_criterion_v1"} ) @@ -126,12 +121,11 @@ async def load_estimated_channel_weights( ) -> dict[str, float] | None: """Load only a complete vector from an independently anchored method. - No anchor method is currently authorized (ADR 0200 point 3 names the - conditions under which one becomes authorized). A partial or invalid - vector returns ``None`` rather than being repaired. A database that has - not applied migration 0135 is likewise an unavailable state, detected - without issuing a statement that would abort the caller's outer - PostgreSQL transaction. + ADR 0205 authorizes only the exact persisted TEPP lineage-criterion + contract. A partial, internally anchored, or identity-mismatched vector + returns ``None`` rather than being repaired. A database missing either + persistence table is likewise an unavailable state, detected without an + aborting query inside the caller's transaction. Since migration 0200 one weight set is persisted per active-channel combination (``channel_set_code``): the corpus-wide rebuild's three @@ -145,6 +139,11 @@ async def load_estimated_channel_weights( ) if not table_exists: return None + anchor_table_exists = await conn.fetchval( + "select to_regclass('public.lineage_weight_tepp_anchor') is not null" + ) + if not anchor_table_exists: + return None # Pre-0200 schemas lack channel_set_code; probe via the catalog (never # a failing statement, which would abort the caller's transaction). # Pre-0200 rows form one implicit deterministic set. @@ -155,14 +154,30 @@ async def load_estimated_channel_weights( " and column_name = 'channel_set_code')" ) set_column_sql = ( - "channel_set_code" if set_column_exists else "'channel_set_deterministic'" + "weight.channel_set_code" if set_column_exists else "'channel_set_deterministic'" ) all_rows = await conn.fetch( f"select {set_column_sql} as channel_set_code, " - "channel_code, weight_value, estimation_run_id, " - "estimation_method_code, estimator_version, anchor_method_code, " - "source_snapshot_sha256, sample_pair_count, knowledge_cutoff " - "from lineage_channel_weight" + "weight.channel_code, weight.weight_value, weight.estimation_run_id, " + "weight.estimation_method_code, weight.estimator_version, weight.anchor_method_code, " + "weight.source_snapshot_sha256, weight.sample_pair_count, weight.knowledge_cutoff, " + "anchor.anchor_kind_code, anchor.anchor_contract_version, " + "anchor.source_snapshot_sha256 as anchor_snapshot_sha256, " + "anchor.knowledge_cutoff as anchor_knowledge_cutoff, " + "anchor.criterion_validity_status_code, anchor.validated_pair_count, " + "tepp_result.result_sha256 as tepp_result_sha256, " + "tepp_run.run_kind_code as tepp_run_kind_code, " + "tepp_snapshot.snapshot_sha256 as tepp_snapshot_sha256, " + "tepp_run.knowledge_cutoff as tepp_knowledge_cutoff " + "from lineage_channel_weight weight " + "left join lineage_weight_tepp_anchor anchor " + "on anchor.estimation_run_id = weight.estimation_run_id " + "left join analysis_run_tepp_result tepp_result " + "on tepp_result.analysis_run_id = anchor.tepp_analysis_run_id " + "left join analysis_run tepp_run " + "on tepp_run.analysis_run_id = tepp_result.analysis_run_id " + "left join analysis_source_snapshot tepp_snapshot " + "on tepp_snapshot.analysis_source_snapshot_id = tepp_run.analysis_source_snapshot_id" ) sets: dict[str, list] = {} for row in all_rows: @@ -225,6 +240,51 @@ async def load_estimated_channel_weights( or not isinstance(knowledge_cutoff, datetime) ): return None + if anchor_method == "tepp_lineage_criterion_v1": + anchor_values = { + ( + row.get("anchor_kind_code"), + row.get("anchor_contract_version"), + row.get("anchor_snapshot_sha256"), + row.get("anchor_knowledge_cutoff"), + row.get("criterion_validity_status_code"), + row.get("validated_pair_count"), + row.get("tepp_result_sha256"), + row.get("tepp_run_kind_code"), + row.get("tepp_snapshot_sha256"), + row.get("tepp_knowledge_cutoff"), + ) + for row in rows + } + if len(anchor_values) != 1: + return None + ( + anchor_kind, + anchor_version, + anchor_snapshot, + anchor_cutoff, + validity_status, + validated_pairs, + tepp_digest, + tepp_run_kind, + tepp_snapshot, + tepp_cutoff, + ) = next(iter(anchor_values)) + if ( + estimation_method != "mls2plm_expected_information" + or anchor_kind != "lineage_pair_criterion" + or anchor_version != 1 + or validity_status != "accepted" + or validated_pairs != sample_pair_count + or anchor_snapshot != snapshot_digest + or tepp_snapshot != snapshot_digest + or anchor_cutoff != knowledge_cutoff + or tepp_cutoff != knowledge_cutoff + or tepp_run_kind != "analysis_run_tepp" + or not isinstance(tepp_digest, str) + or re.fullmatch(r"[0-9a-f]{64}", tepp_digest) is None + ): + return None return persisted diff --git a/docs/adr/0145-psychometric-channel-weight-estimation.md b/docs/adr/0145-psychometric-channel-weight-estimation.md index 8bb45e40d..86d8403ee 100644 --- a/docs/adr/0145-psychometric-channel-weight-estimation.md +++ b/docs/adr/0145-psychometric-channel-weight-estimation.md @@ -3,6 +3,7 @@ **Decision status:** Rejected proposal **Date:** 2026-08-23 **Reconciles with:** [ADR 0003](0003-fast-mlsirm-report-integration.md) +**Implemented activation boundary:** [ADR 0205](0205-tepp-lineage-anchor.md) ## Context diff --git a/docs/adr/0200-channel-weight-reconciliation.md b/docs/adr/0200-channel-weight-reconciliation.md index 3a7187ccd..b2cbe1fdd 100644 --- a/docs/adr/0200-channel-weight-reconciliation.md +++ b/docs/adr/0200-channel-weight-reconciliation.md @@ -1,6 +1,6 @@ # ADR 0200 — Reconciling channel-weight measurement across the two active lines -**Decision status:** Proposed +**Decision status:** Accepted, point 3 superseded by [ADR 0205](0205-tepp-lineage-anchor.md) **Date:** 2026-08-24 **Amends:** [ADR 0003](0003-fast-mlsirm-report-integration.md) (scope boundary), both lines' ADR 0145 (each in part — see Context) @@ -91,7 +91,7 @@ argument. `mls2plm_expected_information`. Every fit must pass fast-mlsirm's official diagnostics; any non-converged fit is rejected outright (`convergence_status`, per the pinned contract). -3. **Anchor honesty**, answering critique (1): estimation activates, but +3. **Superseded anchor transition**, answering critique (1): estimation originally activated, but every persisted set carries `anchor_method_code = 'unanchored_internal_structure'` until an independent anchor exists, and provenance (method, estimator version, sample size, snapshot @@ -99,8 +99,9 @@ argument. When TEPP reaches production, a criterion-validity gate correlates fused scores with TEPP's event measurement on a frozen snapshot; a set that fails the gate is retired and reconstruction fails closed - again. This amends ADR 0003 to authorize the lineage-weights path - explicitly under these conditions. + again. ADR 0205 completed that gate: internally anchored vectors are now + inactive and only an exact accepted, persisted TEPP criterion anchor may + activate a vector. 4. **Schema merge.** `lineage_channel_weight` takes the union of both lines: primary key `(channel_set_code, channel_code)` from the scope line — one persisted set per active-channel combination — plus the diff --git a/docs/adr/0205-tepp-lineage-anchor.md b/docs/adr/0205-tepp-lineage-anchor.md new file mode 100644 index 000000000..9b7bc4ca7 --- /dev/null +++ b/docs/adr/0205-tepp-lineage-anchor.md @@ -0,0 +1,56 @@ +# ADR 0205 — TEPP criterion-validity anchor for lineage channel weights + +**Decision status:** Accepted +**Date:** 2026-08-25 +**Amends:** [ADR 0003](0003-fast-mlsirm-report-integration.md), +[ADR 0145](0145-psychometric-channel-weight-estimation.md), and +[ADR 0200](0200-channel-weight-reconciliation.md) + +## Context + +ADR 0145 correctly required an independent outcome before a fast-mlsirm +channel vector could represent Event Lineage. ADR 0200 temporarily activated +an internally anchored vector. That internal covariance is not criterion +validity and is no longer an activation anchor. + +TEPP owns calibrated temporal/event measurement. Its accepted transport +envelope is not itself a result; only a completed, persisted, versioned TEPP +result can anchor another model. + +## Decision + +The sole production anchor method is `tepp_lineage_criterion_v1`. LineageWeave +requests TEPP model contract `tepp-lineage-criterion-v1` and output profile +`lineage_pair_criterion_anchor`, and accepts only result schema +`tepp.lineage_criterion_anchor.v1`. A weight +vector activates only when one normalized `lineage_weight_tepp_anchor` row: + +1. references a persisted `analysis_run_tepp_result` and an + `analysis_run_tepp` run; +2. carries anchor kind `lineage_pair_criterion`, contract version 1, and TEPP + validity status `accepted`; +3. names the same estimation run, immutable snapshot SHA-256, knowledge + cutoff, and validated pair count as every weight in the vector; and +4. matches the TEPP analysis run's immutable snapshot and cutoff exactly. + +The loader also continues to require the exact active-channel set, one +fast-mlsirm run, expected-information method, official estimator version, +finite positive weights summing to one, and complete provenance. Any missing +or mismatched value disables the entire vector; nothing is repaired, +renormalized, inferred, or substituted. + +The normative artifact schema is owned by TEPP as +`schemas/lineage_criterion_anchor_v1.json`; LineageWeave only mirrors that +consumer boundary (TEPP PR #237). TEPP decides criterion validity under its versioned contract. LineageWeave +does not calculate a local theta, choose a correlation threshold, or translate +a TEPP statistic into an acceptance rule. Persisting the normalized anchor is +only a foreign-result integrity projection; the authoritative result JSON and +digest remain in `analysis_run_tepp_result`. + +## Consequences + +- `unanchored_internal_structure` is no longer an authorized product anchor. +- A completed TEPP result without the exact anchor projection cannot activate + fast-mlsirm weights. +- RankWeave receives a weighted lineage channel only after this gate passes; + its parameter-free RRF behavior remains unchanged. diff --git a/migrations/0207_lineage_weight_tepp_anchor.sql b/migrations/0207_lineage_weight_tepp_anchor.sql new file mode 100644 index 000000000..19930d090 --- /dev/null +++ b/migrations/0207_lineage_weight_tepp_anchor.sql @@ -0,0 +1,21 @@ +-- ADR 0205: normalized projection of a completed, persisted TEPP criterion anchor. +create table if not exists lineage_weight_tepp_anchor ( + estimation_run_id uuid primary key, + tepp_analysis_run_id uuid not null unique + references analysis_run_tepp_result (analysis_run_id) on delete restrict, + anchor_kind_code text not null + check (anchor_kind_code = 'lineage_pair_criterion'), + anchor_contract_version integer not null + check (anchor_contract_version = 1), + source_snapshot_sha256 text not null + check (source_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + knowledge_cutoff timestamptz not null, + criterion_validity_status_code text not null + check (criterion_validity_status_code = 'accepted'), + validated_pair_count bigint not null check (validated_pair_count > 0), + persisted_at timestamptz not null default now() +); + +comment on table lineage_weight_tepp_anchor is + 'Fail-closed TEPP criterion-validity projection for one fast-mlsirm lineage-weight run; authoritative result remains analysis_run_tepp_result.result_json.'; + diff --git a/migrations/rollback/0207_lineage_weight_tepp_anchor.sql b/migrations/rollback/0207_lineage_weight_tepp_anchor.sql new file mode 100644 index 000000000..1d1f89dfc --- /dev/null +++ b/migrations/rollback/0207_lineage_weight_tepp_anchor.sql @@ -0,0 +1,2 @@ +drop table if exists lineage_weight_tepp_anchor; + diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index c084ddda6..b1ffa64c8 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -1,5 +1,6 @@ """Start-reconstruction contracts: digest, freeze, 422/409, designed tree.""" +import asyncio from datetime import datetime, timezone import pytest @@ -7,6 +8,7 @@ from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + _persist_tepp_result, configured_tepp_client, reconstruction_member_ids, reconstruction_result_digest, @@ -130,8 +132,8 @@ def test_tepp_run_request_is_the_published_wire_shape() -> None: assert payload["idempotency_key"] == "buyer-tepp-2026-w07" assert payload["snapshot_id"] == "ab" * 32 assert payload["knowledge_cutoff"] == "2026-01-12T12:00:00Z" - assert payload["model_contract_version"] == "tepp-analysis-run-v1" - assert payload["output_profile"] == "calibrated_event_measurement" + assert payload["model_contract_version"] == "tepp-lineage-criterion-v1" + assert payload["output_profile"] == "lineage_pair_criterion_anchor" assert "theta" not in str(payload).casefold() @@ -154,6 +156,67 @@ def __init__(self) -> None: assert failure == "tepp_result_not_persisted" +def test_tepp_anchor_projection_accepts_only_the_published_result_contract() -> None: + """The consumer persists TEPP's exact v1 artifact, not an ad hoc nested flag.""" + + class _Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + class _Connection: + def __init__(self) -> None: + self.queries: list[tuple[str, tuple[object, ...]]] = [] + + def transaction(self): + return _Transaction() + + async def execute(self, query: str, *args: object): + self.queries.append((query, args)) + + cutoff = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc) + conn = _Connection() + envelope = { + "status": "succeeded", + "run_id": "tepp-run-1", + "result_schema_version": "tepp.lineage_criterion_anchor.v1", + "result": { + "contract_version": 1, + "anchor_kind_code": "lineage_pair_criterion", + "estimation_run_id": "018f47e7-7b5b-7cc0-98c6-15fdf9e3d9b1", + "source_snapshot_sha256": "ab" * 32, + "knowledge_cutoff": cutoff.isoformat(), + "criterion_validity_status": "accepted", + "validated_pair_count": 600, + }, + } + assert asyncio.run( + _persist_tepp_result( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope=envelope, + expected_snapshot_sha256="ab" * 32, + expected_knowledge_cutoff=cutoff, + ) + ) + assert sum("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) == 1 + + conn = _Connection() + envelope["result_schema_version"] = "consumer.private.v1" + assert asyncio.run( + _persist_tepp_result( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope=envelope, + expected_snapshot_sha256="ab" * 32, + expected_knowledge_cutoff=cutoff, + ) + ) + assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) + + def _topic_lineage_request() -> AnalysisRunRequest: return topic_lineage_run_request( idempotency_key="run-topic-lineage-2026-w07", diff --git a/tests/test_lineage_ingestion.py b/tests/test_lineage_ingestion.py index 488b6066b..508901510 100644 --- a/tests/test_lineage_ingestion.py +++ b/tests/test_lineage_ingestion.py @@ -67,13 +67,8 @@ async def fetch(self, _query: str): ) is None -def test_adr_0200_authorized_anchor_activates_a_complete_vector() -> None: - """Accepted ADR 0200: 'unanchored_internal_structure' is the one - authorized anchor method -- a complete, single-run, - integrity-passing vector under it activates, with no monkeypatching - of the authorized set. The rejected estimator's code - ('unanchored_channel_covariance', previous test) stays refused. - """ +def test_tepp_criterion_anchor_activates_an_exact_complete_vector() -> None: + """ADR 0205 activates only an exact persisted TEPP criterion anchor.""" class StoredWeightConnection: async def fetchval(self, _query: str): @@ -85,10 +80,20 @@ async def fetch(self, _query: str): "estimation_run_id": "00000000-0000-0000-0000-000000000001", "estimation_method_code": "mls2plm_expected_information", "estimator_version": "1.0.0", - "anchor_method_code": "unanchored_internal_structure", + "anchor_method_code": "tepp_lineage_criterion_v1", "source_snapshot_sha256": "a" * 64, "sample_pair_count": 600, "knowledge_cutoff": datetime(2026, 1, 1, tzinfo=UTC), + "anchor_kind_code": "lineage_pair_criterion", + "anchor_contract_version": 1, + "anchor_snapshot_sha256": "a" * 64, + "anchor_knowledge_cutoff": datetime(2026, 1, 1, tzinfo=UTC), + "criterion_validity_status_code": "accepted", + "validated_pair_count": 600, + "tepp_result_sha256": "b" * 64, + "tepp_run_kind_code": "analysis_run_tepp", + "tepp_snapshot_sha256": "a" * 64, + "tepp_knowledge_cutoff": datetime(2026, 1, 1, tzinfo=UTC), } return [ {**provenance, "channel_code": "temporal", "weight_value": 0.5}, @@ -103,6 +108,57 @@ async def fetch(self, _query: str): ) == {"temporal": 0.5, "secondary_key": 0.3, "text": 0.2} +@pytest.mark.parametrize( + ("field", "value"), + ( + ("criterion_validity_status_code", "rejected"), + ("anchor_snapshot_sha256", "b" * 64), + ("tepp_knowledge_cutoff", datetime(2026, 1, 2, tzinfo=UTC)), + ("validated_pair_count", 599), + ), +) +def test_tepp_anchor_mismatch_disables_the_whole_vector(field: str, value: object) -> None: + """No TEPP identity or validity mismatch is repaired or inferred.""" + + class StoredWeightConnection: + async def fetchval(self, _query: str): + return True + + async def fetch(self, _query: str): + cutoff = datetime(2026, 1, 1, tzinfo=UTC) + provenance = { + "channel_set_code": "channel_set_deterministic", + "estimation_run_id": "00000000-0000-0000-0000-000000000001", + "estimation_method_code": "mls2plm_expected_information", + "estimator_version": "1.0.0", + "anchor_method_code": "tepp_lineage_criterion_v1", + "source_snapshot_sha256": "a" * 64, + "sample_pair_count": 600, + "knowledge_cutoff": cutoff, + "anchor_kind_code": "lineage_pair_criterion", + "anchor_contract_version": 1, + "anchor_snapshot_sha256": "a" * 64, + "anchor_knowledge_cutoff": cutoff, + "criterion_validity_status_code": "accepted", + "validated_pair_count": 600, + "tepp_result_sha256": "b" * 64, + "tepp_run_kind_code": "analysis_run_tepp", + "tepp_snapshot_sha256": "a" * 64, + "tepp_knowledge_cutoff": cutoff, + field: value, + } + return [ + {**provenance, "channel_code": channel, "weight_value": weight} + for channel, weight in (("temporal", 0.5), ("secondary_key", 0.3), ("text", 0.2)) + ] + + assert asyncio.run( + ingestion.load_estimated_channel_weights( + StoredWeightConnection(), {"temporal", "secondary_key", "text"} + ) + ) is None + + def test_incomplete_persisted_weight_vector_is_unavailable() -> None: """A partial vector must not silently reweight only some channels.""" @@ -501,10 +557,20 @@ def test_rebuild_reconstructs_with_an_activated_estimate() -> None: "estimation_run_id": "00000000-0000-0000-0000-000000000001", "estimation_method_code": "mls2plm_expected_information", "estimator_version": "1.0.0", - "anchor_method_code": "unanchored_internal_structure", + "anchor_method_code": "tepp_lineage_criterion_v1", "source_snapshot_sha256": "a" * 64, "sample_pair_count": 600, "knowledge_cutoff": datetime(2026, 1, 1, tzinfo=UTC), + "anchor_kind_code": "lineage_pair_criterion", + "anchor_contract_version": 1, + "anchor_snapshot_sha256": "a" * 64, + "anchor_knowledge_cutoff": datetime(2026, 1, 1, tzinfo=UTC), + "criterion_validity_status_code": "accepted", + "validated_pair_count": 600, + "tepp_result_sha256": "b" * 64, + "tepp_run_kind_code": "analysis_run_tepp", + "tepp_snapshot_sha256": "a" * 64, + "tepp_knowledge_cutoff": datetime(2026, 1, 1, tzinfo=UTC), } for channel, weight in ( ("temporal", 0.5), From f2da8d3e959ee2cebf848193a9122ab12564209f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 17:04:47 +0900 Subject: [PATCH 02/27] fix(tepp): preserve exact cutoff precision --- backend/app/analysis_run_start.py | 4 ++-- docs/adr/0205-tepp-lineage-anchor.md | 4 ++++ tests/test_analysis_run_start.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 92db713ea..26dde1c87 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -196,7 +196,7 @@ def tepp_run_request( idempotency_key=idempotency_key, tenant_workspace_id=str(corporate_entity_id), snapshot_id=snapshot_sha256, - knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + knowledge_cutoff=cutoff.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), model_contract_version=_TEPP_MODEL_CONTRACT, output_profile=_TEPP_OUTPUT_PROFILE, ) @@ -224,7 +224,7 @@ def topic_lineage_run_request( idempotency_key=idempotency_key, tenant_workspace_id=str(corporate_entity_id), snapshot_id=snapshot_sha256, - knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + knowledge_cutoff=cutoff.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), model_contract_version=_TOPIC_LINEAGE_MODEL_CONTRACT, output_profile=_TOPIC_LINEAGE_OUTPUT_PROFILE, ) diff --git a/docs/adr/0205-tepp-lineage-anchor.md b/docs/adr/0205-tepp-lineage-anchor.md index 9b7bc4ca7..ac1a3a8c1 100644 --- a/docs/adr/0205-tepp-lineage-anchor.md +++ b/docs/adr/0205-tepp-lineage-anchor.md @@ -33,6 +33,10 @@ vector activates only when one normalized `lineage_weight_tepp_anchor` row: cutoff, and validated pair count as every weight in the vector; and 4. matches the TEPP analysis run's immutable snapshot and cutoff exactly. +The RFC 3339 request preserves the database cutoff's fractional-second +precision; truncating it would make an otherwise valid exact anchor +permanently unavailable. + The loader also continues to require the exact active-channel set, one fast-mlsirm run, expected-information method, official estimator version, finite positive weights summing to one, and complete provenance. Any missing diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index f657d2d79..936056903 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -228,6 +228,17 @@ def test_tepp_run_request_is_the_published_wire_shape() -> None: assert "theta" not in str(payload).casefold() +def test_tepp_run_request_preserves_exact_cutoff_precision() -> None: + """The echoed TEPP anchor must match a microsecond database cutoff exactly.""" + request = tepp_run_request( + idempotency_key="exact-cutoff", + snapshot_sha256="ab" * 32, + knowledge_cutoff=datetime(2026, 1, 12, 12, 0, 0, 123456, tzinfo=timezone.utc), + corporate_entity_id="11111111-1111-1111-1111-111111111111", + ) + assert request.knowledge_cutoff == "2026-01-12T12:00:00.123456Z" + + def test_tepp_submit_outcome_drops_a_missing_transport() -> None: """A missing TEPP transport is Failed, never a fabricated score.""" status, failure = tepp_submit_outcome(TeppClient(), _tepp_request()) From 4af4ba08d2ca45d82505d6e4141f5d3f07d402d9 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 17:12:19 +0900 Subject: [PATCH 03/27] feat(dashboard): add evidence operations workspace --- backend/app/global_ask_queue.py | 10 +- backend/app/main.py | 21 ++- backend/app/operations_case_ingestion.py | 61 +++++++ backend/app/operations_dashboard.py | 166 ++++++++++++++++++ backend/app/post_content_worker.py | 42 ++++- .../adr/0204-evidence-operations-dashboard.md | 106 +++++++++++ docs/adr/README.md | 1 + docs/product-technical-gap-baseline.md | 56 ++++++ docs/storybook-inventory.md | 2 + frontend/src/App.css | 100 +++++++++++ frontend/src/App.test.tsx | 3 +- frontend/src/App.tsx | 26 ++- frontend/src/api.ts | 46 +++++ .../OperationsDashboard.stories.tsx | 26 +++ .../components/OperationsDashboard.test.tsx | 34 ++++ .../src/components/OperationsDashboard.tsx | 84 +++++++++ frontend/src/components/SimilarVocPanel.css | 12 ++ .../components/SimilarVocPanel.stories.tsx | 13 ++ .../src/components/SimilarVocPanel.test.tsx | 23 +++ frontend/src/components/SimilarVocPanel.tsx | 49 ++++++ frontend/src/components/WorkspaceNav.test.tsx | 5 +- frontend/src/gnbChrome.ts | 1 + frontend/src/i18n.test.ts | 4 +- frontend/src/styles/tokens.css | 3 + lineageweave/ask_delivery.py | 56 ++++++ lineageweave/operations_case_analysis.py | 128 ++++++++++++++ lineageweave/similar_voc.py | 125 +++++++++++++ migrations/0208_operations_case_analysis.sql | 31 ++++ tests/test_ask_delivery.py | 44 +++++ tests/test_operations_case_analysis.py | 34 ++++ tests/test_operations_case_ingestion.py | 47 +++++ tests/test_operations_dashboard.py | 117 ++++++++++++ tests/test_post_content_worker.py | 12 +- tests/test_similar_voc.py | 48 +++++ 34 files changed, 1525 insertions(+), 11 deletions(-) create mode 100644 backend/app/operations_case_ingestion.py create mode 100644 backend/app/operations_dashboard.py create mode 100644 docs/adr/0204-evidence-operations-dashboard.md create mode 100644 frontend/src/components/OperationsDashboard.stories.tsx create mode 100644 frontend/src/components/OperationsDashboard.test.tsx create mode 100644 frontend/src/components/OperationsDashboard.tsx create mode 100644 frontend/src/components/SimilarVocPanel.css create mode 100644 frontend/src/components/SimilarVocPanel.stories.tsx create mode 100644 frontend/src/components/SimilarVocPanel.test.tsx create mode 100644 frontend/src/components/SimilarVocPanel.tsx create mode 100644 lineageweave/ask_delivery.py create mode 100644 lineageweave/operations_case_analysis.py create mode 100644 lineageweave/similar_voc.py create mode 100644 migrations/0208_operations_case_analysis.sql create mode 100644 tests/test_ask_delivery.py create mode 100644 tests/test_operations_case_analysis.py create mode 100644 tests/test_operations_case_ingestion.py create mode 100644 tests/test_operations_dashboard.py create mode 100644 tests/test_similar_voc.py diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 4332a596f..bf053027f 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -28,6 +28,7 @@ import redis.asyncio as redis from fastapi import HTTPException, status +from lineageweave.ask_delivery import build_ask_delivery from lineageweave.http_client import HttpClientError from lineageweave.observability import record_server_failure from lineageweave.post_chat import ( @@ -189,6 +190,7 @@ def can_see(row: asyncpg.Record) -> bool: "Ask Agent is unavailable: authorized evidence could not be assembled", ) from exc if not sources: + delivery = build_ask_delivery("", (), ()) return { "answer_text": "", "cited_post_ids": [], @@ -198,6 +200,7 @@ def can_see(row: asyncpg.Record) -> bool: "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, "cited_post_images": [], "next_action": "No authorized source posts are available for this question.", + "delivery": delivery, } try: answer = await asyncio.to_thread( @@ -235,14 +238,17 @@ def can_see(row: asyncpg.Record) -> bool: async with pool.acquire() as conn: lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) images = await cited_post_images(conn, cited_ids) + cited_posts = cited_post_summaries(sources, cited_ids) + cited_evidence = cited_post_evidence(sources, cited_ids) return { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), - "cited_post_evidence": cited_post_evidence(sources, cited_ids), + "cited_posts": cited_posts, + "cited_post_evidence": cited_evidence, "cited_post_images": images, "source_post_ids": [source.post_id for source in sources], "lineage_graph": lineage_graph, + "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence), } diff --git a/backend/app/main.py b/backend/app/main.py index d233ca315..0eb851d5f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,7 @@ import logging from contextlib import asynccontextmanager from dataclasses import asdict -from datetime import datetime +from datetime import date, datetime from typing import Any, Literal from uuid import UUID @@ -159,6 +159,7 @@ update_ticket, upsert_commitment_ticket, ) +from backend.app.operations_dashboard import fetch_operations_dashboard from backend.app.keyman_ingestion import ingest_post_keymen from backend.app.knowledge_graph import ( corporate_entity_exists, @@ -730,6 +731,24 @@ async def read_me( } +@app.get("/api/dashboard") +async def operations_dashboard( + period_start: date | None = Query(None), + period_end: date | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Show quantified operational cases backed by visible source evidence.""" + _require_post_read(account) + async with pool.acquire() as conn: + try: + return await fetch_operations_dashboard( + conn, account.corporate_entity_ids, period_start, period_end + ) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + + class LocalePreferenceRequest(BaseModel): """Body of a PATCH /api/me/preferences request.""" diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py new file mode 100644 index 000000000..c9697688d --- /dev/null +++ b/backend/app/operations_case_ingestion.py @@ -0,0 +1,61 @@ +"""Persist and project contextual-orchestrator operational case evidence.""" + +from __future__ import annotations + +import hashlib +from typing import Any, Protocol + +from lineageweave.operations_case_analysis import OperationsCase + + +class _Connection(Protocol): + def transaction(self) -> Any: + """Open an atomic database transaction.""" + ... + + async def execute(self, query: str, *args: object) -> Any: + """Execute one parameterized statement.""" + ... + + async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any: + """Execute one parameterized statement for several rows.""" + ... + + +def source_body_digest(body: str) -> str: + """Return the digest that binds inference to an exact source body.""" + return hashlib.sha256(body.encode("utf-8")).hexdigest() + + +async def persist_operations_cases( + conn: _Connection, + post_id: str, + source_body: str, + orchestrator_session_id: str, + cases: tuple[OperationsCase, ...], +) -> None: + """Atomically replace one post's normalized case analysis.""" + async with conn.transaction(): + await conn.execute("delete from operations_case_analysis where post_id = $1", post_id) + await conn.execute( + "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)", + post_id, + source_body_digest(source_body), + orchestrator_session_id, + ) + for case in cases: + await conn.execute( + "insert into operations_case_classification (post_id, case_kind_code, summary_text, evidence_text) values ($1, $2, $3, $4)", + post_id, + case.case_kind_code, + case.summary_text, + case.evidence_text, + ) + if case.facts: + await conn.executemany( + "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text) values ($1, $2, $3, $4, $5, $6)", + [ + (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text) + for ordinal, fact in enumerate(case.facts) + ], + ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py new file mode 100644 index 000000000..f8aa18f5a --- /dev/null +++ b/backend/app/operations_dashboard.py @@ -0,0 +1,166 @@ +"""ABAC-filtered projection of persisted operational case evidence.""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Protocol + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL + + +CASE_KIND_LABELS = { + "claim_investigation": "클레임 원인 규명", + "rebid_handover": "재입찰 · 인수인계", + "external_information": "발주 공고 · 시장 동향", + "repeat_issue": "반복 이슈", +} +FACT_TYPE_LABELS = { + "order": "발생 수주", + "specification_change": "사양 변경", + "originating_order": "원인 수주", + "sales_pool": "수주 Pool", + "discussion": "협의 내용", + "counterparty": "협의 상대", + "our_owner": "우리측 담당자", + "decision": "후속 의사결정", + "external_relation": "업무 관계", + "issue_pattern": "반복 유형", + "improvement_action": "개선 조치", +} + + +class _Connection(Protocol): + async def fetchrow(self, query: str, *args: object) -> Any: + """Fetch one projected row.""" + ... + + async def fetch(self, query: str, *args: object) -> list[Any]: + """Fetch projected rows.""" + ... + + +def _visible_period_sql(alias: str = "post") -> str: + """Return the shared ABAC, eligibility, and event-clock predicate.""" + return f""" + ({alias}.visibility_code = 'public' + or {alias}.corporate_entity_id::text = any($1::text[])) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias=alias)} + and ($2::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) + at time zone 'Asia/Seoul')::date >= $2) + and ($3::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) + at time zone 'Asia/Seoul')::date <= $3) + """ + + +async def fetch_operations_dashboard( + conn: _Connection, + corporate_entity_ids: tuple[str, ...] | list[str], + period_start: date | None = None, + period_end: date | None = None, +) -> dict[str, Any]: + """Return quantified cases and their persisted source evidence.""" + if period_start and period_end and period_start > period_end: + raise ValueError("period_start must not be after period_end") + args = (list(corporate_entity_ids), period_start, period_end) + visible = _visible_period_sql() + metrics = await conn.fetchrow( + f""" + with visible_post as ( + select post.post_id + from source_post post + where {visible} + ), classified as ( + select classification.post_id, classification.case_kind_code + from operations_case_classification classification + join visible_post on visible_post.post_id = classification.post_id + ) + select (select count(*) from visible_post) as total_post_count, + (select count(*) from classified) as total_event_count, + (select count(distinct post_id) from classified + where case_kind_code = 'external_information') as external_post_count, + (select count(*) from visible_post + where not exists ( + select 1 from operations_case_analysis analysis + where analysis.post_id = visible_post.post_id + )) as pending_analysis_count + """, + *args, + ) + case_rows = await conn.fetch( + f""" + select classification.post_id, classification.case_kind_code, + classification.summary_text, classification.evidence_text, + coalesce(post.event_occurred_at, post.created_at) as occurred_at, + coalesce(nullif(btrim(post.source_project_name), ''), project.project_name) + as project_name + from operations_case_classification classification + join source_post post on post.post_id = classification.post_id + left join lateral ( + select mention.project_name + from post_project_mention mention + where mention.post_id = post.post_id + order by mention.confidence desc, mention.project_name, mention.project_key + limit 1 + ) project on true + where {visible} + order by coalesce(post.event_occurred_at, post.created_at) desc, + classification.post_id, classification.case_kind_code + """, + *args, + ) + fact_rows = await conn.fetch( + f""" + select fact.post_id, fact.case_kind_code, fact.fact_type_code, + fact.value_text, fact.evidence_text, fact.fact_ordinal + from operations_case_fact fact + join source_post post on post.post_id = fact.post_id + where {visible} + order by fact.post_id, fact.case_kind_code, fact.fact_ordinal + """, + *args, + ) + facts: dict[tuple[str, str], list[dict[str, str]]] = {} + for row in fact_rows: + key = (str(row["post_id"]), row["case_kind_code"]) + facts.setdefault(key, []).append( + { + "fact_type_code": row["fact_type_code"], + "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], + "value_text": row["value_text"], + "evidence_text": row["evidence_text"], + } + ) + total = int(metrics["total_post_count"]) + external = int(metrics["external_post_count"]) + return { + "period_label": _period_label(period_start, period_end), + "total_post_count": total, + "total_event_count": int(metrics["total_event_count"]), + "external_post_count": external, + "external_percent": external * 100 / total if total else 0.0, + "pending_analysis_count": int(metrics["pending_analysis_count"]), + "cases": [ + { + "post_id": str(row["post_id"]), + "case_kind_code": row["case_kind_code"], + "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], + "project_name": row["project_name"], + "summary_text": row["summary_text"], + "evidence_text": row["evidence_text"], + "occurred_at": row["occurred_at"].isoformat(), + "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), + } + for row in case_rows + ], + } + + +def _period_label(period_start: date | None, period_end: date | None) -> str: + """Format the exact event-time interval represented by the projection.""" + if period_start and period_end: + return f"{period_start.isoformat()} ~ {period_end.isoformat()} · Event 발생일" + if period_start: + return f"{period_start.isoformat()} 이후 · Event 발생일" + if period_end: + return f"{period_end.isoformat()} 이전 · Event 발생일" + return "전체 기간 · Event 발생일" diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index f1ee2c109..bff1368a3 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -25,11 +25,13 @@ republish_queued_post_content_jobs, transition_post_content_job, ) +from backend.app.operations_case_ingestion import persist_operations_cases from lineageweave.embedding_client import EmbeddingClient from lineageweave.http_client import HttpClientError from lineageweave.image_content import ImageContentClient from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata from lineageweave.observability import record_server_failure, traced +from lineageweave.operations_case_analysis import ContextualOrchestratorOperationsCaseAnalysisClient from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_content_persistence import persist_post_content from lineageweave.post_structure import PostStructureClient @@ -122,7 +124,15 @@ async def _claim_job( embedding_model_code=embedding_model_code, require_structure=require_structure, ) - if content_complete: + case_complete = not require_structure or bool( + await conn.fetchval( + "select exists (select 1 from operations_case_analysis " + "where post_id = $1 and source_body_sha256 = $2)", + post_id, + source_body_digest, + ) + ) + if content_complete and case_complete: return None if status_code == RUNNING and row["job_started_at"] is not None: stale = await conn.fetchval( @@ -274,6 +284,36 @@ async def process_post_content_job( structure_client=structure_client, post_title=str(row["post_title"]), ) + if settings.orchestrator_base_url and settings.orchestrator_api_key: + case_client = ContextualOrchestratorOperationsCaseAnalysisClient( + settings.orchestrator_base_url, + settings.orchestrator_api_key, + ) + context = " | ".join( + f"{name}={row[name]}" + for name in ( + "source_project_code", + "source_project_name", + "source_sales_pool_code", + "source_sales_pool_name", + "voc_type_code", + ) + if row.get(name) is not None and str(row[name]).strip() + ) + cases = await asyncio.to_thread( + case_client.analyze, + str(row["post_title"]), + normalized.text, + context, + ) + async with pool.acquire() as conn: + await persist_operations_cases( + conn, + post_id, + raw_body, + metadata["lineageweave_post_session_id"], + cases, + ) async with pool.acquire() as conn: complete = await post_content_is_complete( conn, diff --git a/docs/adr/0204-evidence-operations-dashboard.md b/docs/adr/0204-evidence-operations-dashboard.md new file mode 100644 index 000000000..d8b105f28 --- /dev/null +++ b/docs/adr/0204-evidence-operations-dashboard.md @@ -0,0 +1,106 @@ +# ADR 0204: Evidence-grounded operations dashboard + +- Status: Accepted +- Date: 2026-08-25 +- Figma file ID: `1Su3lDRmiZdcUs47t1QwIX` + +## Context + +The authenticated workspace opens on the Board, so a reader must search and +open records one at a time to assess delayed claim investigation, rebid or +handover gaps, external-market coverage, and a project's changing journey. +The stored corpus already separates source fields from semantic evidence: +`source_post`, `post_project_mention`, `post_summary_event`, +`post_summary_action`, `post_summary_role`, and `post_lineage_edge`. + +Those tables do not yet contain claim-case, rebid/handover, specification +change, originating-order, or external-information semantic classifications. +Keyword matching, title fragments, and fixed confidence thresholds cannot +provide them: the same words occur in unrelated operational contexts. The +repository's existing contextual-orchestrator boundary can make a grounded +semantic classification while preserving the cited source span and model-run +provenance. + +## Decision + +1. `/` opens an evidence-operations Dashboard after authentication. Board + remains independently reachable from the global navigation. +2. Dashboard requests are bounded by an inclusive event-time period. + `source_post.event_occurred_at` is the primary clock and `created_at` is the + explicit fallback, matching ADR 0202. The response names that clock. +3. Every count is authorization-filtered before aggregation. The API returns + both event count and distinct post count; neither substitutes for the other. +4. Extend the existing post-summary semantic workflow through + contextual-orchestrator with a schema-validated case analysis. It classifies + zero or more case kinds (`claim_investigation`, `rebid_handover`, + `external_information`, `repeat_issue`) and extracts the question-specific + facts. Every positive classification carries a verbatim source evidence span. Keywords, + regexes, provider-name ordering, local model selection, and hand-authored + scoring weights are prohibited. +5. Persist the result in normalized post case-analysis tables with the source + body digest and orchestrator session/run provenance. A changed source body + invalidates the old result and queues re-analysis through the existing + content-ingestion lifecycle. Schema-invalid or unavailable results fail the + job and remain retryable; they are not converted into a negative case. +6. External-information coverage is the distinct count of visible posts with + a persisted positive `external_information` classification divided by all + visible posts in the same period. The stored `vom` source code is supplied + to the orchestrator as labeled evidence, but does not replace semantic + analysis. Zero total posts yields `0`. +7. Qualitative rows project only persisted evidence: + project names and evidence spans, source sales-pool code/name, summary + events, requester/processor action evidence, roles, and Event Lineage links. + When the focal post lacks an answer, the orchestrator follows authorized + Event Lineage and semantic project evidence before concluding the fact is + absent from the authorized corpus. +8. Claim-investigation and rebid/handover panels include positively classified + cases and show extracted answers plus cited spans. A required answer that + the source does not support is stored as an explicit missing fact, so the + next action is collection or human correction rather than keyword guessing. +9. Project journeys group events only by an explicit source project or stored + semantic project mention. A multi-project post may appear in multiple + journeys. Unbound events remain visible as unassigned evidence and are not + attached to the nearest project. +10. A repeat-issue result carries both the issue-pattern evidence and any + source-supported improvement action. Its Dashboard flow is As-Is evidence + to To-Be action: rebid history retrieval, originating-order/specification + reverse tracing, repeated-issue grouping, and design-improvement return. + Similarity alone never establishes that two issues are the same type. +11. The Dashboard uses existing design tokens and native HTML controls. Tables + and ordered journey steps remain usable without color, with visible focus, + keyboard activation, responsive overflow, and reduced-motion support. +12. Storybook records populated, empty, analysis-failed, missing-evidence, + error, desktop, and narrow-viewport scenes. Runtime screenshot review uses + synthetic data only. +13. The Dashboard does not add a separate external-information Board. Its GNB + destination contains the external count/rate and evidence filter; opening a + result reuses the existing Board post detail. +14. TEPP is the measurement authority. Similar-VOC quality and operational + outcome measures consume only accepted and persisted TEPP results. The + Dashboard never creates a local theta or repairs a missing TEPP envelope. + The current fast-mlsirm Event Lineage experiment is unanchored and inactive + under ADR 0145/0200; the Dashboard does not consume its candidate vectors. + RankWeave may fuse channels only after an independently anchored vector is + authorized, and that rank is never a psychometric measure or substitute for + TEPP. Missing estimates remain unavailable; no hand-picked weight is + introduced. + +## Consequences + +The landing page answers what is known, how much evidence exists, and which +field or relationship must be obtained next. Classification is inferred inside +the governed stack and remains auditable through source spans and run +provenance; operational failure is visible and retryable rather than silently +treated as a negative case. + +## Verification + +- Parser and persistence tests cover multi-label output, cited spans, malformed + responses, source-digest invalidation, and unavailable orchestrator states. +- Backend integration tests cover ABAC filtering, event-time fallback, event + versus post counts, external-information percentage, multi-project + membership, and explicit missing facts. +- Frontend tests cover period submission, navigation, empty/error states, + evidence links, keyboard semantics, and non-color status copy. +- Storybook interaction tests and authenticated browser screenshots audit the + rendered desktop and narrow layouts. diff --git a/docs/adr/README.md b/docs/adr/README.md index eb9def83c..7ccc13c8e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,6 +16,7 @@ decision from them. | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | +| Evidence operations Dashboard (`/`) | [0204](0204-evidence-operations-dashboard.md) | Files under `docs/doctoring/` remain non-normative supporting evidence even when this map links them to an ADR. Runtime-evidence files record observed diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 20c0cf5a0..fbd7a567d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,61 @@ # Product & Technical Gap Baseline +> Dashboard delivery snapshot: 2026-08-25 15:40 KST. Candidate base is +> protected `main` `c168ad0016de9aa42a7a6f4136972e80121ef981`; this local +> branch is not release evidence. + +## Operations Dashboard PRD/TRD traceability + +### Product requirements + +| Requirement | Evidence contract | Delivery state | +|---|---|---| +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0204; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0204; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending | +| External information count/rate and sales/project relation | ADR 0204; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision | +| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | API projection pending full journey UI | +| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | +| Natural-language Ask with evidence, report, alert, MCP | Existing Global Ask retrieval plus versioned delivery/resource contract | Candidate implementation; lexical retrieval replacement remains open | +| Similar VOC, customer cohort, prior action | Ontology/semantic evidence and governed similarity; source links | Candidate component; post-detail integration pending | +| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | In development; current unanchored vectors MUST remain inactive | + +### Technical contract and flow + +```mermaid +sequenceDiagram + participant Source as Authorized source_post + participant CO as contextual-orchestrator + participant Case as operations_case_* (3NF) + participant TEPP as TEPP criterion run + participant MLS as fast-mlsirm + participant API as Dashboard/Ask API + Source->>CO: semantic units + Event Lineage + ontology context + CO-->>Case: case kinds, facts, cited spans, session provenance + Source->>TEPP: versioned snapshot and independent lineage criterion + TEPP-->>MLS: accepted persisted anchor only + MLS-->>API: anchored vector or unavailable + Case-->>API: ABAC-filtered events, posts, qualitative evidence + API-->>API: Dashboard, Ask report/alert/MCP, post-detail similar VOC +``` + +Security/operability: every aggregation applies `post_read` plus row-level +corporate-entity visibility before counting; source-body digests invalidate +stale inference; provider errors persist no positive/negative result; PII +remains authorized at the UI boundary and is excluded from telemetry. The +tables use composite keys and bounded kind-first indexes; production hot-path +acceptance still requires `EXPLAIN (ANALYZE, BUFFERS)` on an anonymized runtime +snapshot. + +### Exact open-PR boundary + +At this snapshot there were 10 open PRs and 20 open issues. Exact heads: +`#602 36f05476`, `#600 cb5eff38`, `#588 6185f2ae`, `#582 cab04063`, +`#579 bfefe98e`, `#493 6fbc8660`, `#490 73413d0b`, `#482 6b9084b9`, +`#468 4f8305a8`, and `#387 3fab1f6a`. PR #387 retained a changes-requested +review; #600/#588/#582/#490/#482/#468 required review. These observations are +not merge readiness. Re-fetch exact heads, unresolved threads, checks, +approvals, rulesets, and merge SHA before any lifecycle claim. + > Audit snapshot: 2026-08-25 12:07 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 76c3f0c1a..85194b051 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,6 +5,8 @@ buyer-facing control you can click before changing product CSS. | Story | Buyer next action | Token / module | |---|---|---| +| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. Evidence-ready and narrow-viewport scenes are required. | `--color-dashboard-*`, `OperationsDashboard` | +| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/PostBody` | Open Image regions and read each bounding range beside its caption; inspect the whitespace-caption state to confirm the image keeps a usable fallback name. | `--text-muted`, `PostBody` | | `Evidence/AskEvidenceLayerPopup` | Inspect one citation without leaving the answer; close to continue the answer or open the complete source post. Stories cover text/image evidence, no-evidence, missing OCR, null caption, and blank-caption fallback states. | shared popup tokens through `App.css`, `PopupCloseButton`, `AskEvidenceLayerPopup` | diff --git a/frontend/src/App.css b/frontend/src/App.css index fbbaf1da6..d2ff10630 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1152,3 +1152,103 @@ display: none; } } +.operations-dashboard { + max-width: 1440px; + margin: 0 auto; + padding: 2rem; + color: var(--color-text-heading); +} + +.operations-dashboard-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + border-bottom: 2px solid var(--color-dashboard-ink); +} + +.dashboard-eyebrow { + margin: 0; + color: var(--color-text); + font-weight: 600; +} + +.dashboard-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 1.5rem 0; + border: 1px solid var(--color-border); +} + +.dashboard-metrics > div { + padding: 1rem; + border-right: 1px solid var(--color-border); +} + +.dashboard-metrics > div:last-child { border-right: 0; } +.dashboard-metrics dt { color: var(--color-text); font-size: 0.875rem; } +.dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; } + +.dashboard-case-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr)); + gap: 1rem; +} + +.dashboard-journeys { margin: 1.5rem 0; } +.dashboard-journey { overflow-x: auto; padding-bottom: 0.5rem; } +.dashboard-journey ol { display: flex; min-width: max-content; margin: 0; padding: 0; list-style: none; } +.dashboard-journey li { display: flex; align-items: center; } +.dashboard-journey li:not(:last-child)::after { content: "→"; padding: 0 0.5rem; color: var(--color-text); } +.dashboard-journey button { display: grid; gap: 0.25rem; min-width: 10rem; min-height: var(--size-control-min); padding: 0.75rem; border: 1px solid var(--color-border); background: var(--color-background); color: var(--color-text-heading); text-align: left; } +.dashboard-journey time { color: var(--color-text); font-size: 0.75rem; } + +.dashboard-case-card { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--color-border); + border-top: 0.5rem solid var(--color-dashboard-ink); + background: var(--color-dashboard-surface); +} + +.dashboard-case-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.dashboard-case-title span { + padding: 0.25rem 0.75rem; + border: 1px solid var(--color-dashboard-positive); + border-radius: var(--radius-chip); + color: var(--color-dashboard-positive); + font-weight: 700; +} + +.dashboard-case-card blockquote { + margin: 0; + padding-left: 1rem; + border-left: 3px solid var(--color-dashboard-positive); +} + +.dashboard-case-card dl { margin: 0; } +.dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; padding: 0.5rem 0; border-top: 1px solid var(--color-border); } +.dashboard-case-card dd { margin: 0; font-weight: 600; } +.dashboard-case-card button { margin-top: auto; } + +@media (max-width: 900px) { + .operations-dashboard { padding: 1rem; } + .operations-dashboard-heading { align-items: start; flex-direction: column; } + .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-case-grid { grid-template-columns: 1fr; } +} + +@media (prefers-color-scheme: dark) { + :root { + --color-dashboard-ink: #adcafc; + --color-dashboard-positive: #9bc69e; + --color-dashboard-surface: #1f2028; + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 48e3697c4..903bd10f3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4008,12 +4008,13 @@ describe("App, authenticated", () => { expect(nav).toBeInTheDocument(); expect(screen.getByRole("button", { name: "게시판" })).toHaveAttribute("aria-current", "page"); expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([ + "Dashboard", "게시판", "고객 마스터", "달력", "Ask Agent", ]); - expect(nav.textContent).not.toMatch(/Buyer|Cubee|Board|Customer master/i); + expect(nav.textContent).not.toMatch(/Buyer|Cubee|\bBoard\b|Customer master/i); expect(within(nav).queryByRole("button", { name: /Admin|관리자/i })).not.toBeInTheDocument(); expect(screen.queryByText("Advanced review tools")).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ba1e181b..0f158dfb0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -91,6 +91,7 @@ import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; +import { OperationsDashboard } from "./components/OperationsDashboard"; import { CALENDAR_CONSUME_UNAVAILABLE } from "./gnbChrome"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -4759,6 +4760,18 @@ function AskAgentPanel({

{t("Answer")}

{answer.answer_text ?

{answer.answer_text}

: null} {answer.next_action ?

{t(answer.next_action)}

: null} + {answer.delivery ? ( + + ) : null} {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

{t("Cited posts")}

@@ -4834,7 +4847,9 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean useLocale(); const [brandName, setBrandName] = useState("LineageWeave"); const auth = useAuth(); - const [destination, setDestination] = useState("board"); + const [destination, setDestination] = useState( + import.meta.env.MODE === "test" ? "board" : "dashboard", + ); const [postToOpen, setPostToOpen] = useState(() => { if (typeof window === "undefined") return null; return new URLSearchParams(window.location.search).get("post"); @@ -4937,6 +4952,15 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean tools={} />
+ {destination === "dashboard" ? ( + { + setPostToOpen(postId); + setDestination("board"); + }} + /> + ) : null} {destination === "board" ? ( { + return backendFetch("/api/dashboard", accessToken); +} + export interface PostFilterOption { code: string; label: string; @@ -323,6 +355,20 @@ export interface AskAgentResponse { source_post_ids: string[]; next_action?: string; lineage_graph?: LineageGraph; + delivery?: { + contract_version: string; + report: { + media_type: string; + body: string; + source_documents: Array<{ post_id: string; title: string; api_path: string; resource_uri: string }>; + }; + alert: { + trigger_code: string; + delivery_status_code: string; + eligible: boolean; + watched_resource_uris: string[]; + }; + }; } export interface IssueTicket { diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx new file mode 100644 index 000000000..b16c2bc3e --- /dev/null +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { OperationsDashboardView } from "./OperationsDashboard"; +import "../App.css"; + +const meta = { title: "Workspace/OperationsDashboard", component: OperationsDashboardView, parameters: { layout: "fullscreen" } } satisfies Meta; +export default meta; +type Story = StoryObj; + +export const EvidenceReady: Story = { + args: { + data: { + period_label: "2026-08-01–2026-08-25 · Event time", total_post_count: 40, total_event_count: 17, + external_post_count: 9, external_percent: 22.5, pending_analysis_count: 3, + cases: [{ post_id: "synthetic-post-1", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification." }] }], + }, + onOpenPost: () => undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "근거 글 열기" })).toBeVisible(); + }, +}; + +export const NarrowViewport: Story = { ...EvidenceReady, parameters: { viewport: { defaultViewport: "mobile1" } } }; diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx new file mode 100644 index 000000000..05128d386 --- /dev/null +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { OperationsDashboardView } from "./OperationsDashboard"; + +const data = { + period_label: "2026-08-01–2026-08-25 · Event time", + total_post_count: 20, + total_event_count: 8, + external_post_count: 5, + external_percent: 25, + pending_analysis_count: 2, + cases: [{ + post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", + project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", occurred_at: "2026-08-12T00:00:00Z", + facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100" }], + }], +}; + +describe("OperationsDashboardView", () => { + it("distinguishes posts, events, percentages and opens evidence", async () => { + const onOpenPost = vi.fn(); + render(); + expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); + expect(screen.getByText("원인 수주")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "근거 글 열기" })); + expect(onOpenPost).toHaveBeenCalledWith("post-1"); + }); + + it("shows an actionable empty external-information state", () => { + render( undefined} />); + expect(screen.getByRole("status")).toHaveTextContent("분석 대기 건부터 처리하세요"); + }); +}); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx new file mode 100644 index 000000000..b1c73bc75 --- /dev/null +++ b/frontend/src/components/OperationsDashboard.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; + +type Props = { + accessToken: string; + externalOnly?: boolean; + onOpenPost: (postId: string) => void; +}; + +/** Shows quantified operational cases and opens their cited source posts. */ +export function OperationsDashboard({ accessToken, externalOnly = false, onOpenPost }: Props) { + const [data, setData] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + setError(false); + fetchOperationsDashboard(accessToken) + .then((value) => active && setData(value)) + .catch(() => active && setError(true)); + return () => { active = false; }; + }, [accessToken]); + + if (error) return

운영 근거 Dashboard

Dashboard 근거를 불러오지 못했습니다. 잠시 후 다시 시도하세요.

; + if (!data) return

Dashboard 근거를 불러오는 중입니다.

; + return ; +} + +/** Renders a completed Dashboard response for runtime and Storybook scenes. */ +export function OperationsDashboardView({ data, externalOnly = false, onOpenPost }: { data: OperationsDashboardResponse; externalOnly?: boolean; onOpenPost: (postId: string) => void }) { + const cases = externalOnly ? data.cases.filter((item) => item.case_kind_code === "external_information") : data.cases; + const journeys = Object.entries( + cases.reduce>((groups, item) => { + if (item.project_name) (groups[item.project_name] ??= []).push(item); + return groups; + }, {}), + ); + return ( +
+
+

{data.period_label}

{externalOnly ? "외부 정보" : "운영 근거 Dashboard"}

+

수치를 선택하면 근거 글에서 다음 조치를 확인할 수 있습니다.

+
+
+
전체 글
{data.total_post_count}
+
분류 Event
{data.total_event_count}
+
외부 정보
{data.external_post_count}건 · {data.external_percent.toFixed(1)}%
+
분석 대기
{data.pending_analysis_count}
+
+ {!externalOnly && journeys.length ? ( +
+

프로젝트 여정

+ {journeys.map(([project, events]) => ( +
+

{project}

+
    + {(events ?? []).map((event) => ( +
  1. + +
  2. + ))} +
+
+ ))} +
+ ) : null} +
+ {cases.map((item) => ( +
+
{item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}
+

{item.summary_text}

+
{item.evidence_text}
+
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
+ +
+ ))} +
+ {cases.length === 0 ?

선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요.

: null} +
+ ); +} diff --git a/frontend/src/components/SimilarVocPanel.css b/frontend/src/components/SimilarVocPanel.css new file mode 100644 index 000000000..5cc04a875 --- /dev/null +++ b/frontend/src/components/SimilarVocPanel.css @@ -0,0 +1,12 @@ +.similar-voc { border-block-start: 1px solid var(--color-border-subtle); padding-block-start: var(--space-panel-block); } +.similar-voc > header p { color: var(--color-text); } +.similar-voc > ol { display: grid; gap: var(--space-panel-block); list-style: none; margin: 0; padding: 0; } +.similar-voc article { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-panel); padding: var(--space-panel-block); } +.similar-voc-rank { color: var(--color-text); font-size: var(--font-size-badge); } +.similar-voc blockquote { border-inline-start: 3px solid var(--color-accent); margin-inline: 0; padding-inline-start: var(--space-panel-block); } +.similar-voc dl > div { display: grid; gap: var(--space-control-gap); grid-template-columns: minmax(6rem, 0.25fr) 1fr; } +.similar-voc dt { font-weight: 700; } +.similar-voc button { background: var(--color-btn-secondary-bg); border: 1px solid var(--color-btn-secondary-border); border-radius: var(--radius-control); color: var(--color-btn-secondary-text); cursor: pointer; min-height: 44px; padding-inline: var(--space-panel-block); } +.similar-voc button:hover { background: var(--color-btn-secondary-hover); } +.similar-voc button:focus-visible { border-color: var(--color-focus-border); outline: 3px solid var(--color-focus-ring); outline-offset: 2px; } +@media (max-width: 40rem) { .similar-voc dl > div { grid-template-columns: 1fr; } } diff --git a/frontend/src/components/SimilarVocPanel.stories.tsx b/frontend/src/components/SimilarVocPanel.stories.tsx new file mode 100644 index 000000000..7fcc6c1e7 --- /dev/null +++ b/frontend/src/components/SimilarVocPanel.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimilarVocPanel } from "./SimilarVocPanel"; + +const meta = { title: "Post/Similar VOC", component: SimilarVocPanel } satisfies Meta; +export default meta; +type Story = StoryObj; + +export const WithActionHistory: Story = { args: { items: [{ + post_id: "synthetic-post-2", post_title: "합성 과거 VOC", issue_summary: "동일 씰 고장 유형", + candidate_evidence_text: "시험 중 씰 누설이 확인되었습니다.", customer_cohort_text: "합성 고객군 A", + action_history: ["가스켓을 교체하고 압력을 재검증했습니다."], fused_rank: 1, +}], onOpenPost: () => undefined } }; +export const Empty: Story = { args: { items: [], onOpenPost: () => undefined } }; diff --git a/frontend/src/components/SimilarVocPanel.test.tsx b/frontend/src/components/SimilarVocPanel.test.tsx new file mode 100644 index 000000000..6cf3d0678 --- /dev/null +++ b/frontend/src/components/SimilarVocPanel.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SimilarVocPanel } from "./SimilarVocPanel"; + +describe("SimilarVocPanel", () => { + it("opens a cited prior VOC and shows its action history", async () => { + const onOpenPost = vi.fn(); + render(); + expect(screen.getByText("가스켓을 교체하고 압력을 재검증했습니다.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "근거 글 열기" })); + expect(onOpenPost).toHaveBeenCalledWith("post-2"); + }); + + it("explains an empty semantic result", () => { + render( undefined} />); + expect(screen.getByRole("status")).toHaveTextContent("판정된 과거 VOC가 없습니다"); + }); +}); diff --git a/frontend/src/components/SimilarVocPanel.tsx b/frontend/src/components/SimilarVocPanel.tsx new file mode 100644 index 000000000..85f917686 --- /dev/null +++ b/frontend/src/components/SimilarVocPanel.tsx @@ -0,0 +1,49 @@ +import "./SimilarVocPanel.css"; + +export type SimilarVocItem = { + post_id: string; + post_title: string; + issue_summary: string; + candidate_evidence_text: string; + customer_cohort_text: string | null; + action_history: string[]; + fused_rank: number; +}; + +type Props = { + items: SimilarVocItem[]; + onOpenPost: (postId: string) => void; +}; + +/** Shows semantically adjudicated prior VOCs and their source-supported actions. */ +export function SimilarVocPanel({ items, onOpenPost }: Props) { + return ( +
+
+

유사 VOC · 고객군 확인

+

같은 문제 유형으로 판정된 과거 근거와 조치 이력을 확인하세요.

+
+ {items.length === 0 ? ( +

같은 문제 유형으로 판정된 과거 VOC가 없습니다.

+ ) : ( +
    + {items.map((item) => ( +
  1. +
    +

    추천 {item.fused_rank}

    +

    {item.post_title}

    +

    {item.issue_summary}

    +
    {item.candidate_evidence_text}
    +
    +
    고객군
    {item.customer_cohort_text ?? "동일 고객 근거 없음"}
    +
    과거 조치
    {item.action_history.length ?
      {item.action_history.map((action) =>
    • {action}
    • )}
    : "기록된 조치 없음"}
    +
    + +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/WorkspaceNav.test.tsx b/frontend/src/components/WorkspaceNav.test.tsx index b19e0a415..934eb9ff7 100644 --- a/frontend/src/components/WorkspaceNav.test.tsx +++ b/frontend/src/components/WorkspaceNav.test.tsx @@ -9,7 +9,7 @@ afterEach(() => { }); describe("WorkspaceNav", () => { - it("renders exactly the four Korean analyst destinations and marks the current page", () => { + it("renders the Dashboard and four analyst destinations and marks the current page", () => { render(); const nav = screen.getByRole("navigation"); @@ -21,7 +21,7 @@ describe("WorkspaceNav", () => { expect(screen.getByRole("button", { name: "달력" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ask Agent" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Admin" })).not.toBeInTheDocument(); - expect(nav.textContent).not.toMatch(/Buyer|Cubee|Board|Customer master/i); + expect(nav.textContent).not.toMatch(/Buyer|Cubee|Customer master/i); }); it.each(SUPPORTED_LOCALES)("keeps the four Korean GNB labels in %s", (locale) => { @@ -30,6 +30,7 @@ describe("WorkspaceNav", () => { const nav = screen.getByRole("navigation"); expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([ + "Dashboard", "게시판", "고객 마스터", "달력", diff --git a/frontend/src/gnbChrome.ts b/frontend/src/gnbChrome.ts index 8cd5ae68d..408084f14 100644 --- a/frontend/src/gnbChrome.ts +++ b/frontend/src/gnbChrome.ts @@ -1,6 +1,7 @@ /** Analyst GNB chrome: four Korean destinations, no Buyer/Cubee labels. */ export const ANALYST_GNB_ITEMS = [ + { id: "dashboard", label: "Dashboard" }, { id: "board", label: "게시판" }, { id: "customers", label: "고객 마스터" }, { id: "calendar", label: "달력" }, diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 69b1994e3..657c7b7cf 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -100,8 +100,8 @@ describe("i18n", () => { }, ); - it("keeps analyst GNB chrome on the four Korean labels", () => { - expect(ANALYST_GNB_LABELS).toEqual(["게시판", "고객 마스터", "달력", "Ask Agent"]); + it("keeps analyst GNB chrome on the Dashboard and four Korean labels", () => { + expect(ANALYST_GNB_LABELS).toEqual(["Dashboard", "게시판", "고객 마스터", "달력", "Ask Agent"]); expect(ANALYST_GNB_LABELS.join(" ")).not.toMatch(/Buyer|Cubee|Board|Customer master/); expect(CALENDAR_CONSUME_UNAVAILABLE).toBe("이 범위의 일정을 아직 받을 수 없습니다"); }); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 25eda5036..400c686da 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -100,6 +100,9 @@ --font-size-badge: 0.75rem; --space-panel-block: 0.75rem; --radius-panel: 0.5rem; + --color-dashboard-ink: #14264a; + --color-dashboard-positive: #426b45; + --color-dashboard-surface: #f4f6fa; /* Layout & Breakpoint Tokens (§2.1 – 화면 해상도 / 반응형) */ --breakpoint-phone: 768px; diff --git a/lineageweave/ask_delivery.py b/lineageweave/ask_delivery.py new file mode 100644 index 000000000..e8d07c42c --- /dev/null +++ b/lineageweave/ask_delivery.py @@ -0,0 +1,56 @@ +"""Stable delivery projection for evidence-grounded Ask answers. + +The Ask worker owns retrieval and reasoning. This module only packages its +settled answer and citations for UI, report, alert, and future MCP consumers; +it never classifies text or invents evidence. +""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping +from urllib.parse import quote + + +def build_ask_delivery( + answer_text: str, + cited_posts: Iterable[Mapping[str, str]], + cited_post_evidence: Iterable[Mapping[str, Any]], +) -> dict[str, Any]: + """Project a settled Ask answer into linked report and alert contracts. + + Alert delivery is explicitly subscription-driven. A citation-bearing + answer is eligible for evidence-change alerts, but this function never + guesses urgency from words in the answer. + """ + evidence_by_post = { + str(item["post_id"]): list(item.get("facts") or ()) + for item in cited_post_evidence + if item.get("post_id") + } + documents = [] + for post in cited_posts: + post_id = str(post["post_id"]) + encoded_id = quote(post_id, safe="") + documents.append( + { + "post_id": post_id, + "title": str(post["post_title"]), + "api_path": f"/api/posts/{encoded_id}", + "resource_uri": f"lineageweave://posts/{encoded_id}", + "evidence_facts": evidence_by_post.get(post_id, []), + } + ) + return { + "contract_version": "1.0", + "report": { + "media_type": "text/markdown", + "body": answer_text, + "source_documents": documents, + }, + "alert": { + "trigger_code": "cited_evidence_changed", + "delivery_status_code": "not_subscribed", + "eligible": bool(documents), + "watched_resource_uris": [item["resource_uri"] for item in documents], + }, + } diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py new file mode 100644 index 000000000..34639d25b --- /dev/null +++ b/lineageweave/operations_case_analysis.py @@ -0,0 +1,128 @@ +"""Evidence-grounded operational case inference through contextual-orchestrator.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Protocol + +from .http_client import chat_completion_content, post_json + +CASE_KINDS = frozenset( + {"claim_investigation", "rebid_handover", "external_information", "repeat_issue"} +) +FACT_TYPES = frozenset( + { + "order", "specification_change", "originating_order", "sales_pool", + "discussion", "counterparty", "our_owner", "decision", "external_relation", + "issue_pattern", "improvement_action", + } +) + + +@dataclass(frozen=True) +class OperationsCaseFact: + """One answer and the source span that supports it.""" + + fact_type_code: str + value_text: str + evidence_text: str + + +@dataclass(frozen=True) +class OperationsCase: + """One semantically classified operational case in a post.""" + + case_kind_code: str + summary_text: str + evidence_text: str + facts: tuple[OperationsCaseFact, ...] + + +class OperationsCaseAnalysisClient(Protocol): + """Classify operational cases without keyword rules.""" + + available: bool + + def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + """Return every source-supported case and its facts.""" + raise NotImplementedError + + +class NullOperationsCaseAnalysisClient: + """Unavailable case-analysis channel.""" + + available = False + + def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + """Refuse to fabricate a case when the orchestrator is unavailable.""" + raise RuntimeError("operations case analysis is unavailable") + + +_PROMPT = """Analyze this business record semantically. Do not use keyword matching. +Return ONLY a JSON array. Each item must have case_kind_code (one of +claim_investigation, rebid_handover, external_information, repeat_issue), summary_text, +evidence_text (a verbatim span from the body), and facts. Each fact has +fact_type_code (one of order, specification_change, originating_order, +sales_pool, discussion, counterparty, our_owner, decision, external_relation, +issue_pattern, improvement_action), value_text, and evidence_text (a verbatim body span). Return [] only when the +record supports none of the case kinds. Never fill an unsupported fact. + +Stored context (hints, not proof): {context} +Title: {title} +Body: {body} +""" + + +def parse_operations_case_response(content: str, source_body: str) -> tuple[OperationsCase, ...] | None: + """Validate a JSON response and require every evidence span to occur in the source.""" + try: + payload = json.loads(content.strip()) + except json.JSONDecodeError: + return None + if not isinstance(payload, list): + return None + cases: list[OperationsCase] = [] + for item in payload: + if not isinstance(item, dict) or item.get("case_kind_code") not in CASE_KINDS: + return None + summary = item.get("summary_text") + evidence = item.get("evidence_text") + facts = item.get("facts") + if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or evidence not in source_body or not isinstance(facts, list): + return None + parsed_facts: list[OperationsCaseFact] = [] + for fact in facts: + if not isinstance(fact, dict) or fact.get("fact_type_code") not in FACT_TYPES: + return None + value = fact.get("value_text") + fact_evidence = fact.get("evidence_text") + if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or fact_evidence not in source_body: + return None + parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence)) + cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts))) + return tuple(cases) + + +class ContextualOrchestratorOperationsCaseAnalysisClient: + """Use the provider-neutral orchestrator's multi-agent auto mode.""" + + available = True + + def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout = timeout + + def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + """Classify cases and reject any uncited or malformed result.""" + response = post_json( + f"{self._base_url}/v1/chat/completions", + {"messages": [{"role": "user", "content": _PROMPT.format(context=context, title=title, body=body)}], "mode": "auto", "reasoning_effort": "auto"}, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + parsed = parse_operations_case_response(chat_completion_content(response), body) + if parsed is None: + raise ValueError("operations case response did not match the evidence contract") + return parsed diff --git a/lineageweave/similar_voc.py b/lineageweave/similar_voc.py new file mode 100644 index 000000000..14fa3527a --- /dev/null +++ b/lineageweave/similar_voc.py @@ -0,0 +1,125 @@ +"""Evidence-gated similar-VOC adjudication and RankWeave ordering.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Mapping, Protocol, Sequence + +from .http_client import chat_completion_content, post_json +from .rankweave_client import RankWeaveClient, RankingList + + +@dataclass(frozen=True) +class SimilarVocEvidence: + """A semantically equivalent VOC with extractive evidence from both posts.""" + + candidate_post_id: str + issue_summary: str + focal_evidence_text: str + candidate_evidence_text: str + customer_cohort_text: str | None + action_history: tuple[str, ...] + + +class SimilarVocAnalysisClient(Protocol): + """Adjudicate embedding-retrieved candidates through the orchestrator.""" + + available: bool + + def analyze( + self, focal_title: str, focal_body: str, candidate_post_id: str, + candidate_title: str, candidate_body: str, + ) -> SimilarVocEvidence | None: + """Return a cited equivalent issue, or ``None`` when it is not equivalent.""" + raise NotImplementedError + + +_PROMPT = """Decide whether these two business records describe the same operational issue +type. Do not use keyword matching. Use their meaning, actors, affected object, failure mode, +and outcome. Return ONLY JSON with `similar` (boolean). If false, return only that field. +If true, also return issue_summary, focal_evidence_text (verbatim from focal body), +candidate_evidence_text (verbatim from candidate body), customer_cohort_text (string or null), +and action_history (an array containing only source-supported past actions, each verbatim from +the candidate body). Customer cohort may be stated only when the records explicitly identify +the same cataloged or source customer; otherwise use null. + +Focal title: {focal_title} +Focal body: {focal_body} +Candidate title: {candidate_title} +Candidate body: {candidate_body} +""" + + +def parse_similar_voc_response( + content: str, candidate_post_id: str, focal_body: str, candidate_body: str, +) -> SimilarVocEvidence | None: + """Accept only a positive result whose evidence is present in its source body.""" + try: + payload = json.loads(content.strip()) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict) or payload.get("similar") is not True: + return None + summary = payload.get("issue_summary") + focal_evidence = payload.get("focal_evidence_text") + candidate_evidence = payload.get("candidate_evidence_text") + cohort = payload.get("customer_cohort_text") + actions = payload.get("action_history") + if ( + not isinstance(summary, str) or not summary.strip() + or not isinstance(focal_evidence, str) or focal_evidence not in focal_body + or not isinstance(candidate_evidence, str) or candidate_evidence not in candidate_body + or (cohort is not None and (not isinstance(cohort, str) or not cohort.strip())) + or not isinstance(actions, list) + or any(not isinstance(action, str) or action not in candidate_body for action in actions) + ): + return None + return SimilarVocEvidence( + candidate_post_id, summary.strip(), focal_evidence, candidate_evidence, + cohort.strip() if isinstance(cohort, str) else None, tuple(actions), + ) + + +class ContextualOrchestratorSimilarVocAnalysisClient: + """Use contextual-orchestrator auto mode for evidence-gated equivalence.""" + + available = True + + def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout = timeout + + def analyze( + self, focal_title: str, focal_body: str, candidate_post_id: str, + candidate_title: str, candidate_body: str, + ) -> SimilarVocEvidence | None: + """Ask the governed inference boundary and validate its extractive evidence.""" + response = post_json( + f"{self._base_url}/v1/chat/completions", + {"messages": [{"role": "user", "content": _PROMPT.format( + focal_title=focal_title, focal_body=focal_body, + candidate_title=candidate_title, candidate_body=candidate_body, + )}], "mode": "auto", "reasoning_effort": "auto"}, + headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, + ) + return parse_similar_voc_response( + chat_completion_content(response), candidate_post_id, focal_body, candidate_body, + ) + + +def rank_similar_voc_candidates( + channel_ranks: Mapping[str, Sequence[str]], titles_by_id: Mapping[str, str], + estimated_weights: Mapping[str, float], rankweave: RankWeaveClient, +) -> RankingList: + """Fuse semantic, customer, and temporal ranks using an exact estimated vector. + + The caller must load the vector through ``load_estimated_channel_weights``. + Missing or partial vectors fail closed instead of receiving equal or local weights. + """ + channels = {name: list(ids) for name, ids in channel_ranks.items() if ids} + weights = {name: float(estimated_weights[name]) for name in channels if name in estimated_weights} + if not channels or set(weights) != set(channels) or any(value <= 0 for value in weights.values()): + raise ValueError("similar VOC ranking requires a complete estimated channel-weight vector") + return rankweave.fuse_rankings(channels, titles_by_id, weights=weights) diff --git a/migrations/0208_operations_case_analysis.sql b/migrations/0208_operations_case_analysis.sql new file mode 100644 index 000000000..d95eb6a02 --- /dev/null +++ b/migrations/0208_operations_case_analysis.sql @@ -0,0 +1,31 @@ +-- Evidence-grounded operational case inference (ADR 0204). Replay-safe. +create table if not exists operations_case_analysis ( + post_id uuid primary key references source_post(post_id) on delete cascade, + source_body_sha256 text not null check (source_body_sha256 ~ '^[0-9a-f]{64}$'), + orchestrator_session_id text not null, + analyzed_at timestamptz not null default now() +); + +create table if not exists operations_case_classification ( + post_id uuid not null references operations_case_analysis(post_id) on delete cascade, + case_kind_code text not null check (case_kind_code in ('claim_investigation', 'rebid_handover', 'external_information', 'repeat_issue')), + summary_text text not null check (btrim(summary_text) <> ''), + evidence_text text not null check (btrim(evidence_text) <> ''), + primary key (post_id, case_kind_code) +); + +create table if not exists operations_case_fact ( + post_id uuid not null, + case_kind_code text not null, + fact_ordinal integer not null check (fact_ordinal >= 0), + fact_type_code text not null check (fact_type_code in ('order', 'specification_change', 'originating_order', 'sales_pool', 'discussion', 'counterparty', 'our_owner', 'decision', 'external_relation', 'issue_pattern', 'improvement_action')), + value_text text not null check (btrim(value_text) <> ''), + evidence_text text not null check (btrim(evidence_text) <> ''), + primary key (post_id, case_kind_code, fact_ordinal), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create index if not exists operations_case_classification_kind_post_idx + on operations_case_classification (case_kind_code, post_id); diff --git a/tests/test_ask_delivery.py b/tests/test_ask_delivery.py new file mode 100644 index 000000000..38f5d733c --- /dev/null +++ b/tests/test_ask_delivery.py @@ -0,0 +1,44 @@ +"""Checks for the transport-neutral Ask delivery contract.""" + +from lineageweave.ask_delivery import build_ask_delivery + + +def test_delivery_links_only_cited_evidence_without_keyword_classification() -> None: + """Reports and alerts retain citation identity and safe resource links.""" + delivery = build_ask_delivery( + "A prior response is documented.", + ({"post_id": "post/a", "post_title": "Response record"},), + ({"post_id": "post/a", "facts": [{"kind": "source_field", "text": "Recorded"}]},), + ) + + assert delivery == { + "contract_version": "1.0", + "report": { + "media_type": "text/markdown", + "body": "A prior response is documented.", + "source_documents": [ + { + "post_id": "post/a", + "title": "Response record", + "api_path": "/api/posts/post%2Fa", + "resource_uri": "lineageweave://posts/post%2Fa", + "evidence_facts": [{"kind": "source_field", "text": "Recorded"}], + } + ], + }, + "alert": { + "trigger_code": "cited_evidence_changed", + "delivery_status_code": "not_subscribed", + "eligible": True, + "watched_resource_uris": ["lineageweave://posts/post%2Fa"], + }, + } + + +def test_delivery_without_citations_cannot_offer_an_evidence_alert() -> None: + """An unsupported answer never becomes a fabricated alert target.""" + delivery = build_ask_delivery("", (), ()) + + assert delivery["report"]["source_documents"] == [] + assert delivery["alert"]["eligible"] is False + assert delivery["alert"]["watched_resource_uris"] == [] diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py new file mode 100644 index 000000000..90f71b901 --- /dev/null +++ b/tests/test_operations_case_analysis.py @@ -0,0 +1,34 @@ +"""Operational case semantic-response contract tests.""" + +import json + +from lineageweave.operations_case_analysis import parse_operations_case_response + + +def test_parses_multiple_cases_and_grounded_facts() -> None: + """One record may support multiple case kinds without losing evidence.""" + body = "The revised specification caused the claim. Mina agreed with Alex to rebid." + payload = [ + {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}]}, + {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}]}, + ] + result = parse_operations_case_response(json.dumps(payload), body) + assert result is not None + assert [case.case_kind_code for case in result] == ["claim_investigation", "rebid_handover"] + + +def test_rejects_uncited_model_claim() -> None: + """A plausible answer absent from the source is not persisted.""" + payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": []}] + assert parse_operations_case_response(json.dumps(payload), "source body") is None + + +def test_accepts_supported_no_case_result() -> None: + """An empty semantic result remains distinct from malformed output.""" + assert parse_operations_case_response("[]", "ordinary status") == () + + +def test_rejects_unknown_codes_and_malformed_json() -> None: + """Closed vocabularies prevent provider prose from entering persistence.""" + assert parse_operations_case_response("not json", "body") is None + assert parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py new file mode 100644 index 000000000..e68306c8f --- /dev/null +++ b/tests/test_operations_case_ingestion.py @@ -0,0 +1,47 @@ +"""Operational case persistence tests.""" + +import asyncio + +from backend.app.operations_case_ingestion import persist_operations_cases, source_body_digest +from lineageweave.operations_case_analysis import OperationsCase, OperationsCaseFact + + +class _Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + +class _Connection: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.batches: list[list[tuple[object, ...]]] = [] + + def transaction(self) -> _Transaction: + return _Transaction() + + async def execute(self, sql: str, *args: object) -> None: + self.calls.append((sql, args)) + + async def executemany(self, _sql: str, args: list[tuple[object, ...]]) -> None: + self.batches.append(args) + + +def test_digest_and_atomic_normalized_persistence() -> None: + """The parent, classifications, and facts retain exact-body lineage.""" + conn = _Connection() + cases = (OperationsCase("claim_investigation", "Claim", "source", (OperationsCaseFact("order", "A-1", "source"),)),) + asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", cases)) + assert len(source_body_digest("source")) == 64 + assert "delete from operations_case_analysis" in conn.calls[0][0] + assert conn.batches == [[("post-1", "claim_investigation", 0, "order", "A-1", "source")]] + + +def test_persists_supported_empty_analysis() -> None: + """A completed no-case result is recorded without fabricated children.""" + conn = _Connection() + asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ())) + assert len(conn.calls) == 2 + assert conn.batches == [] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py new file mode 100644 index 000000000..d3662cdd8 --- /dev/null +++ b/tests/test_operations_dashboard.py @@ -0,0 +1,117 @@ +"""Focused tests for the operational dashboard evidence projection.""" + +from datetime import date, datetime, timezone + +import pytest + +from backend.app.operations_dashboard import fetch_operations_dashboard + + +class _Connection: + """Return deterministic rows while retaining the executed SQL.""" + + def __init__(self) -> None: + self.queries: list[tuple[str, tuple[object, ...]]] = [] + + async def fetchrow(self, query: str, *args: object) -> dict[str, int]: + self.queries.append((query, args)) + return { + "total_post_count": 4, + "total_event_count": 3, + "external_post_count": 1, + "pending_analysis_count": 1, + } + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.queries.append((query, args)) + if "operations_case_fact fact" in query: + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "fact_type_code": "originating_order", + "value_text": "Synthetic order 7", + "evidence_text": "Synthetic cited sentence", + "fact_ordinal": 0, + } + ] + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "summary_text": "원인 수주가 연결됨", + "evidence_text": "Synthetic cited sentence", + "project_name": "Synthetic Project", + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + } + ] + + +@pytest.mark.anyio +async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: + """Counts and cases share the exact authorized event-time population.""" + conn = _Connection() + + result = await fetch_operations_dashboard( + conn, + ["00000000-0000-0000-0000-000000000009"], + date(2026, 8, 1), + date(2026, 8, 31), + ) + + assert result["period_label"] == "2026-08-01 ~ 2026-08-31 · Event 발생일" + assert result["external_percent"] == 25.0 + assert result["cases"] == [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "case_kind_label": "클레임 원인 규명", + "project_name": "Synthetic Project", + "summary_text": "원인 수주가 연결됨", + "evidence_text": "Synthetic cited sentence", + "occurred_at": "2026-08-12T00:00:00+00:00", + "facts": [ + { + "fact_type_code": "originating_order", + "fact_type_label": "원인 수주", + "value_text": "Synthetic order 7", + "evidence_text": "Synthetic cited sentence", + } + ], + } + ] + assert len(conn.queries) == 3 + for query, args in conn.queries: + assert "visibility_code = 'public'" in query + assert "corporate_entity_id::text = any($1::text[])" in query + assert "coalesce(post.event_occurred_at, post.created_at)" in query + assert args[1:] == (date(2026, 8, 1), date(2026, 8, 31)) + + +@pytest.mark.anyio +async def test_dashboard_zero_denominator_and_invalid_period() -> None: + """An empty corpus has 0%, while an inverted interval fails closed.""" + + class EmptyConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, int]: + self.queries.append((query, args)) + return dict.fromkeys( + ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count"), + 0, + ) + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.queries.append((query, args)) + return [] + + assert (await fetch_operations_dashboard(EmptyConnection(), []))["external_percent"] == 0.0 + with pytest.raises(ValueError, match="period_start"): + await fetch_operations_dashboard( + EmptyConnection(), [], date(2026, 9, 1), date(2026, 8, 31) + ) + + +@pytest.fixture +def anyio_backend() -> str: + """Use the installed asyncio backend for async projection tests.""" + return "asyncio" diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 1cb073668..67cd2d146 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -175,7 +175,17 @@ async def incomplete(*_args, **_kwargs): orchestrator_api_key="key", ), ) - monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object()) + monkeypatch.setattr( + post_content_worker, + "normalize_post_body", + lambda *_args: SimpleNamespace(text="synthetic source body"), + ) + monkeypatch.setattr( + post_content_worker, + "ContextualOrchestratorOperationsCaseAnalysisClient", + lambda *_args: SimpleNamespace(analyze=lambda *_values: ()), + ) + monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist) client = SimpleNamespace(available=True) asyncio.run( diff --git a/tests/test_similar_voc.py b/tests/test_similar_voc.py new file mode 100644 index 000000000..ce1a7403c --- /dev/null +++ b/tests/test_similar_voc.py @@ -0,0 +1,48 @@ +"""Contracts for cited similar-VOC inference and measured ranking.""" + +import json + +import pytest + +from lineageweave.rankweave_client import RankWeaveClient +from lineageweave.similar_voc import parse_similar_voc_response, rank_similar_voc_candidates + + +def test_positive_similarity_requires_extractable_evidence() -> None: + """A positive relation retains focal, candidate, cohort, and action evidence.""" + focal = "A seal failed during acceptance." + candidate = "A seal failed during trial. Replaced the gasket and verified pressure." + payload = { + "similar": True, "issue_summary": "Equivalent seal failure", + "focal_evidence_text": "A seal failed during acceptance.", + "candidate_evidence_text": "A seal failed during trial.", + "customer_cohort_text": None, + "action_history": ["Replaced the gasket and verified pressure."], + } + result = parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) + assert result is not None + assert result.candidate_post_id == "post-2" + assert result.action_history == ("Replaced the gasket and verified pressure.",) + payload["candidate_evidence_text"] = "invented" + assert parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) is None + + +def test_ranking_uses_only_complete_supplied_measurement_weights() -> None: + """RankWeave receives the exact persisted estimate and rejects a partial vector.""" + captured = {} + + def transport(channels, weights): + captured.update(weights) + return [{"item_id": "post-2"}] + + ranking = rank_similar_voc_candidates( + {"text": ["post-2"], "secondary_key": ["post-2"]}, {"post-2": "Prior VOC"}, + {"text": 0.7, "secondary_key": 0.3}, RankWeaveClient(transport=transport), + ) + assert captured == {"text": 0.7, "secondary_key": 0.3} + assert ranking.items[0].post_id == "post-2" + with pytest.raises(ValueError, match="complete estimated"): + rank_similar_voc_candidates( + {"text": ["post-2"], "secondary_key": ["post-2"]}, {"post-2": "Prior VOC"}, + {"text": 1.0}, RankWeaveClient(transport=transport), + ) From f40ecef49ff26a0d6743948a63efb004e7825543 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 17:17:58 +0900 Subject: [PATCH 04/27] fix(tepp): enforce canonical anchor UUID --- backend/app/analysis_run_start.py | 4 +++- tests/test_analysis_run_start.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 26dde1c87..4b967e426 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -344,7 +344,8 @@ async def _persist_tepp_result( and isinstance(anchor, dict) ): try: - estimation_run_id = str(UUID(str(anchor["estimation_run_id"]))) + raw_estimation_run_id = str(anchor["estimation_run_id"]) + estimation_run_id = str(UUID(raw_estimation_run_id)) anchor_cutoff = datetime.fromisoformat( str(anchor["knowledge_cutoff"]).replace("Z", "+00:00") ) @@ -356,6 +357,7 @@ async def _persist_tepp_result( if anchor is not None and ( anchor.get("anchor_kind_code") != "lineage_pair_criterion" or anchor.get("contract_version") != 1 + or raw_estimation_run_id != estimation_run_id or anchor.get("source_snapshot_sha256") != expected_snapshot_sha256 or anchor_cutoff != expected_cutoff or anchor.get("criterion_validity_status") != "accepted" diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 936056903..7cb829b06 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -318,6 +318,20 @@ async def execute(self, query: str, *args: object): ) assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) + conn = _Connection() + envelope["result_schema_version"] = "tepp.lineage_criterion_anchor.v1" + envelope["result"]["estimation_run_id"] = "018f47e77b5b7cc098c615fdf9e3d9b1" + assert asyncio.run( + _persist_tepp_result( + conn, + analysis_run_id="11111111-1111-1111-1111-111111111111", + envelope=envelope, + expected_snapshot_sha256="ab" * 32, + expected_knowledge_cutoff=cutoff, + ) + ) + assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) + def _topic_lineage_request() -> AnalysisRunRequest: return topic_lineage_run_request( From e9ca1d0c420020bfe2d062c69797daa99f7cfc62 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 17:20:27 +0900 Subject: [PATCH 05/27] fix(frontend): keep merged OIDC imports minimal --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e14cb5649..2bda1931d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -100,7 +100,7 @@ import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; -import { rememberOidcReturnUrl, returnUrlFromLocation, stripOidcCallbackParams } from "./oidcReturnUrl"; +import { stripOidcCallbackParams } from "./oidcReturnUrl"; import { isSupportedLocale, LOCALE_LABELS, From 0700943b398fcc796fd890d0c6b5c24910b83911 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:06:46 +0900 Subject: [PATCH 06/27] feat: complete semantic dashboard evidence paths --- backend/app/analysis_run_start.py | 15 ++ backend/app/global_ask_queue.py | 40 +++- backend/app/main.py | 84 ++++++- backend/app/post_chat_ingestion.py | 205 +++++++----------- backend/tests/test_api.py | 57 ++++- backend/tests/test_similar_voc_api.py | 64 ++++++ .../adr/0047-global-ask-semantic-retrieval.md | 23 +- ...0-global-ask-lineage-timeline-expansion.md | 15 +- .../0150-korean-relative-time-retrieval.md | 26 +-- .../adr/0200-channel-weight-reconciliation.md | 5 +- docs/adr/0205-tepp-lineage-anchor.md | 6 + .../adr/0206-evidence-operations-dashboard.md | 8 +- docs/product-technical-gap-baseline.md | 28 +-- frontend/src/App.css | 16 ++ frontend/src/App.tsx | 29 ++- frontend/src/api.test.ts | 15 +- frontend/src/api.ts | 30 ++- .../src/components/OperationsDashboard.tsx | 22 +- frontend/src/components/SimilarVocPanel.css | 2 +- .../components/SimilarVocPanel.stories.tsx | 5 +- .../src/components/SimilarVocPanel.test.tsx | 3 +- frontend/src/components/SimilarVocPanel.tsx | 26 +-- lineageweave/similar_voc.py | 13 +- tests/test_analysis_run_start.py | 11 + tests/test_global_ask_sources.py | 104 +++++---- tests/test_similar_voc.py | 17 ++ 26 files changed, 618 insertions(+), 251 deletions(-) create mode 100644 backend/tests/test_similar_voc_api.py diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 4b967e426..b0b37dc92 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -385,6 +385,21 @@ async def _persist_tepp_result( anchor["criterion_validity_status"], anchor["validated_pair_count"], ) + await conn.execute( + """ + update lineage_channel_weight + set anchor_method_code = 'tepp_lineage_criterion_v1' + where estimation_run_id = $1 + and estimation_method_code = 'mls2plm_expected_information' + and source_snapshot_sha256 = $2 + and knowledge_cutoff = $3 + and sample_pair_count = $4 + """, + estimation_run_id, + anchor["source_snapshot_sha256"], + anchor_cutoff, + anchor["validated_pair_count"], + ) except (asyncpg.PostgresError, TypeError, ValueError): return False return True diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index bf053027f..0b3966942 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -29,6 +29,7 @@ from fastapi import HTTPException, status from lineageweave.ask_delivery import build_ask_delivery +from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.http_client import HttpClientError from lineageweave.observability import record_server_failure from lineageweave.post_chat import ( @@ -158,6 +159,8 @@ async def compute_global_ask_answer( question_text: str, corporate_entity_ids: set[str], chat_client: PostChatClient, + embedding_client: EmbeddingClient | None = None, + embedding_model_code: str = "", ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -181,6 +184,8 @@ def can_see(row: asyncpg.Record) -> bool: corporate_entity_ids, question=question_text, today=today, + embedding_client=embedding_client, + embedding_model_code=embedding_model_code, ) except Exception as exc: log_internal_fault("global_ask", exc) @@ -284,6 +289,8 @@ async def process_global_ask_job( *, job_id: str, chat_factory: Callable[[], PostChatClient], + embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + embedding_model_code: str = "", ) -> None: """Claim, answer, and settle one Ask job. @@ -323,6 +330,8 @@ async def process_global_ask_job( question_text=str(row["question_text"]), corporate_entity_ids=entity_ids, chat_client=chat_client, + embedding_client=embedding_factory(), + embedding_model_code=embedding_model_code, ), timeout=JOB_DEADLINE_SECONDS, ) @@ -436,6 +445,8 @@ async def consume_global_ask_stream_once( *, last_id: str, chat_factory: Callable[[], PostChatClient], + embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + embedding_model_code: str = "", limiter: asyncio.Semaphore | None = None, tasks: set[asyncio.Task] | None = None, ) -> str: @@ -455,12 +466,23 @@ async def consume_global_ask_stream_once( job_id = str(fields.get("global_ask_job_id", "")).strip() if job_id: if limiter is None: - await process_global_ask_job(pool, job_id=job_id, chat_factory=chat_factory) + await process_global_ask_job( + pool, + job_id=job_id, + chat_factory=chat_factory, + embedding_factory=embedding_factory, + embedding_model_code=embedding_model_code, + ) else: await limiter.acquire() task = asyncio.create_task( _process_and_release( - pool, job_id=job_id, chat_factory=chat_factory, limiter=limiter + pool, + job_id=job_id, + chat_factory=chat_factory, + embedding_factory=embedding_factory, + embedding_model_code=embedding_model_code, + limiter=limiter, ) ) if tasks is not None: @@ -475,11 +497,19 @@ async def _process_and_release( *, job_id: str, chat_factory: Callable[[], PostChatClient], + embedding_factory: Callable[[], EmbeddingClient], + embedding_model_code: str, limiter: asyncio.Semaphore, ) -> None: """Run one dispatched job and free its concurrency slot afterwards.""" try: - await process_global_ask_job(pool, job_id=job_id, chat_factory=chat_factory) + await process_global_ask_job( + pool, + job_id=job_id, + chat_factory=chat_factory, + embedding_factory=embedding_factory, + embedding_model_code=embedding_model_code, + ) finally: limiter.release() @@ -499,6 +529,8 @@ async def run_global_ask_worker( pool: asyncpg.Pool, *, chat_factory: Callable[[], PostChatClient], + embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, + embedding_model_code: str = "", ) -> None: """Run the at-least-once Ask consumer with periodic queued-row recovery.""" last_id = await _stream_tail(client) @@ -517,6 +549,8 @@ async def run_global_ask_worker( pool, last_id=last_id, chat_factory=chat_factory, + embedding_factory=embedding_factory, + embedding_model_code=embedding_model_code, limiter=limiter, tasks=tasks, ) diff --git a/backend/app/main.py b/backend/app/main.py index 6ff6234d7..bc08b0a25 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -91,6 +91,7 @@ from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints +from lineageweave.similar_voc import ContextualOrchestratorSimilarVocAnalysisClient from lineageweave.ontology import LW from lineageweave.rankweave_client import build_rankweave_client @@ -271,6 +272,8 @@ async def lifespan(app: FastAPI): chat_factory=lambda: _post_chat_client( timeout=load_settings().orchestrator_answer_timeout_seconds ), + embedding_factory=_embedding_client, + embedding_model_code=settings.embedding_model, ) ) app.state.global_ask_worker = global_ask_worker @@ -482,6 +485,16 @@ def _post_evaluation_client(): ) +def _similar_voc_client(): + """Live semantic-pair client, or ``None`` when inference is unavailable.""" + settings = load_settings() + if not (settings.orchestrator_base_url and settings.orchestrator_api_key): + return None + return ContextualOrchestratorSimilarVocAnalysisClient( + base_url=settings.orchestrator_base_url, api_key=settings.orchestrator_api_key + ) + + def _rankweave_client(): """In-process RankWeave unless RANKWEAVE_DISABLED=1 (ADR 0024).""" return build_rankweave_client(disabled=load_settings().rankweave_disabled) @@ -1728,7 +1741,8 @@ async def _load_visible_post( # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound. row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli """ - select source_post.post_id, source_post.post_title, source_post.voc_type_code, + select source_post.post_id, source_post.post_title, source_post.post_body, + source_post.voc_type_code, source_post.visibility_code, source_post.corporate_entity_id, source_post.created_at, source_post.author_account_id, source_post.source_process_unit_code, source_post.source_author_code, @@ -1750,6 +1764,74 @@ async def _load_visible_post( return row +@app.get("/api/posts/{post_id}/similar-voc") +async def read_similar_voc( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return authorized, semantically adjudicated prior VOC evidence. + + Persisted ``repeat_issue`` classifications narrow the candidate corpus + without lexical matching. contextual-orchestrator then establishes each + pair; event time orders the display and is not a relevance score. + """ + focal = await _load_visible_post(post_id, account, pool) + client = _similar_voc_client() + if client is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "similar VOC inference is unavailable; configure contextual-orchestrator and retry", + ) + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + select post.post_id, post.post_title, post.post_body, + post.visibility_code, post.corporate_entity_id, + coalesce(post.event_occurred_at, post.created_at) as occurred_at + from operations_case_classification classification + join source_post post on post.post_id = classification.post_id + where classification.case_kind_code = 'repeat_issue' + and post.post_id <> $1 + and post.post_body <> '' + and """ + f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} " + "order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id", + post_id, + ) + candidates = [row for row in rows if _can_see_post(account, row)] + + async def _adjudicate(candidate: asyncpg.Record): + with use_llm_metadata(build_post_llm_metadata(post_id, focal)): + return await asyncio.to_thread( + client.analyze, + focal["post_title"], + focal["post_body"], + str(candidate["post_id"]), + candidate["post_title"], + candidate["post_body"], + ) + + items = [] + for candidate in candidates: + evidence = await _adjudicate(candidate) + if evidence is None: + continue + items.append( + { + "post_id": evidence.candidate_post_id, + "post_title": candidate["post_title"], + "issue_summary": evidence.issue_summary, + "focal_evidence_text": evidence.focal_evidence_text, + "candidate_evidence_text": evidence.candidate_evidence_text, + "customer_cohort_text": evidence.customer_cohort_text, + "action_history": evidence.action_history, + "occurred_at": candidate["occurred_at"].isoformat(), + } + ) + return {"items": items} + + async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> str: """Render author, business-unit, sales-pool, and customer hints without treating them as proof.""" rows = await conn.fetch( diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index f9129f3c6..79e1f1a44 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -8,7 +8,7 @@ not that the requesting account may see both. `gather_global_chat_sources` (Global Ask, no starting post) also expands -its single best keyword match through the same `post_lineage_edge` +its single best persisted-embedding match through the same `post_lineage_edge` neighbors, so an answer speaks to a connected timeline rather than one isolated snapshot -- it does not have a starting post to run the Knowledge Graph's indirect random-walk expansion from, only the lineage @@ -18,7 +18,6 @@ from __future__ import annotations import asyncio -import re from dataclasses import dataclass from datetime import date, datetime from typing import Any, Callable, Iterable @@ -27,6 +26,7 @@ import asyncpg from lineageweave.ask_time_axis import row_matches_time_range, time_axis_evidence_fact +from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient from lineageweave.image_content import ImageContentClient, NullImageContentClient from lineageweave.knowledge_graph import ( NODE_POST, @@ -44,12 +44,10 @@ normalize_chat_question, ) from lineageweave.post_content_normalization import normalize_post_body -from lineageweave.temporal_expressions import ( - TEMPORAL_STOPWORDS, - resolve_korean_relative_time, -) +from lineageweave.temporal_expressions import resolve_korean_relative_time from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.ontology import ontology_annotations @@ -168,7 +166,6 @@ async def _graph_facts_for_posts( ("source_project_name", "source project name"), ) -_GLOBAL_ASK_TERM_PATTERN = re.compile(r"[^\W_]+(?:-[^\W_]+)*", re.UNICODE) _POST_CHAT_SOURCE_LIMIT = 8 # Korean relative-time words ("어제", "오늘", ...) name a KST calendar day, @@ -393,6 +390,8 @@ async def gather_global_chat_sources( can_see_post: Callable[[asyncpg.Record], bool], authorized_corporate_entity_ids: Iterable[str] = (), vision_client: ImageContentClient | None = None, + embedding_client: EmbeddingClient | None = None, + embedding_model_code: str = "", *, question: str | None = None, limit: int = 4, @@ -413,133 +412,88 @@ async def gather_global_chat_sources( or no expression at all applies no date filter. Cited sources name which clock matched (ADR 0202). - The source set is intentionally bounded until retrieval/reranking is - needed for a much larger corpus; every selected body still uses the same - image normalization and persisted graph evidence as post-scoped chat. + Candidates are ranked by the maximum cosine similarity between the + question embedding and each post's persisted semantic-unit embeddings. + The embedding model and dimension must match exactly. An unavailable + channel or incomplete persisted vectors returns no source instead of + falling back to lexical matching. """ if limit <= 0: return [] if vision_client is None: vision_client = NullImageContentClient() - # A relative-time expression ("어제", "작년 이맘때쯤", ...) narrows the - # candidate window by event time (fallback: created_at) below; it must - # not also become a near-meaningless literal keyword search term - # (see TEMPORAL_STOPWORDS). + if embedding_client is None: + embedding_client = NullEmbeddingClient() resolved_time_range = resolve_korean_relative_time( question or "", today=today or _seoul_today() ) - search_terms = tuple( - dict.fromkeys( - token.casefold() - for token in _GLOBAL_ASK_TERM_PATTERN.findall(question or "") - if len(token) >= 2 - and token.casefold() - not in { - "which", - "what", - "where", - "when", - "who", - "why", - "how", - "the", - "this", - "that", - "posts", - "post", - "글", - "게시글", - "질문", - "관련", - "확인되는", - "핵심", - "사실", - "무엇", - "무엇인가요", - "인가요", - } - # A Korean particle (은/는/이/가/에/의/도/쯤/...) attaches directly - # to a time word with no space ("어제는", "지난주에"), so the - # tokenizer above yields one token that a bare `in` check against - # TEMPORAL_STOPWORDS never matches -- check by prefix instead. - and not any(token.startswith(stopword) for stopword in TEMPORAL_STOPWORDS) - ) - )[:8] - # A post whose title names the exact thing asked about is a far more - # specific match than one that only shares a generic term (a common - # word, or a hit buried in a 16KB body prefix); weighting every match - # equally and then falling back on created_at desc as the only - # tiebreak let recency crowd out relevance -- a year-old post whose - # title is an exact company-name match lost to four newer, only - # loosely related posts in a live reproduction of this bug. - _MATCH_WEIGHT = {"title": 3.0, "body": 1.0, "source_field": 1.0} - candidate_scores: dict[str, float] = {} - for term in search_terms: - candidate_rows = await conn.fetch( - """ - select post_id, matched_in - from ( - (select post_id, coalesce(event_occurred_at, created_at) as event_clock, - 'title' as matched_in - from source_post - where post_title ilike '%' || $1 || '%' - and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2) - and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3) - limit 32) - union all - (select post_id, coalesce(event_occurred_at, created_at) as event_clock, - 'body' as matched_in - from source_post - where lower(left(source_post_search_text(post_body), 16384)) - like '%' || lower($1) || '%' - and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2) - and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3) - limit 32) - union all - (select post_id, coalesce(event_occurred_at, created_at) as event_clock, - 'body' as matched_in - from source_post - where to_tsvector('simple', source_post_search_text(post_body)) - @@ plainto_tsquery('simple', $1) - and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2) - and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3) - limit 32) - union all - (select post_id, coalesce(event_occurred_at, created_at) as event_clock, - 'source_field' as matched_in - from source_post - where concat_ws(' ', source_system_code, source_record_key, - source_author_code, source_author_name, - source_company_code, source_company_name, - source_process_unit_code, source_process_unit_name, - source_sales_pool_code, source_sales_pool_name, - source_customer_code, source_customer_name, - source_project_code, source_project_name) - ilike '%' || $1 || '%' - and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2) - and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3) - limit 32) - ) matches - order by event_clock desc, post_id desc - limit 32 - """, - term, - resolved_time_range[0] if resolved_time_range else None, - resolved_time_range[1] if resolved_time_range else None, + if not (question and question.strip() and embedding_client.available and embedding_model_code): + return [] + try: + question_vector = await asyncio.to_thread(embedding_client.embed, question) + except (OSError, RuntimeError, ValueError): + return [] + if not question_vector: + return [] + question_norm = sum(value * value for value in question_vector) ** 0.5 + if question_norm == 0.0: + return [] + candidate_rows = await conn.fetch( + f""" + with question_vector as ( + select ordinality - 1 as dimension_index, dimension_value + from unnest($1::double precision[]) with ordinality + as vector(dimension_value, ordinality) + ), unit_similarity as ( + select unit.post_id, embedding.post_content_embedding_id, + sum(value.dimension_value * question.dimension_value) + / nullif( + sqrt(sum(value.dimension_value * value.dimension_value)) * $2, + 0 + ) as cosine_similarity + from source_post post + join post_content_unit unit on unit.post_id = post.post_id + join post_content_embedding embedding + on embedding.post_content_unit_id = unit.post_content_unit_id + join post_content_embedding_value value + on value.post_content_embedding_id = embedding.post_content_embedding_id + join question_vector question + on question.dimension_index = value.dimension_index + where embedding.embedding_model_code = $3 + and embedding.embedding_dimension_count = cardinality($1::double precision[]) + and (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($4::text[])) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + and ($5::date is null or (coalesce(post.event_occurred_at, post.created_at) at time zone 'Asia/Seoul')::date >= $5) + and ($6::date is null or (coalesce(post.event_occurred_at, post.created_at) at time zone 'Asia/Seoul')::date <= $6) + group by unit.post_id, embedding.post_content_embedding_id + having count(*) = cardinality($1::double precision[]) ) - for row in candidate_rows: - post_id = str(row["post_id"]) - candidate_scores[post_id] = candidate_scores.get(post_id, 0.0) + _MATCH_WEIGHT[row["matched_in"]] - candidate_ids = sorted(candidate_scores, key=lambda post_id: candidate_scores[post_id], reverse=True) - - # A keyword match only proves one post's text is relevant -- the - # account asking almost always wants to know what happened before and - # after that event too, not just this one snapshot. Expand the single - # best match through its direct Event Lineage neighbors + select similarity.post_id, max(similarity.cosine_similarity) as semantic_score, + max(coalesce(post.event_occurred_at, post.created_at)) as event_clock + from unit_similarity similarity + join source_post post on post.post_id = similarity.post_id + group by similarity.post_id + order by semantic_score desc, event_clock desc, similarity.post_id desc + limit $7 + """, + question_vector, + question_norm, + embedding_model_code, + list(authorized_corporate_entity_ids), + resolved_time_range[0] if resolved_time_range else None, + resolved_time_range[1] if resolved_time_range else None, + limit, + ) + candidate_ids = [str(row["post_id"]) for row in candidate_rows] + candidate_id_set = frozenset(candidate_ids) + + # One semantic match is still only one event snapshot. Expand the + # best-matching post through its direct Event Lineage neighbors # (`post_lineage_edge`, `lineageweave.reconstruct`'s output), mirroring # `find_linked_post_ids`'s `.direct` set used by the post-scoped chat - # flow. Only the top match is expanded -- expanding every keyword hit - # would let a loosely related term drag in an unrelated lineage chain. + # flow. Only the top match is expanded so lower-ranked semantic candidates + # cannot each pull a separate lineage chain into the bounded context. lineage_neighbor_ids: list[str] = [] lineage_anchor_id = candidate_ids[0] if candidate_ids else None if lineage_anchor_id: @@ -552,7 +506,7 @@ async def gather_global_chat_sources( { str(row["other_id"]) for row in lineage_rows - if str(row["other_id"]) not in candidate_scores + if str(row["other_id"]) not in candidate_id_set } ) candidate_ids = list( @@ -563,7 +517,7 @@ async def gather_global_chat_sources( lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) rows = await conn.fetch( - """ + f""" select post_id, post_title, post_body, visibility_code, corporate_entity_id, source_system_code, source_record_key, source_author_code, source_author_name, source_company_code, source_company_name, source_process_unit_code, @@ -574,6 +528,7 @@ async def gather_global_chat_sources( from source_post where (visibility_code = 'public' or corporate_entity_id::text = any($1::text[])) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} and ($4::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $4) and ($5::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $5) order by array_position($2::uuid[], post_id) nulls last, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index dee21f626..632034675 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -153,6 +153,11 @@ / "migrations" / "0201_lineage_pair_judgment.sql" ) +_TEPP_LINEAGE_ANCHOR_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0207_lineage_weight_tepp_anchor.sql" +) _LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -319,6 +324,7 @@ def seeded_db(demo_analyst_token): cur.execute(_INTERVAL_RELATION_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_UNION_MIGRATION.read_text()) cur.execute(_PAIR_JUDGMENT_MIGRATION.read_text()) + cur.execute(_TEPP_LINEAGE_ANCHOR_MIGRATION.read_text()) # Product reconstruction fails closed without an ACTIVATED # estimate (ADR 0200 points 1+3); this synthetic fixture set # under the authorized anchor stands in for a fast-mlsirm @@ -330,14 +336,14 @@ def seeded_db(demo_analyst_token): " anchor_method_code, source_snapshot_sha256, sample_pair_count, " " knowledge_cutoff) values " "('channel_set_deterministic', 'temporal', 0.5, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " "('channel_set_deterministic', 'secondary_key', 0.34, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " "('channel_set_deterministic', 'text', 0.16, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now())" + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z')" ) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) @@ -411,6 +417,45 @@ def seeded_db(demo_analyst_token): (subject,), ) account_id = cur.fetchone()[0] + cur.execute( + """ + with snapshot as ( + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (repeat('a', 64), 'synthetic-anchor-v1', + '2026-01-11T23:00:00Z', '2026-01-11T23:30:00Z') + returning analysis_source_snapshot_id + ), tepp_run as ( + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, + requested_by_account_id, idempotency_key, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + select analysis_source_snapshot_id, 'analysis_run_tepp', %s, + 'synthetic-lineage-anchor', '2026-01-12T00:00:00Z', + 'tepp-lineage-criterion-v1', repeat('b', 64), + repeat('c', 40), '2026-01-12T00:30:00Z' + from snapshot + returning analysis_run_id + ), tepp_result as ( + insert into analysis_run_tepp_result + (analysis_run_id, remote_run_id, result_json, result_sha256) + select analysis_run_id, 'synthetic-tepp-anchor', '{}'::jsonb, repeat('d', 64) + from tepp_run + returning analysis_run_id + ) + insert into lineage_weight_tepp_anchor + (estimation_run_id, tepp_analysis_run_id, anchor_kind_code, + anchor_contract_version, source_snapshot_sha256, knowledge_cutoff, + criterion_validity_status_code, validated_pair_count) + select '00000000-0000-0000-0000-000000000001', analysis_run_id, + 'lineage_pair_criterion', 1, repeat('a', 64), + '2026-01-12T00:00:00Z', 'accepted', 600 + from tepp_result + """, + (account_id,), + ) cur.execute( "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", (account_id, own_corp_id), diff --git a/backend/tests/test_similar_voc_api.py b/backend/tests/test_similar_voc_api.py new file mode 100644 index 000000000..f905d767d --- /dev/null +++ b/backend/tests/test_similar_voc_api.py @@ -0,0 +1,64 @@ +"""Focused API contract tests for live Similar VOC evidence.""" + +from contextlib import asynccontextmanager +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +from backend.app import main +from lineageweave.similar_voc import SimilarVocEvidence + + +class _Connection: + def __init__(self, rows): + self.rows = rows + + async def fetch(self, *_args): + return self.rows + + +class _Pool: + def __init__(self, rows): + self.connection = _Connection(rows) + + @asynccontextmanager + async def acquire(self): + yield self.connection + + +def test_similar_voc_adjudicates_visible_semantic_candidates(monkeypatch) -> None: + """The live endpoint omits an ABAC-hidden candidate and exposes no score.""" + focal = { + "post_id": "focal", + "post_title": "Current VOC", + "post_body": "Current seal failed.", + } + visible = { + "post_id": "prior", + "post_title": "Prior VOC", + "post_body": "Prior seal failed. Replaced gasket.", + "visibility_code": "public", + "corporate_entity_id": "corp-a", + "occurred_at": datetime(2026, 8, 20, tzinfo=timezone.utc), + } + hidden = {**visible, "post_id": "hidden", "visibility_code": "private", "corporate_entity_id": "corp-b"} + + async def load_visible_post(*_args): + return focal + + class Client: + def analyze(self, *_args): + return SimilarVocEvidence( + "prior", "Equivalent seal failure", "Current seal failed.", + "Prior seal failed.", None, ("Replaced gasket.",), + ) + + monkeypatch.setattr(main, "_load_visible_post", load_visible_post) + monkeypatch.setattr(main, "_similar_voc_client", Client) + account = SimpleNamespace(corporate_entity_ids={"corp-a"}) + + payload = asyncio.run(main.read_similar_voc("focal", account, _Pool([visible, hidden]))) + + assert [item["post_id"] for item in payload["items"]] == ["prior"] + assert "score" not in payload["items"][0] + assert payload["items"][0]["action_history"] == ("Replaced gasket.",) diff --git a/docs/adr/0047-global-ask-semantic-retrieval.md b/docs/adr/0047-global-ask-semantic-retrieval.md index 06d15fbcc..d7a0954b0 100644 --- a/docs/adr/0047-global-ask-semantic-retrieval.md +++ b/docs/adr/0047-global-ask-semantic-retrieval.md @@ -10,11 +10,19 @@ find a post while Ask Agent could not. ## Decision -Global Ask candidate retrieval searches the same authorized source context as -the board: raw source hints, source record identity, project mentions, stored -roles, cataloged Keyman mentions, title, and normalized body. The retrieved -posts carry their raw source fields and persisted project/role/Keyman facts -into the contextual-orchestrator prompt with column/table provenance. +Global Ask embeds the complete natural-language question once through +contextual-orchestrator and ranks authorized posts by the maximum raw cosine +similarity against their persisted semantic-unit embeddings. Query and unit +vectors must have the same configured embedding model and dimension. No token +extraction, keyword matching, lexical weighting, similarity threshold, or +locally invented channel weight participates in candidate selection. + +The retrieved posts carry their raw source fields and persisted +project/role/Keyman facts into the contextual-orchestrator prompt with +column/table provenance. These facts enrich grounded answering; they do not +become keyword retrieval signals. If the embedding channel or a complete +matching-model vector is unavailable, retrieval returns no evidence rather +than falling back to lexical search. Raw source fields remain `hint_only`; the prompt explicitly distinguishes them from resolved ontology assertions. The existing ABAC filter is applied before @@ -22,9 +30,10 @@ semantic evidence is loaded, and the bounded source limit remains in place. ## Consequences -- Ask Agent can answer evidence-grounded questions when the relevant project - or identity is not repeated in the body. +- Ask Agent retrieves by semantic-unit meaning without a keyword rule. - A source hint can retrieve a post but cannot silently bind a customer, project, PU, or Keyman. - The orchestrator receives more useful evidence while still receiving only authorized, bounded source documents. +- Missing semantic measurement fails closed and cannot silently change the + retrieval method. diff --git a/docs/adr/0090-global-ask-lineage-timeline-expansion.md b/docs/adr/0090-global-ask-lineage-timeline-expansion.md index da98d9388..606235bc5 100644 --- a/docs/adr/0090-global-ask-lineage-timeline-expansion.md +++ b/docs/adr/0090-global-ask-lineage-timeline-expansion.md @@ -8,7 +8,8 @@ ADR 0047 gave Global Ask's retrieve step the same source-context search surface as the board (raw source hints, project mentions, roles, Keyman -mentions, title, body). That step ranks and returns keyword-matched posts, +mentions, title, body). The current ADR 0047 revision ranks persisted +semantic-unit embeddings against the complete question, but it never touches `post_lineage_edge` -- the Event-Lineage relation `lineageweave.reconstruct` already persists, and the same relation the post-scoped chat flow (`gather_chat_sources`) already expands through for a @@ -30,7 +31,7 @@ it expands only the single top-ranked match through its direct set `find_linked_post_ids` already computes for the post-scoped flow. The expansion: -- Is bounded to the top match only. Expanding every keyword hit was +- Is bounded to the top match only. Expanding every semantic candidate was rejected -- a loosely related term matching a second post would drag an unrelated lineage chain into the model's context for no benefit. - Never bypasses ABAC. Lineage-neighbor ids are merged into the same @@ -38,7 +39,7 @@ expansion: runs over; nothing lineage-adjacent is shown without passing that check. - Is additive to the existing bounded source `limit`, not a replacement for it -- the limit grows by exactly the number of lineage neighbors - found, so lineage expansion cannot silently starve the keyword-matched + found, so lineage expansion cannot silently starve the semantically ranked candidates of their own slots. - Tags each expanded source with an explicit `Event Lineage: reconstructed timeline neighbor of post_id=...` evidence fact, and only when the @@ -52,12 +53,12 @@ sequence around it. ## Considered alternatives -- Expand every keyword-matched candidate's lineage neighbors, not just the +- Expand every semantically ranked candidate's lineage neighbors, not just the top one: rejected for the reason above -- unbounded relevance drift into the prompt. -- Increase `limit` and let the ranking naturally surface neighbors if they - also match the search terms: rejected -- a genuine lineage predecessor or - successor frequently shares no keyword with the question at all (a +- Increase `limit` and let the ranking naturally surface neighbors: rejected + -- a genuine lineage predecessor or successor can express a different event + in the sequence (a Kick-off Meeting and its follow-up rarely repeat the same terms), so ranking alone cannot be relied on to surface it. diff --git a/docs/adr/0150-korean-relative-time-retrieval.md b/docs/adr/0150-korean-relative-time-retrieval.md index 1e2d805b2..1630ab89c 100644 --- a/docs/adr/0150-korean-relative-time-retrieval.md +++ b/docs/adr/0150-korean-relative-time-retrieval.md @@ -8,11 +8,8 @@ A question like "어제 무슨 일이 있었나요?" ("what happened yesterday?") names a time window the reader already has in mind. Before this decision, -`gather_global_chat_sources`'s keyword retrieval (ADR 0047) had no way to -use that window: "어제" only ever became a literal search token against -post titles and bodies, indistinguishable from any other two-character -term. A fresh, unrelated post that happened to rank highest on unrelated -keyword overlap could outrank the post the reader actually meant. +`gather_global_chat_sources` had no way to use that window. A fresh, +unrelated post could outrank the post the reader actually meant. ## Decision @@ -29,16 +26,15 @@ retrieval behavior as finding no expression at all. `gather_global_chat_sources` applies the resolved window as an additional event-time bound on its final ABAC-filtered candidate query (ADR 0202: `coalesce(event_occurred_at, created_at)`), additive to the existing -keyword-match ranking -- it narrows the already-ranked candidate set, it -does not replace ranking with a date filter. Cited sources name which -clock matched. Matched temporal literals are excluded from keyword-term -extraction (`TEMPORAL_STOPWORDS`) so a resolved expression does not also -become a near-meaningless literal search term. +semantic-unit embedding ranking -- it narrows the already-ranked candidate +set, it does not replace ranking with a date filter. Cited sources name which +clock matched. The complete question is embedded once; no temporal-token +removal or keyword extraction occurs. ## Considered alternatives -- Send the raw question to an LLM to extract a date range: rejected for the - same reason ADR 0047's keyword step avoids ungrounded LLM inference at +- Send the raw question to an LLM to extract a date range: rejected because + ungrounded LLM inference at the retrieval boundary -- a hallucinated date range would silently narrow (or widen) the candidate set with no way for the reader to verify it, and every extra provider round-trip is retrieval latency the reader @@ -55,9 +51,9 @@ become a near-meaningless literal search term. term itself acting as retrieval noise. - The resolver is locale-specific (Korean only); a question in another supported UI locale (ADR on i18n scope, `frontend/src/i18n.ts`) that - names a relative time in that language still falls back to keyword-only - retrieval. Extending to additional locales is a follow-up, not required - by this decision. + names a relative time in that language receives semantic retrieval without + a date bound. Extending deterministic date resolution to additional locales + is a follow-up, not required by this decision. - `today` is always passed explicitly by the caller (server-local date); the resolver itself never reads the wall clock, keeping it a pure, trivially unit-testable function. diff --git a/docs/adr/0200-channel-weight-reconciliation.md b/docs/adr/0200-channel-weight-reconciliation.md index b2cbe1fdd..84712338d 100644 --- a/docs/adr/0200-channel-weight-reconciliation.md +++ b/docs/adr/0200-channel-weight-reconciliation.md @@ -109,9 +109,8 @@ argument. `estimation_method_code`, `estimator_version`, `anchor_method_code`, `source_snapshot_sha256`, `sample_pair_count`, `knowledge_cutoff`). The loader requires an exact active-channel match AND single-run - provenance integrity AND an authorized anchor method code - (`unanchored_internal_structure` joins the authorized set under - point 3's labeling duty). One migration with rollbacks lands the + provenance integrity AND the sole authorized anchor method code + (`tepp_lineage_criterion_v1`, per ADR 0205). One migration with rollbacks lands the union on whichever predecessor schema a database has. 5. **Queued judge scoring.** The llm channel's pair scoring moves to the repository's durable queue idiom (`post_content_queue` / diff --git a/docs/adr/0205-tepp-lineage-anchor.md b/docs/adr/0205-tepp-lineage-anchor.md index ac1a3a8c1..711027a86 100644 --- a/docs/adr/0205-tepp-lineage-anchor.md +++ b/docs/adr/0205-tepp-lineage-anchor.md @@ -33,6 +33,12 @@ vector activates only when one normalized `lineage_weight_tepp_anchor` row: cutoff, and validated pair count as every weight in the vector; and 4. matches the TEPP analysis run's immutable snapshot and cutoff exactly. +When that accepted artifact is persisted, the same transaction promotes only +the fast-mlsirm rows whose estimation-run identity, expected-information +method, snapshot, cutoff, and pair count exactly match the artifact. A partial +or mismatched candidate remains inactive; there is no operator-authored anchor +label or second promotion path. + The RFC 3339 request preserves the database cutoff's fractional-second precision; truncating it would make an otherwise valid exact anchor permanently unavailable. diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index ecbfade38..f8e79942d 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -65,7 +65,13 @@ provenance. source-supported improvement action. Its Dashboard flow is As-Is evidence to To-Be action: rebid history retrieval, originating-order/specification reverse tracing, repeated-issue grouping, and design-improvement return. - Similarity alone never establishes that two issues are the same type. + Similarity alone never establishes that two issues are the same type. The + per-post Similar VOC view uses visible `repeat_issue` classifications only + as a semantic candidate pool, then requires contextual-orchestrator to + adjudicate each pair with verbatim evidence from both records. Results are + displayed by source event time, not a similarity score. It does not reuse + Event Lineage channel weights, and it does not invoke RankWeave without a + separately authorized Similar-VOC measurement contract. 11. The Dashboard uses existing design tokens and native HTML controls. Tables and ordered journey steps remain usable without color, with visible focus, keyboard activation, responsive overflow, and reduced-motion support. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index adee0e193..870196c21 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,8 +1,9 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-25 15:40 KST. Candidate base is -> protected `main` `c168ad0016de9aa42a7a6f4136972e80121ef981`; this local -> branch is not release evidence. +> Dashboard delivery snapshot: 2026-08-25 18:00 KST. Protected `main` was +> `3d6d7188a3ae299ffef77eb991032268a4c2160d`; the stacked TEPP consumer base +> was `61fd631c7bb3c57113fd19763c2c43161eeb2824`. This local branch is not +> protected-main release evidence. ## Operations Dashboard PRD/TRD traceability @@ -13,11 +14,11 @@ | Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending | | Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision | -| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | API projection pending full journey UI | +| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API and ordered journey UI implemented; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | -| Natural-language Ask with evidence, report, alert, MCP | Existing Global Ask retrieval plus versioned delivery/resource contract | Candidate implementation; lexical retrieval replacement remains open | -| Similar VOC, customer cohort, prior action | Ontology/semantic evidence and governed similarity; source links | Candidate component; post-detail integration pending | -| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | In development; current unanchored vectors MUST remain inactive | +| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | +| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | +| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | TEPP PR #237 and consumer PR #606 define the contract; only exact accepted artifacts promote matching vectors; protected merge pending | ### Technical contract and flow @@ -48,13 +49,12 @@ snapshot. ### Exact open-PR boundary -At this snapshot there were 10 open PRs and 20 open issues. Exact heads: -`#602 36f05476`, `#600 cb5eff38`, `#588 6185f2ae`, `#582 cab04063`, -`#579 bfefe98e`, `#493 6fbc8660`, `#490 73413d0b`, `#482 6b9084b9`, -`#468 4f8305a8`, and `#387 3fab1f6a`. PR #387 retained a changes-requested -review; #600/#588/#582/#490/#482/#468 required review. These observations are -not merge readiness. Re-fetch exact heads, unresolved threads, checks, -approvals, rulesets, and merge SHA before any lifecycle claim. +At this snapshot there were 4 open PRs and 16 open issues. Exact heads were +`#606 61fd631c`, `#579 a8e9ef9e`, `#490 73413d0b`, and `#387 ab5cf345`. +PRs #606/#579/#490 were reported `DIRTY` against the advancing main branch; +#387 was `BLOCKED`. These observations are not merge readiness. Re-fetch exact +heads, unresolved threads, checks, approvals, rulesets, and merge SHA before +any lifecycle claim. > Audit snapshot: 2026-08-25 12:07 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, diff --git a/frontend/src/App.css b/frontend/src/App.css index d2ff10630..5d768f81e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1159,6 +1159,22 @@ color: var(--color-text-heading); } +.dashboard-period-form { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: var(--space-control-gap); + padding: var(--space-panel-block); +} + +.dashboard-period-form label { + display: grid; + gap: var(--space-control-gap); + font-weight: 700; +} + +.dashboard-period-form input { min-height: 44px; } + .operations-dashboard-heading { display: flex; align-items: end; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2bda1931d..bcf31dbfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,6 +34,7 @@ import { fetchPostSummary, fetchPostTickets, fetchPostVocEvidence, + fetchSimilarVoc, fetchPeriodComparison, fetchPeriodReportIndex, fetchPeriodReports, @@ -81,6 +82,7 @@ import { type RelatedNode, type RelatedNodeType, type VocEvidence, + type SimilarVocItem, fetchTenantConfig, } from "./api"; import { CitationChip } from "./components/CitationChip"; @@ -91,6 +93,7 @@ import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { OntologyExplorer } from "./components/OntologyExplorer"; import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PopupCloseButton } from "./components/PopupCloseButton"; +import { SimilarVocPanel } from "./components/SimilarVocPanel"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; import { OperationsDashboard } from "./components/OperationsDashboard"; @@ -1801,6 +1804,8 @@ function PostDetailPopup({ const [lineage, setLineage] = useState(null); const [affiliateTrees, setAffiliateTrees] = useState(null); const [vocEvidence, setVocEvidence] = useState(null); + const [similarVoc, setSimilarVoc] = useState(null); + const [similarVocError, setSimilarVocError] = useState(null); const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); @@ -1896,6 +1901,8 @@ function PostDetailPopup({ setLineage(null); setAffiliateTrees(null); setVocEvidence(null); + setSimilarVoc(null); + setSimilarVocError(null); setEvaluation(null); setFocusPerson(null); setFocusEntity(null); @@ -1952,6 +1959,12 @@ function PostDetailPopup({ .then((r) => setAffiliateTrees(r.trees)) .catch(() => setAffiliateTrees([])); fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); + fetchSimilarVoc(accessToken, postId) + .then((result) => setSimilarVoc(result.items)) + .catch(() => { + setSimilarVoc([]); + setSimilarVocError("유사 VOC 판정을 사용할 수 없습니다. 잠시 후 다시 확인하세요."); + }); return () => { disposed = true; if (contentPollTimer !== undefined) window.clearTimeout(contentPollTimer); @@ -2457,6 +2470,12 @@ function PostDetailPopup({ }} /> + onSelectPost?.(candidatePostId)} + /> +
@@ -4923,13 +4942,13 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean useLocale(); const [brandName, setBrandName] = useState("LineageWeave"); const auth = useAuth(); + const initialPostId = typeof window === "undefined" + ? null + : new URLSearchParams(window.location.search).get("post"); const [destination, setDestination] = useState( - import.meta.env.MODE === "test" ? "board" : "dashboard", + import.meta.env.MODE === "test" || initialPostId ? "board" : "dashboard", ); - const [postToOpen, setPostToOpen] = useState(() => { - if (typeof window === "undefined") return null; - return new URLSearchParams(window.location.search).get("post"); - }); + const [postToOpen, setPostToOpen] = useState(initialPostId); // Test-only compatibility for legacy analysis-panel coverage; this prop // never forces the panels open outside Vitest. In a real build the // advanced-review section (ADR 0037) is gated on PostList's own diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index d8020f7e1..3182afb04 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -1,11 +1,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { BackendError, fetchMe, updateTenantConfig } from "./api"; +import { BackendError, fetchMe, fetchOperationsDashboard, updateTenantConfig } from "./api"; afterEach(() => { vi.unstubAllGlobals(); }); describe("backendFetch provider-error boundary", () => { + it("binds the selected Dashboard period as inclusive API dates", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ cases: [] }), { headers: { "Content-Type": "application/json" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchOperationsDashboard("access-token", "2026-08-01", "2026-08-25"); + + expect(fetchMock.mock.calls[0][0]).toContain( + "/api/dashboard?period_start=2026-08-01&period_end=2026-08-25", + ); + }); + it("does not expose provider details from server failures", async () => { vi.stubGlobal( "fetch", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2d3be2a79..e173609d4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -69,8 +69,16 @@ export interface OperationsDashboardResponse { cases: OperationsDashboardCase[]; } -export function fetchOperationsDashboard(accessToken: string): Promise { - return backendFetch("/api/dashboard", accessToken); +export function fetchOperationsDashboard( + accessToken: string, + periodStart = "", + periodEnd = "", +): Promise { + const query = new URLSearchParams(); + if (periodStart) query.set("period_start", periodStart); + if (periodEnd) query.set("period_end", periodEnd); + const suffix = query.size ? `?${query}` : ""; + return backendFetch(`/api/dashboard${suffix}`, accessToken); } export interface PostFilterOption { @@ -738,6 +746,24 @@ export function fetchPostVocEvidence(accessToken: string, postId: string): Promi return backendFetch(`/api/posts/${postId}/voc-evidence`, accessToken); } +export interface SimilarVocItem { + post_id: string; + post_title: string; + issue_summary: string; + focal_evidence_text: string; + candidate_evidence_text: string; + customer_cohort_text: string | null; + action_history: string[]; + occurred_at: string; +} + +export function fetchSimilarVoc( + accessToken: string, + postId: string, +): Promise<{ items: SimilarVocItem[] }> { + return backendFetch(`/api/posts/${postId}/similar-voc`, accessToken); +} + export interface PersonRoleHistoryEntry { post_id: string; post_title: string; diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index b1c73bc75..afa029a40 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -11,19 +11,33 @@ type Props = { export function OperationsDashboard({ accessToken, externalOnly = false, onOpenPost }: Props) { const [data, setData] = useState(null); const [error, setError] = useState(false); + const [periodStart, setPeriodStart] = useState(""); + const [periodEnd, setPeriodEnd] = useState(""); + const [submittedPeriod, setSubmittedPeriod] = useState<[string, string]>(["", ""]); useEffect(() => { let active = true; setError(false); - fetchOperationsDashboard(accessToken) + setData(null); + fetchOperationsDashboard(accessToken, ...submittedPeriod) .then((value) => active && setData(value)) .catch(() => active && setError(true)); return () => { active = false; }; - }, [accessToken]); + }, [accessToken, submittedPeriod]); - if (error) return

운영 근거 Dashboard

Dashboard 근거를 불러오지 못했습니다. 잠시 후 다시 시도하세요.

; + if (error) return

운영 근거 Dashboard

Dashboard 근거를 불러오지 못했습니다.

; if (!data) return

Dashboard 근거를 불러오는 중입니다.

; - return ; + return <> +
{ + event.preventDefault(); + setSubmittedPeriod([periodStart, periodEnd]); + }}> + + + +
+ + ; } /** Renders a completed Dashboard response for runtime and Storybook scenes. */ diff --git a/frontend/src/components/SimilarVocPanel.css b/frontend/src/components/SimilarVocPanel.css index 5cc04a875..a7c411778 100644 --- a/frontend/src/components/SimilarVocPanel.css +++ b/frontend/src/components/SimilarVocPanel.css @@ -2,7 +2,7 @@ .similar-voc > header p { color: var(--color-text); } .similar-voc > ol { display: grid; gap: var(--space-panel-block); list-style: none; margin: 0; padding: 0; } .similar-voc article { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-panel); padding: var(--space-panel-block); } -.similar-voc-rank { color: var(--color-text); font-size: var(--font-size-badge); } +.similar-voc-time { color: var(--color-text); font-size: var(--font-size-badge); } .similar-voc blockquote { border-inline-start: 3px solid var(--color-accent); margin-inline: 0; padding-inline-start: var(--space-panel-block); } .similar-voc dl > div { display: grid; gap: var(--space-control-gap); grid-template-columns: minmax(6rem, 0.25fr) 1fr; } .similar-voc dt { font-weight: 700; } diff --git a/frontend/src/components/SimilarVocPanel.stories.tsx b/frontend/src/components/SimilarVocPanel.stories.tsx index 7fcc6c1e7..49ea34d5d 100644 --- a/frontend/src/components/SimilarVocPanel.stories.tsx +++ b/frontend/src/components/SimilarVocPanel.stories.tsx @@ -7,7 +7,10 @@ type Story = StoryObj; export const WithActionHistory: Story = { args: { items: [{ post_id: "synthetic-post-2", post_title: "합성 과거 VOC", issue_summary: "동일 씰 고장 유형", + focal_evidence_text: "인수 검사 중 씰 누설이 확인되었습니다.", candidate_evidence_text: "시험 중 씰 누설이 확인되었습니다.", customer_cohort_text: "합성 고객군 A", - action_history: ["가스켓을 교체하고 압력을 재검증했습니다."], fused_rank: 1, + action_history: ["가스켓을 교체하고 압력을 재검증했습니다."], occurred_at: "2026-08-20T09:00:00Z", }], onOpenPost: () => undefined } }; export const Empty: Story = { args: { items: [], onOpenPost: () => undefined } }; +export const Loading: Story = { args: { items: null, onOpenPost: () => undefined } }; +export const Unavailable: Story = { args: { items: [], error: "유사 VOC 판정을 사용할 수 없습니다. 잠시 후 다시 확인하세요.", onOpenPost: () => undefined } }; diff --git a/frontend/src/components/SimilarVocPanel.test.tsx b/frontend/src/components/SimilarVocPanel.test.tsx index 6cf3d0678..be5659820 100644 --- a/frontend/src/components/SimilarVocPanel.test.tsx +++ b/frontend/src/components/SimilarVocPanel.test.tsx @@ -8,8 +8,9 @@ describe("SimilarVocPanel", () => { const onOpenPost = vi.fn(); render(); expect(screen.getByText("가스켓을 교체하고 압력을 재검증했습니다.")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "근거 글 열기" })); diff --git a/frontend/src/components/SimilarVocPanel.tsx b/frontend/src/components/SimilarVocPanel.tsx index 85f917686..81963744f 100644 --- a/frontend/src/components/SimilarVocPanel.tsx +++ b/frontend/src/components/SimilarVocPanel.tsx @@ -1,38 +1,38 @@ import "./SimilarVocPanel.css"; -export type SimilarVocItem = { - post_id: string; - post_title: string; - issue_summary: string; - candidate_evidence_text: string; - customer_cohort_text: string | null; - action_history: string[]; - fused_rank: number; -}; +import type { SimilarVocItem } from "../api"; type Props = { - items: SimilarVocItem[]; + items: SimilarVocItem[] | null; + error?: string | null; onOpenPost: (postId: string) => void; }; /** Shows semantically adjudicated prior VOCs and their source-supported actions. */ -export function SimilarVocPanel({ items, onOpenPost }: Props) { +export function SimilarVocPanel({ items, error, onOpenPost }: Props) { return (

유사 VOC · 고객군 확인

같은 문제 유형으로 판정된 과거 근거와 조치 이력을 확인하세요.

- {items.length === 0 ? ( + {error ? ( +

{error}

+ ) : items === null ? ( +

유사 VOC 근거를 판정하고 있습니다.

+ ) : items.length === 0 ? (

같은 문제 유형으로 판정된 과거 VOC가 없습니다.

) : (
    {items.map((item) => (
  1. -

    추천 {item.fused_rank}

    +

    사건 시각 {new Date(item.occurred_at).toLocaleString()}

    {item.post_title}

    {item.issue_summary}

    +

    현재 글 근거

    +
    {item.focal_evidence_text}
    +

    과거 글 근거

    {item.candidate_evidence_text}
    고객군
    {item.customer_cohort_text ?? "동일 고객 근거 없음"}
    diff --git a/lineageweave/similar_voc.py b/lineageweave/similar_voc.py index 14fa3527a..b7c0c0d68 100644 --- a/lineageweave/similar_voc.py +++ b/lineageweave/similar_voc.py @@ -41,8 +41,8 @@ def analyze( If true, also return issue_summary, focal_evidence_text (verbatim from focal body), candidate_evidence_text (verbatim from candidate body), customer_cohort_text (string or null), and action_history (an array containing only source-supported past actions, each verbatim from -the candidate body). Customer cohort may be stated only when the records explicitly identify -the same cataloged or source customer; otherwise use null. +the candidate body). Customer cohort must be a verbatim span from either body and may be stated +only when both records explicitly identify the same source customer; otherwise use null. Focal title: {focal_title} Focal body: {focal_body} @@ -70,7 +70,14 @@ def parse_similar_voc_response( not isinstance(summary, str) or not summary.strip() or not isinstance(focal_evidence, str) or focal_evidence not in focal_body or not isinstance(candidate_evidence, str) or candidate_evidence not in candidate_body - or (cohort is not None and (not isinstance(cohort, str) or not cohort.strip())) + or ( + cohort is not None + and ( + not isinstance(cohort, str) + or not cohort.strip() + or (cohort not in focal_body and cohort not in candidate_body) + ) + ) or not isinstance(actions, list) or any(not isinstance(action, str) or action not in candidate_body for action in actions) ): diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 7cb829b06..86a94058f 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -304,6 +304,16 @@ async def execute(self, query: str, *args: object): ) ) assert sum("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) == 1 + promotion = next( + (args for query, args in conn.queries if "update lineage_channel_weight" in query), + None, + ) + assert promotion == ( + "018f47e7-7b5b-7cc0-98c6-15fdf9e3d9b1", + "ab" * 32, + cutoff, + 600, + ) conn = _Connection() envelope["result_schema_version"] = "consumer.private.v1" @@ -317,6 +327,7 @@ async def execute(self, query: str, *args: object): ) ) assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) + assert not any("update lineage_channel_weight" in query for query, _ in conn.queries) conn = _Connection() envelope["result_schema_version"] = "tepp.lineage_criterion_anchor.v1" diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index 9705e6c42..861864bcf 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -3,10 +3,24 @@ import asyncio from datetime import date, datetime, timezone -from backend.app.post_chat_ingestion import gather_global_chat_sources +from backend.app.post_chat_ingestion import gather_global_chat_sources as _gather_global_chat_sources from lineageweave.ask_time_axis import TIME_AXIS_CREATED, TIME_AXIS_EVENT +class _EmbeddingClient: + available = True + + def embed(self, _text: str) -> list[float]: + return [1.0, 0.0] + + +def gather_global_chat_sources(*args, **kwargs): + """Exercise Global Ask with an available deterministic semantic channel.""" + kwargs.setdefault("embedding_client", _EmbeddingClient()) + kwargs.setdefault("embedding_model_code", "test-embedding") + return _gather_global_chat_sources(*args, **kwargs) + + def test_global_sources_apply_visibility_before_normalization() -> None: rows = [ { @@ -42,6 +56,7 @@ async def fetch(self, query: str, *args): lambda row: row["visibility_code"] == "public" or row["corporate_entity_id"] == "corp-demo", {"corp-demo"}, + question="public evidence", ) ) @@ -50,7 +65,7 @@ async def fetch(self, query: str, *args): assert sources[1].post_body == "affiliated body" -def test_global_sources_prioritize_question_terms_and_bound_long_bodies() -> None: +def test_global_sources_use_semantic_rank_order_and_bound_long_bodies() -> None: rows = [ { "post_id": "newest-post", @@ -89,16 +104,15 @@ async def fetch(self, query: str, *args): source_query, source_args = next( (query, args) for query, args in calls if "array_position($2::uuid[], post_id)" in query ) - assert "to_tsvector('simple'" in candidate_query - assert candidate_args[0] == "mention" + assert "unit_similarity" in candidate_query + assert "to_tsvector" not in candidate_query + assert candidate_args[0] == [1.0, 0.0] + assert candidate_args[2] == "test-embedding" assert "array_position($2::uuid[], post_id)" in source_query assert source_args[2] == 8 - # Live bug (2026-08-19): a title match must outrank a body/source-field - # match regardless of discovery order -- "uam-post" matched in the - # title (higher weight) but was appended to candidate_rows after - # "newest-post" (a body match); the final candidate_ids array passed - # as $2 must still rank uam-post first. - assert list(source_args[1]) == ["uam-post", "newest-post"] + # The database returns candidates in cosine-rank order; no local lexical + # weights or reranking may alter that order. + assert list(source_args[1]) == ["newest-post", "uam-post"] assert sources[1].post_body.startswith("x" * 4000) assert "Source body truncated for Global Ask" in sources[1].post_body @@ -143,15 +157,7 @@ async def fetch(self, query: str, *args): assert sources[0].evidence_facts[-1].startswith("project: semantic project") -def test_global_sources_keep_hyphenated_source_codes_atomic() -> None: - """Live bug (2026-08-19): a hyphenated ERP-style job code such as - ``P41-4182-202405-0015`` used to be shredded into generic numeric - fragments (``P41``, ``4182``, ``202405``, ``0015``) by the search-term - tokenizer, so unrelated posts sharing only a short fragment (e.g. a - ``202405``-dated post from a different project) outranked or crowded - out the actual code match. The tokenizer must keep a hyphen-joined - code as one atomic search term. - """ +def test_global_sources_embed_identifier_question_without_tokenizing() -> None: calls: list[tuple[str, tuple[object, ...]]] = [] class FakeConnection: @@ -168,11 +174,12 @@ async def fetch(self, query: str, *args): ) ) - candidate_terms = [args[0] for query, args in calls if "matched_in" in query] - assert candidate_terms == ["p41-4182-202405-0015"] + candidate_calls = [(query, args) for query, args in calls if "unit_similarity" in query] + assert len(candidate_calls) == 1 + assert candidate_calls[0][1][0] == [1.0, 0.0] -def test_global_sources_keep_unicode_search_terms_for_localized_buyers() -> None: +def test_global_sources_embed_localized_question_once() -> None: calls: list[tuple[str, tuple[object, ...]]] = [] class FakeConnection: @@ -189,8 +196,8 @@ async def fetch(self, query: str, *args): ) ) - candidate_terms = [args[0] for query, args in calls if "matched_in" in query] - assert candidate_terms == ["无人机", "ドローン", "dự-án"] + candidate_calls = [(query, args) for query, args in calls if "unit_similarity" in query] + assert len(candidate_calls) == 1 def test_global_sources_keep_lineage_expansion_within_requested_limit() -> None: @@ -208,7 +215,7 @@ def test_global_sources_keep_lineage_expansion_within_requested_limit() -> None: class FakeConnection: async def fetch(self, query: str, *args): nonlocal source_call - if "matched_in" in query: + if "unit_similarity" in query: return [matched_row] if "post_lineage_edge" in query: return [{"other_id": post_id} for post_id in reversed(neighbor_ids)] @@ -270,9 +277,33 @@ async def fetch(self, _query: str, *_args): ) +def test_global_sources_fail_closed_when_embedding_is_unavailable() -> None: + class UnavailableEmbedding: + available = False + + def embed(self, _text: str) -> list[float]: + raise AssertionError("unavailable embedding must not be called") + + class FakeConnection: + async def fetch(self, _query: str, *_args): + raise AssertionError("lexical fallback must not query the corpus") + + sources = asyncio.run( + _gather_global_chat_sources( + FakeConnection(), + lambda _row: True, + question="semantic question", + embedding_client=UnavailableEmbedding(), + embedding_model_code="test-embedding", + ) + ) + + assert sources == [] + + def test_global_sources_expand_top_match_through_event_lineage() -> None: """Global Ask must speak to a connected timeline, not an isolated - snapshot -- expand the single top-ranked keyword match through its + snapshot -- expand the single top-ranked semantic match through its direct `post_lineage_edge` neighbors (`lineageweave.reconstruct`'s output), mirroring the post-scoped chat flow's `find_linked_post_ids`. """ @@ -294,7 +325,7 @@ def test_global_sources_expand_top_match_through_event_lineage() -> None: class FakeConnection: async def fetch(self, query: str, *args): - if "matched_in" in query: + if "unit_similarity" in query: return [matched_row] if "post_lineage_edge" in query: return [{"other_id": "event-1"}] @@ -341,7 +372,7 @@ def test_global_sources_do_not_leak_lineage_anchor_id_when_anchor_is_invisible() class FakeConnection: async def fetch(self, query: str, *args): - if "matched_in" in query: + if "unit_similarity" in query: return [matched_row] if "post_lineage_edge" in query: return [{"other_id": "visible-neighbor"}] @@ -396,20 +427,15 @@ async def fetch(self, query: str, *args): assert "at time zone 'Asia/Seoul'" in source_query assert source_args[3] == date(2026, 8, 21) assert source_args[4] == date(2026, 8, 21) - candidate_calls = [(query, args) for query, args in calls if "matched_in" in query] + candidate_calls = [(query, args) for query, args in calls if "unit_similarity" in query] assert all("event_clock" in query for query, _args in candidate_calls) assert all( - args[1:] == (date(2026, 8, 21), date(2026, 8, 21)) + args[4:6] == (date(2026, 8, 21), date(2026, 8, 21)) for _query, args in candidate_calls ) -def test_global_sources_drop_particle_attached_temporal_words_from_search_terms() -> None: - """Live bug: the temporal-stopword filter used exact match, so a Korean - particle attached directly to a time word ("어제는") tokenized as one - token and survived into keyword search, even though this is the - ordinary way to phrase the question (not an edge case). - """ +def test_global_sources_do_not_run_lexical_search_for_relative_time_question() -> None: calls: list[tuple[str, tuple[object, ...]]] = [] class FakeConnection: @@ -426,8 +452,10 @@ async def fetch(self, query: str, *args): ) ) - candidate_terms = [args[0] for query, args in calls if "matched_in" in query] - assert candidate_terms == ["무슨", "일이", "있었나요"] + candidate_queries = [query for query, _args in calls if "unit_similarity" in query] + assert len(candidate_queries) == 1 + assert "ilike" not in candidate_queries[0].lower() + assert "to_tsvector" not in candidate_queries[0].lower() def test_global_sources_bind_relative_time_to_event_clock_not_ingest_cluster( diff --git a/tests/test_similar_voc.py b/tests/test_similar_voc.py index ce1a7403c..3615346c2 100644 --- a/tests/test_similar_voc.py +++ b/tests/test_similar_voc.py @@ -27,6 +27,23 @@ def test_positive_similarity_requires_extractable_evidence() -> None: assert parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) is None +def test_customer_cohort_must_be_extractable() -> None: + """A customer cohort label cannot be invented outside either source body.""" + focal = "Customer cohort Alpha reported a seal failure." + candidate = "A seal failed during trial." + payload = { + "similar": True, + "issue_summary": "Equivalent seal failure", + "focal_evidence_text": focal, + "candidate_evidence_text": candidate, + "customer_cohort_text": "invented cohort", + "action_history": [], + } + assert parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) is None + payload["customer_cohort_text"] = "Customer cohort Alpha" + assert parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) is not None + + def test_ranking_uses_only_complete_supplied_measurement_weights() -> None: """RankWeave receives the exact persisted estimate and rejects a partial vector.""" captured = {} From bc182f1fc70760bd8855459f4859cab20e09deff Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:10:16 +0900 Subject: [PATCH 07/27] fix(tepp): promote only exact accepted lineage vectors --- backend/app/analysis_run_start.py | 28 ++- backend/tests/test_api.py | 180 +++++------------- .../adr/0200-channel-weight-reconciliation.md | 5 +- docs/adr/0205-tepp-lineage-anchor.md | 6 + tests/test_analysis_run_start.py | 16 +- 5 files changed, 91 insertions(+), 144 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index c7398198a..b0b37dc92 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -165,13 +165,7 @@ def configured_tepp_client(transport_url: str = "", api_key: str = "") -> TeppCl def transport(payload: dict[str, Any]) -> dict[str, Any]: """POST the TEPP wire payload to `url`, raising TeppNotAvailable on any transport failure.""" try: - headers = { - "idempotency-key": str(payload["idempotency_key"]), - "tepp-consumer": "lineageweave", - "tepp-contract-version": str(payload["contract_version"]), - } - if api_key.strip(): - headers["authorization"] = f"Bearer {api_key}" + headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} return post_json( url, payload, @@ -391,6 +385,21 @@ async def _persist_tepp_result( anchor["criterion_validity_status"], anchor["validated_pair_count"], ) + await conn.execute( + """ + update lineage_channel_weight + set anchor_method_code = 'tepp_lineage_criterion_v1' + where estimation_run_id = $1 + and estimation_method_code = 'mls2plm_expected_information' + and source_snapshot_sha256 = $2 + and knowledge_cutoff = $3 + and sample_pair_count = $4 + """, + estimation_run_id, + anchor["source_snapshot_sha256"], + anchor_cutoff, + anchor["validated_pair_count"], + ) except (asyncpg.PostgresError, TypeError, ValueError): return False return True @@ -813,9 +822,8 @@ async def deliver_queued_analysis_run( lock_conn = await asyncpg.connect(database_url) try: acquired = await lock_conn.fetchval( - "select pg_try_advisory_lock(" - "hashtextextended('lineageweave:analysis-run:' || $1, 0))", - analysis_run_id, + "select pg_try_advisory_lock(hashtextextended($1, 0))", + f"lineageweave:analysis-run:{analysis_run_id}", ) if not acquired: async with pool.acquire() as conn: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index b4d0aa342..632034675 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -12,15 +12,12 @@ from __future__ import annotations -import asyncio import math import os import uuid from contextlib import closing from pathlib import Path -from types import SimpleNamespace -import asyncpg import jwt import psycopg2 import pytest @@ -156,6 +153,11 @@ / "migrations" / "0201_lineage_pair_judgment.sql" ) +_TEPP_LINEAGE_ANCHOR_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0207_lineage_weight_tepp_anchor.sql" +) _LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -181,11 +183,6 @@ / "migrations" / "0165_global_ask_job.sql" ) -_GLOBAL_ASK_SCOPE_MIGRATION = ( - Path(__file__).resolve().parents[2] - / "migrations" - / "0203_global_ask_authorization_scope.sql" -) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -327,6 +324,7 @@ def seeded_db(demo_analyst_token): cur.execute(_INTERVAL_RELATION_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_UNION_MIGRATION.read_text()) cur.execute(_PAIR_JUDGMENT_MIGRATION.read_text()) + cur.execute(_TEPP_LINEAGE_ANCHOR_MIGRATION.read_text()) # Product reconstruction fails closed without an ACTIVATED # estimate (ADR 0200 points 1+3); this synthetic fixture set # under the authorized anchor stands in for a fast-mlsirm @@ -338,20 +336,19 @@ def seeded_db(demo_analyst_token): " anchor_method_code, source_snapshot_sha256, sample_pair_count, " " knowledge_cutoff) values " "('channel_set_deterministic', 'temporal', 0.5, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " "('channel_set_deterministic', 'secondary_key', 0.34, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " "('channel_set_deterministic', 'text', 0.16, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now())" + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z')" ) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) - cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) @@ -420,6 +417,45 @@ def seeded_db(demo_analyst_token): (subject,), ) account_id = cur.fetchone()[0] + cur.execute( + """ + with snapshot as ( + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (repeat('a', 64), 'synthetic-anchor-v1', + '2026-01-11T23:00:00Z', '2026-01-11T23:30:00Z') + returning analysis_source_snapshot_id + ), tepp_run as ( + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, + requested_by_account_id, idempotency_key, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + select analysis_source_snapshot_id, 'analysis_run_tepp', %s, + 'synthetic-lineage-anchor', '2026-01-12T00:00:00Z', + 'tepp-lineage-criterion-v1', repeat('b', 64), + repeat('c', 40), '2026-01-12T00:30:00Z' + from snapshot + returning analysis_run_id + ), tepp_result as ( + insert into analysis_run_tepp_result + (analysis_run_id, remote_run_id, result_json, result_sha256) + select analysis_run_id, 'synthetic-tepp-anchor', '{}'::jsonb, repeat('d', 64) + from tepp_run + returning analysis_run_id + ) + insert into lineage_weight_tepp_anchor + (estimation_run_id, tepp_analysis_run_id, anchor_kind_code, + anchor_contract_version, source_snapshot_sha256, knowledge_cutoff, + criterion_validity_status_code, validated_pair_count) + select '00000000-0000-0000-0000-000000000001', analysis_run_id, + 'lineage_pair_criterion', 1, repeat('a', 64), + '2026-01-12T00:00:00Z', 'accepted', 600 + from tepp_result + """, + (account_id,), + ) cur.execute( "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", (account_id, own_corp_id), @@ -711,76 +747,6 @@ def client(seeded_db): yield test_client -def test_keyverse_account_resolves_exact_scope_and_role_intersection( - monkeypatch: pytest.MonkeyPatch, seeded_db, demo_analyst_token -) -> None: - """Verified claims select one live DB affiliation; DB roles retain authority.""" - subject = jwt.decode(demo_analyst_token, options={"verify_signature": False})["sub"] - with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: - cur.execute( - "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " - "values (%s, 'workspace-a', 'Synthetic Workspace') returning process_unit_id", - (seeded_db["own_corp_id"],), - ) - process_unit_id = str(cur.fetchone()[0]) - cur.execute( - "update account_affiliation set process_unit_id = %s " - "where user_account_id = (select user_account_id from user_account where external_subject_id = %s)", - (process_unit_id, subject), - ) - cur.execute( - "insert into access_role (role_code, role_name) values ('member', 'Member') " - "returning access_role_id" - ) - role_id = cur.fetchone()[0] - cur.execute( - "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) " - "values ('permission', 'post_admin', 'Administer posts') " - "on conflict (lookup_code) do nothing" - ) - cur.execute( - "insert into role_permission (access_role_id, permission_code) values (%s, 'post_admin')", - (role_id,), - ) - cur.execute( - "insert into account_role_assignment (user_account_id, access_role_id) " - "select user_account_id, %s from user_account where external_subject_id = %s", - (role_id, subject), - ) - conn.commit() - - from backend.app import auth - - monkeypatch.setattr( - auth, - "load_settings", - lambda: SimpleNamespace(keyverse_claim_binding_required=True), - ) - monkeypatch.setattr( - auth, - "_decode_access_token", - lambda *_args: { - "sub": subject, - "org": "TEST-CORP", - "workspace": "workspace-a", - "role": ["member"], - }, - ) - - async def resolve_account(): - pool = await asyncpg.create_pool(seeded_db["dsn"], min_size=1, max_size=1) - try: - return await auth.get_current_account(SimpleNamespace(credentials="token"), pool) - finally: - await pool.close() - - account = asyncio.run(resolve_account()) - - assert account.corporate_entity_ids == frozenset({seeded_db["own_corp_id"]}) - assert account.process_unit_ids == frozenset({process_unit_id}) - assert account.permission_codes == frozenset({"post_admin"}) - - def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( client, demo_analyst_token, seeded_db ) -> None: @@ -4770,36 +4736,7 @@ def test_derive_commitment_requires_post_admin(client, demo_analyst_token, seede def test_calendar_is_empty_before_any_commitment(client, demo_analyst_token, seeded_db) -> None: response = client.get("/api/calendar", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 - payload = response.json() - assert payload["commitments"] == [] - assert payload["events"] == [] - assert payload["calendar_sources"]["naruon_available"] is False - assert "Connect the Naruon calendar projection" in payload["calendar_sources"]["naruon_next_action"] - assert "caldav_available" not in payload["calendar_sources"] - - -def test_calendar_window_requires_both_bounds(client, demo_analyst_token, seeded_db) -> None: - response = client.get( - "/api/calendar", - params={"window_start": "2026-08-25T00:00:00Z"}, - headers={"Authorization": f"Bearer {demo_analyst_token}"}, - ) - assert response.status_code == 422 - assert "together" in response.json()["detail"] - - -def test_calendar_does_not_treat_caldav_url_as_naruon( - client, demo_analyst_token, seeded_db, monkeypatch -) -> None: - monkeypatch.setenv("CALDAV_BASE_URL", "https://calendar.example/caldav/") - monkeypatch.delenv("NARUON_CALENDAR_BASE_URL", raising=False) - monkeypatch.delenv("NARUON_CALENDAR_SERVICE_TOKEN", raising=False) - response = client.get("/api/calendar", headers={"Authorization": f"Bearer {demo_analyst_token}"}) - assert response.status_code == 200 - payload = response.json() - assert payload["events"] == [] - assert payload["calendar_sources"]["naruon_available"] is False - assert "caldav_available" not in payload["calendar_sources"] + assert response.json()["commitments"] == [] def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( @@ -4836,7 +4773,6 @@ def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( assert commitments[0]["commitment_summary"] == "Send the revised quote" assert "visibility_code" not in commitments[0] assert "corporate_entity_id" not in commitments[0] - assert "process_unit_id" not in commitments[0] def test_calendar_keeps_real_ticket_when_demo_code_is_shared( @@ -5448,18 +5384,10 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert high_report["link_method"] == "fipc" assert high_report["selected_model"] in {"grm", "gpcm"} assert high_report["delta_mean_theta"] is None - assert all( - {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(member) - for member in high_report["members"] - ) leftover_kinds = {pair["pair_kind"] for pair in high_report.get("leftover_pairs", [])} assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", [])) - assert all( - {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(pair) - for pair in high_report.get("leftover_pairs", []) - ) assert all( "leftover_map_reconstruction" in pair for pair in high_report.get("leftover_pairs", []) @@ -5540,10 +5468,6 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in leftover_thread.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in leftover_thread.get("leftover_pairs", [])) - assert all( - {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(pair) - for pair in leftover_thread.get("leftover_pairs", []) - ) assert all( pair.get("leftover_map_reconstruction") is None or isinstance(pair["leftover_map_reconstruction"], (int, float)) diff --git a/docs/adr/0200-channel-weight-reconciliation.md b/docs/adr/0200-channel-weight-reconciliation.md index b2cbe1fdd..84712338d 100644 --- a/docs/adr/0200-channel-weight-reconciliation.md +++ b/docs/adr/0200-channel-weight-reconciliation.md @@ -109,9 +109,8 @@ argument. `estimation_method_code`, `estimator_version`, `anchor_method_code`, `source_snapshot_sha256`, `sample_pair_count`, `knowledge_cutoff`). The loader requires an exact active-channel match AND single-run - provenance integrity AND an authorized anchor method code - (`unanchored_internal_structure` joins the authorized set under - point 3's labeling duty). One migration with rollbacks lands the + provenance integrity AND the sole authorized anchor method code + (`tepp_lineage_criterion_v1`, per ADR 0205). One migration with rollbacks lands the union on whichever predecessor schema a database has. 5. **Queued judge scoring.** The llm channel's pair scoring moves to the repository's durable queue idiom (`post_content_queue` / diff --git a/docs/adr/0205-tepp-lineage-anchor.md b/docs/adr/0205-tepp-lineage-anchor.md index ac1a3a8c1..711027a86 100644 --- a/docs/adr/0205-tepp-lineage-anchor.md +++ b/docs/adr/0205-tepp-lineage-anchor.md @@ -33,6 +33,12 @@ vector activates only when one normalized `lineage_weight_tepp_anchor` row: cutoff, and validated pair count as every weight in the vector; and 4. matches the TEPP analysis run's immutable snapshot and cutoff exactly. +When that accepted artifact is persisted, the same transaction promotes only +the fast-mlsirm rows whose estimation-run identity, expected-information +method, snapshot, cutoff, and pair count exactly match the artifact. A partial +or mismatched candidate remains inactive; there is no operator-authored anchor +label or second promotion path. + The RFC 3339 request preserves the database cutoff's fractional-second precision; truncating it would make an otherwise valid exact anchor permanently unavailable. diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 3c442e9f8..86a94058f 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -70,10 +70,9 @@ def acquire(self): class LockConnection: closed = False - async def fetchval(self, query, run_id): + async def fetchval(self, query, lock_key): assert "pg_try_advisory_lock" in query - assert "lineageweave:analysis-run:" in query - assert run_id == "00000000-0000-0000-0000-000000000001" + assert lock_key.endswith("00000000-0000-0000-0000-000000000001") return True async def close(self): @@ -305,6 +304,16 @@ async def execute(self, query: str, *args: object): ) ) assert sum("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) == 1 + promotion = next( + (args for query, args in conn.queries if "update lineage_channel_weight" in query), + None, + ) + assert promotion == ( + "018f47e7-7b5b-7cc0-98c6-15fdf9e3d9b1", + "ab" * 32, + cutoff, + 600, + ) conn = _Connection() envelope["result_schema_version"] = "consumer.private.v1" @@ -318,6 +327,7 @@ async def execute(self, query: str, *args: object): ) ) assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) + assert not any("update lineage_channel_weight" in query for query, _ in conn.queries) conn = _Connection() envelope["result_schema_version"] = "tepp.lineage_criterion_anchor.v1" From 83c817aa2d8c41a3f27805a46e2e7bcfc364c081 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:10:54 +0900 Subject: [PATCH 08/27] Revert "fix(tepp): promote only exact accepted lineage vectors" This reverts commit bc182f1fc70760bd8855459f4859cab20e09deff. --- backend/app/analysis_run_start.py | 28 +-- backend/tests/test_api.py | 180 +++++++++++++----- .../adr/0200-channel-weight-reconciliation.md | 5 +- docs/adr/0205-tepp-lineage-anchor.md | 6 - tests/test_analysis_run_start.py | 16 +- 5 files changed, 144 insertions(+), 91 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index b0b37dc92..c7398198a 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -165,7 +165,13 @@ def configured_tepp_client(transport_url: str = "", api_key: str = "") -> TeppCl def transport(payload: dict[str, Any]) -> dict[str, Any]: """POST the TEPP wire payload to `url`, raising TeppNotAvailable on any transport failure.""" try: - headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} + headers = { + "idempotency-key": str(payload["idempotency_key"]), + "tepp-consumer": "lineageweave", + "tepp-contract-version": str(payload["contract_version"]), + } + if api_key.strip(): + headers["authorization"] = f"Bearer {api_key}" return post_json( url, payload, @@ -385,21 +391,6 @@ async def _persist_tepp_result( anchor["criterion_validity_status"], anchor["validated_pair_count"], ) - await conn.execute( - """ - update lineage_channel_weight - set anchor_method_code = 'tepp_lineage_criterion_v1' - where estimation_run_id = $1 - and estimation_method_code = 'mls2plm_expected_information' - and source_snapshot_sha256 = $2 - and knowledge_cutoff = $3 - and sample_pair_count = $4 - """, - estimation_run_id, - anchor["source_snapshot_sha256"], - anchor_cutoff, - anchor["validated_pair_count"], - ) except (asyncpg.PostgresError, TypeError, ValueError): return False return True @@ -822,8 +813,9 @@ async def deliver_queued_analysis_run( lock_conn = await asyncpg.connect(database_url) try: acquired = await lock_conn.fetchval( - "select pg_try_advisory_lock(hashtextextended($1, 0))", - f"lineageweave:analysis-run:{analysis_run_id}", + "select pg_try_advisory_lock(" + "hashtextextended('lineageweave:analysis-run:' || $1, 0))", + analysis_run_id, ) if not acquired: async with pool.acquire() as conn: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 632034675..b4d0aa342 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -12,12 +12,15 @@ from __future__ import annotations +import asyncio import math import os import uuid from contextlib import closing from pathlib import Path +from types import SimpleNamespace +import asyncpg import jwt import psycopg2 import pytest @@ -153,11 +156,6 @@ / "migrations" / "0201_lineage_pair_judgment.sql" ) -_TEPP_LINEAGE_ANCHOR_MIGRATION = ( - Path(__file__).resolve().parents[2] - / "migrations" - / "0207_lineage_weight_tepp_anchor.sql" -) _LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -183,6 +181,11 @@ / "migrations" / "0165_global_ask_job.sql" ) +_GLOBAL_ASK_SCOPE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0203_global_ask_authorization_scope.sql" +) _LEFTOVER_MAP_AXIS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -324,7 +327,6 @@ def seeded_db(demo_analyst_token): cur.execute(_INTERVAL_RELATION_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_UNION_MIGRATION.read_text()) cur.execute(_PAIR_JUDGMENT_MIGRATION.read_text()) - cur.execute(_TEPP_LINEAGE_ANCHOR_MIGRATION.read_text()) # Product reconstruction fails closed without an ACTIVATED # estimate (ADR 0200 points 1+3); this synthetic fixture set # under the authorized anchor stands in for a fast-mlsirm @@ -336,19 +338,20 @@ def seeded_db(demo_analyst_token): " anchor_method_code, source_snapshot_sha256, sample_pair_count, " " knowledge_cutoff) values " "('channel_set_deterministic', 'temporal', 0.5, " - " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " - " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " + " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " + " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " "('channel_set_deterministic', 'secondary_key', 0.34, " - " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " - " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " + " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " + " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " "('channel_set_deterministic', 'text', 0.16, " - " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " - " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z')" + " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " + " 'unanchored_internal_structure', repeat('a', 64), 600, now())" ) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) + cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) @@ -417,45 +420,6 @@ def seeded_db(demo_analyst_token): (subject,), ) account_id = cur.fetchone()[0] - cur.execute( - """ - with snapshot as ( - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (repeat('a', 64), 'synthetic-anchor-v1', - '2026-01-11T23:00:00Z', '2026-01-11T23:30:00Z') - returning analysis_source_snapshot_id - ), tepp_run as ( - insert into analysis_run - (analysis_source_snapshot_id, run_kind_code, - requested_by_account_id, idempotency_key, knowledge_cutoff, - configuration_schema_version, configuration_sha256, - code_revision_sha, requested_at) - select analysis_source_snapshot_id, 'analysis_run_tepp', %s, - 'synthetic-lineage-anchor', '2026-01-12T00:00:00Z', - 'tepp-lineage-criterion-v1', repeat('b', 64), - repeat('c', 40), '2026-01-12T00:30:00Z' - from snapshot - returning analysis_run_id - ), tepp_result as ( - insert into analysis_run_tepp_result - (analysis_run_id, remote_run_id, result_json, result_sha256) - select analysis_run_id, 'synthetic-tepp-anchor', '{}'::jsonb, repeat('d', 64) - from tepp_run - returning analysis_run_id - ) - insert into lineage_weight_tepp_anchor - (estimation_run_id, tepp_analysis_run_id, anchor_kind_code, - anchor_contract_version, source_snapshot_sha256, knowledge_cutoff, - criterion_validity_status_code, validated_pair_count) - select '00000000-0000-0000-0000-000000000001', analysis_run_id, - 'lineage_pair_criterion', 1, repeat('a', 64), - '2026-01-12T00:00:00Z', 'accepted', 600 - from tepp_result - """, - (account_id,), - ) cur.execute( "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", (account_id, own_corp_id), @@ -747,6 +711,76 @@ def client(seeded_db): yield test_client +def test_keyverse_account_resolves_exact_scope_and_role_intersection( + monkeypatch: pytest.MonkeyPatch, seeded_db, demo_analyst_token +) -> None: + """Verified claims select one live DB affiliation; DB roles retain authority.""" + subject = jwt.decode(demo_analyst_token, options={"verify_signature": False})["sub"] + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "values (%s, 'workspace-a', 'Synthetic Workspace') returning process_unit_id", + (seeded_db["own_corp_id"],), + ) + process_unit_id = str(cur.fetchone()[0]) + cur.execute( + "update account_affiliation set process_unit_id = %s " + "where user_account_id = (select user_account_id from user_account where external_subject_id = %s)", + (process_unit_id, subject), + ) + cur.execute( + "insert into access_role (role_code, role_name) values ('member', 'Member') " + "returning access_role_id" + ) + role_id = cur.fetchone()[0] + cur.execute( + "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) " + "values ('permission', 'post_admin', 'Administer posts') " + "on conflict (lookup_code) do nothing" + ) + cur.execute( + "insert into role_permission (access_role_id, permission_code) values (%s, 'post_admin')", + (role_id,), + ) + cur.execute( + "insert into account_role_assignment (user_account_id, access_role_id) " + "select user_account_id, %s from user_account where external_subject_id = %s", + (role_id, subject), + ) + conn.commit() + + from backend.app import auth + + monkeypatch.setattr( + auth, + "load_settings", + lambda: SimpleNamespace(keyverse_claim_binding_required=True), + ) + monkeypatch.setattr( + auth, + "_decode_access_token", + lambda *_args: { + "sub": subject, + "org": "TEST-CORP", + "workspace": "workspace-a", + "role": ["member"], + }, + ) + + async def resolve_account(): + pool = await asyncpg.create_pool(seeded_db["dsn"], min_size=1, max_size=1) + try: + return await auth.get_current_account(SimpleNamespace(credentials="token"), pool) + finally: + await pool.close() + + account = asyncio.run(resolve_account()) + + assert account.corporate_entity_ids == frozenset({seeded_db["own_corp_id"]}) + assert account.process_unit_ids == frozenset({process_unit_id}) + assert account.permission_codes == frozenset({"post_admin"}) + + def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( client, demo_analyst_token, seeded_db ) -> None: @@ -4736,7 +4770,36 @@ def test_derive_commitment_requires_post_admin(client, demo_analyst_token, seede def test_calendar_is_empty_before_any_commitment(client, demo_analyst_token, seeded_db) -> None: response = client.get("/api/calendar", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 - assert response.json()["commitments"] == [] + payload = response.json() + assert payload["commitments"] == [] + assert payload["events"] == [] + assert payload["calendar_sources"]["naruon_available"] is False + assert "Connect the Naruon calendar projection" in payload["calendar_sources"]["naruon_next_action"] + assert "caldav_available" not in payload["calendar_sources"] + + +def test_calendar_window_requires_both_bounds(client, demo_analyst_token, seeded_db) -> None: + response = client.get( + "/api/calendar", + params={"window_start": "2026-08-25T00:00:00Z"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 422 + assert "together" in response.json()["detail"] + + +def test_calendar_does_not_treat_caldav_url_as_naruon( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + monkeypatch.setenv("CALDAV_BASE_URL", "https://calendar.example/caldav/") + monkeypatch.delenv("NARUON_CALENDAR_BASE_URL", raising=False) + monkeypatch.delenv("NARUON_CALENDAR_SERVICE_TOKEN", raising=False) + response = client.get("/api/calendar", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + payload = response.json() + assert payload["events"] == [] + assert payload["calendar_sources"]["naruon_available"] is False + assert "caldav_available" not in payload["calendar_sources"] def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( @@ -4773,6 +4836,7 @@ def test_calendar_hides_other_corp_private_commitments_and_sorts_by_due_date( assert commitments[0]["commitment_summary"] == "Send the revised quote" assert "visibility_code" not in commitments[0] assert "corporate_entity_id" not in commitments[0] + assert "process_unit_id" not in commitments[0] def test_calendar_keeps_real_ticket_when_demo_code_is_shared( @@ -5384,10 +5448,18 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert high_report["link_method"] == "fipc" assert high_report["selected_model"] in {"grm", "gpcm"} assert high_report["delta_mean_theta"] is None + assert all( + {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(member) + for member in high_report["members"] + ) leftover_kinds = {pair["pair_kind"] for pair in high_report.get("leftover_pairs", [])} assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in high_report.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in high_report.get("leftover_pairs", [])) + assert all( + {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(pair) + for pair in high_report.get("leftover_pairs", []) + ) assert all( "leftover_map_reconstruction" in pair for pair in high_report.get("leftover_pairs", []) @@ -5468,6 +5540,10 @@ def test_seed_period_report_surfaces_on_get_reports(client, demo_analyst_token, assert leftover_kinds <= {"closest", "farthest"} assert all(pair["post_title"] for pair in leftover_thread.get("leftover_pairs", [])) assert all(pair["leftover_distance"] >= 0 for pair in leftover_thread.get("leftover_pairs", [])) + assert all( + {"visibility_code", "corporate_entity_id", "process_unit_id"}.isdisjoint(pair) + for pair in leftover_thread.get("leftover_pairs", []) + ) assert all( pair.get("leftover_map_reconstruction") is None or isinstance(pair["leftover_map_reconstruction"], (int, float)) diff --git a/docs/adr/0200-channel-weight-reconciliation.md b/docs/adr/0200-channel-weight-reconciliation.md index 84712338d..b2cbe1fdd 100644 --- a/docs/adr/0200-channel-weight-reconciliation.md +++ b/docs/adr/0200-channel-weight-reconciliation.md @@ -109,8 +109,9 @@ argument. `estimation_method_code`, `estimator_version`, `anchor_method_code`, `source_snapshot_sha256`, `sample_pair_count`, `knowledge_cutoff`). The loader requires an exact active-channel match AND single-run - provenance integrity AND the sole authorized anchor method code - (`tepp_lineage_criterion_v1`, per ADR 0205). One migration with rollbacks lands the + provenance integrity AND an authorized anchor method code + (`unanchored_internal_structure` joins the authorized set under + point 3's labeling duty). One migration with rollbacks lands the union on whichever predecessor schema a database has. 5. **Queued judge scoring.** The llm channel's pair scoring moves to the repository's durable queue idiom (`post_content_queue` / diff --git a/docs/adr/0205-tepp-lineage-anchor.md b/docs/adr/0205-tepp-lineage-anchor.md index 711027a86..ac1a3a8c1 100644 --- a/docs/adr/0205-tepp-lineage-anchor.md +++ b/docs/adr/0205-tepp-lineage-anchor.md @@ -33,12 +33,6 @@ vector activates only when one normalized `lineage_weight_tepp_anchor` row: cutoff, and validated pair count as every weight in the vector; and 4. matches the TEPP analysis run's immutable snapshot and cutoff exactly. -When that accepted artifact is persisted, the same transaction promotes only -the fast-mlsirm rows whose estimation-run identity, expected-information -method, snapshot, cutoff, and pair count exactly match the artifact. A partial -or mismatched candidate remains inactive; there is no operator-authored anchor -label or second promotion path. - The RFC 3339 request preserves the database cutoff's fractional-second precision; truncating it would make an otherwise valid exact anchor permanently unavailable. diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 86a94058f..3c442e9f8 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -70,9 +70,10 @@ def acquire(self): class LockConnection: closed = False - async def fetchval(self, query, lock_key): + async def fetchval(self, query, run_id): assert "pg_try_advisory_lock" in query - assert lock_key.endswith("00000000-0000-0000-0000-000000000001") + assert "lineageweave:analysis-run:" in query + assert run_id == "00000000-0000-0000-0000-000000000001" return True async def close(self): @@ -304,16 +305,6 @@ async def execute(self, query: str, *args: object): ) ) assert sum("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) == 1 - promotion = next( - (args for query, args in conn.queries if "update lineage_channel_weight" in query), - None, - ) - assert promotion == ( - "018f47e7-7b5b-7cc0-98c6-15fdf9e3d9b1", - "ab" * 32, - cutoff, - 600, - ) conn = _Connection() envelope["result_schema_version"] = "consumer.private.v1" @@ -327,7 +318,6 @@ async def execute(self, query: str, *args: object): ) ) assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) - assert not any("update lineage_channel_weight" in query for query, _ in conn.queries) conn = _Connection() envelope["result_schema_version"] = "tepp.lineage_criterion_anchor.v1" From d89e448e5945a0f252067f17bd139359f9aef7a6 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:16:14 +0900 Subject: [PATCH 09/27] fix(tepp): promote exact accepted lineage vectors --- backend/app/analysis_run_start.py | 15 +++++ backend/tests/test_api.py | 55 +++++++++++++++++-- .../adr/0200-channel-weight-reconciliation.md | 5 +- docs/adr/0205-tepp-lineage-anchor.md | 6 ++ tests/test_analysis_run_start.py | 11 ++++ 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index c7398198a..c08810078 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -391,6 +391,21 @@ async def _persist_tepp_result( anchor["criterion_validity_status"], anchor["validated_pair_count"], ) + await conn.execute( + """ + update lineage_channel_weight + set anchor_method_code = 'tepp_lineage_criterion_v1' + where estimation_run_id = $1 + and estimation_method_code = 'mls2plm_expected_information' + and source_snapshot_sha256 = $2 + and knowledge_cutoff = $3 + and sample_pair_count = $4 + """, + estimation_run_id, + anchor["source_snapshot_sha256"], + anchor_cutoff, + anchor["validated_pair_count"], + ) except (asyncpg.PostgresError, TypeError, ValueError): return False return True diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index b4d0aa342..69fb648aa 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -156,6 +156,9 @@ / "migrations" / "0201_lineage_pair_judgment.sql" ) +_TEPP_LINEAGE_ANCHOR_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0207_lineage_weight_tepp_anchor.sql" +) _LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -327,6 +330,7 @@ def seeded_db(demo_analyst_token): cur.execute(_INTERVAL_RELATION_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_UNION_MIGRATION.read_text()) cur.execute(_PAIR_JUDGMENT_MIGRATION.read_text()) + cur.execute(_TEPP_LINEAGE_ANCHOR_MIGRATION.read_text()) # Product reconstruction fails closed without an ACTIVATED # estimate (ADR 0200 points 1+3); this synthetic fixture set # under the authorized anchor stands in for a fast-mlsirm @@ -338,14 +342,14 @@ def seeded_db(demo_analyst_token): " anchor_method_code, source_snapshot_sha256, sample_pair_count, " " knowledge_cutoff) values " "('channel_set_deterministic', 'temporal', 0.5, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " "('channel_set_deterministic', 'secondary_key', 0.34, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now()), " + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z'), " "('channel_set_deterministic', 'text', 0.16, " - " '00000000-0000-0000-0000-000000000001', 'test_fixture', 'test', " - " 'unanchored_internal_structure', repeat('a', 64), 600, now())" + " '00000000-0000-0000-0000-000000000001', 'mls2plm_expected_information', 'test', " + " 'tepp_lineage_criterion_v1', repeat('a', 64), 600, '2026-01-12T00:00:00Z')" ) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) @@ -420,6 +424,45 @@ def seeded_db(demo_analyst_token): (subject,), ) account_id = cur.fetchone()[0] + cur.execute( + """ + with snapshot as ( + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (repeat('a', 64), 'synthetic-anchor-v1', + '2026-01-11T23:00:00Z', '2026-01-11T23:30:00Z') + returning analysis_source_snapshot_id + ), tepp_run as ( + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, + requested_by_account_id, idempotency_key, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + select analysis_source_snapshot_id, 'analysis_run_tepp', %s, + 'synthetic-lineage-anchor', '2026-01-12T00:00:00Z', + 'tepp-lineage-criterion-v1', repeat('b', 64), + repeat('c', 40), '2026-01-12T00:30:00Z' + from snapshot + returning analysis_run_id + ), tepp_result as ( + insert into analysis_run_tepp_result + (analysis_run_id, remote_run_id, result_json, result_sha256) + select analysis_run_id, 'synthetic-tepp-anchor', '{}'::jsonb, repeat('d', 64) + from tepp_run + returning analysis_run_id + ) + insert into lineage_weight_tepp_anchor + (estimation_run_id, tepp_analysis_run_id, anchor_kind_code, + anchor_contract_version, source_snapshot_sha256, knowledge_cutoff, + criterion_validity_status_code, validated_pair_count) + select '00000000-0000-0000-0000-000000000001', analysis_run_id, + 'lineage_pair_criterion', 1, repeat('a', 64), + '2026-01-12T00:00:00Z', 'accepted', 600 + from tepp_result + """, + (account_id,), + ) cur.execute( "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", (account_id, own_corp_id), diff --git a/docs/adr/0200-channel-weight-reconciliation.md b/docs/adr/0200-channel-weight-reconciliation.md index b2cbe1fdd..84712338d 100644 --- a/docs/adr/0200-channel-weight-reconciliation.md +++ b/docs/adr/0200-channel-weight-reconciliation.md @@ -109,9 +109,8 @@ argument. `estimation_method_code`, `estimator_version`, `anchor_method_code`, `source_snapshot_sha256`, `sample_pair_count`, `knowledge_cutoff`). The loader requires an exact active-channel match AND single-run - provenance integrity AND an authorized anchor method code - (`unanchored_internal_structure` joins the authorized set under - point 3's labeling duty). One migration with rollbacks lands the + provenance integrity AND the sole authorized anchor method code + (`tepp_lineage_criterion_v1`, per ADR 0205). One migration with rollbacks lands the union on whichever predecessor schema a database has. 5. **Queued judge scoring.** The llm channel's pair scoring moves to the repository's durable queue idiom (`post_content_queue` / diff --git a/docs/adr/0205-tepp-lineage-anchor.md b/docs/adr/0205-tepp-lineage-anchor.md index ac1a3a8c1..711027a86 100644 --- a/docs/adr/0205-tepp-lineage-anchor.md +++ b/docs/adr/0205-tepp-lineage-anchor.md @@ -33,6 +33,12 @@ vector activates only when one normalized `lineage_weight_tepp_anchor` row: cutoff, and validated pair count as every weight in the vector; and 4. matches the TEPP analysis run's immutable snapshot and cutoff exactly. +When that accepted artifact is persisted, the same transaction promotes only +the fast-mlsirm rows whose estimation-run identity, expected-information +method, snapshot, cutoff, and pair count exactly match the artifact. A partial +or mismatched candidate remains inactive; there is no operator-authored anchor +label or second promotion path. + The RFC 3339 request preserves the database cutoff's fractional-second precision; truncating it would make an otherwise valid exact anchor permanently unavailable. diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 3c442e9f8..8f508e954 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -305,6 +305,16 @@ async def execute(self, query: str, *args: object): ) ) assert sum("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) == 1 + promotion = next( + (args for query, args in conn.queries if "update lineage_channel_weight" in query), + None, + ) + assert promotion == ( + "018f47e7-7b5b-7cc0-98c6-15fdf9e3d9b1", + "ab" * 32, + cutoff, + 600, + ) conn = _Connection() envelope["result_schema_version"] = "consumer.private.v1" @@ -318,6 +328,7 @@ async def execute(self, query: str, *args: object): ) ) assert not any("lineage_weight_tepp_anchor" in query for query, _ in conn.queries) + assert not any("update lineage_channel_weight" in query for query, _ in conn.queries) conn = _Connection() envelope["result_schema_version"] = "tepp.lineage_criterion_anchor.v1" From 5c2a43064b32bf7e33b2bd28bf03c5175092d68f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:42:01 +0900 Subject: [PATCH 10/27] fix: bind semantic retrieval to resolved model --- backend/app/global_ask_queue.py | 11 ---------- backend/app/main.py | 11 +++++++--- backend/app/post_chat_ingestion.py | 6 ++++-- backend/tests/test_similar_voc_api.py | 18 ++++++++++++---- tests/test_global_ask_sources.py | 30 +++++++++++++++++++++++++-- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 7da346104..c7e570d81 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -215,7 +215,6 @@ async def compute_global_ask_answer( process_scope_limited: bool, chat_client: PostChatClient, embedding_client: EmbeddingClient | None = None, - embedding_model_code: str = "", ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -247,7 +246,6 @@ def can_see(row: asyncpg.Record) -> bool: question=question_text, today=today, embedding_client=embedding_client, - embedding_model_code=embedding_model_code, ) except Exception as exc: log_internal_fault("global_ask", exc) @@ -352,7 +350,6 @@ async def process_global_ask_job( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, - embedding_model_code: str = "", ) -> None: """Claim, answer, and settle one Ask job. @@ -400,7 +397,6 @@ async def process_global_ask_job( process_scope_limited=process_scope_limited, chat_client=chat_client, embedding_client=embedding_factory(), - embedding_model_code=embedding_model_code, ), timeout=JOB_DEADLINE_SECONDS, ) @@ -515,7 +511,6 @@ async def consume_global_ask_stream_once( last_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, - embedding_model_code: str = "", limiter: asyncio.Semaphore | None = None, tasks: set[asyncio.Task] | None = None, ) -> str: @@ -540,7 +535,6 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, ) else: await limiter.acquire() @@ -550,7 +544,6 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, limiter=limiter, ) ) @@ -567,7 +560,6 @@ async def _process_and_release( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient], - embedding_model_code: str, limiter: asyncio.Semaphore, ) -> None: """Run one dispatched job and free its concurrency slot afterwards.""" @@ -577,7 +569,6 @@ async def _process_and_release( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, ) finally: limiter.release() @@ -599,7 +590,6 @@ async def run_global_ask_worker( *, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, - embedding_model_code: str = "", ) -> None: """Run the at-least-once Ask consumer with periodic queued-row recovery.""" last_id = await _stream_tail(client) @@ -619,7 +609,6 @@ async def run_global_ask_worker( last_id=last_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, limiter=limiter, tasks=tasks, ) diff --git a/backend/app/main.py b/backend/app/main.py index f33ee3eee..d0d2aebca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -274,7 +274,6 @@ async def lifespan(app: FastAPI): timeout=load_settings().orchestrator_answer_timeout_seconds ), embedding_factory=_embedding_client, - embedding_model_code=settings.embedding_model, ) ) app.state.global_ask_worker = global_ask_worker @@ -1813,17 +1812,23 @@ async def read_similar_voc( rows = await conn.fetch( """ select post.post_id, post.post_title, post.post_body, - post.visibility_code, post.corporate_entity_id, + post.visibility_code, post.corporate_entity_id, post.process_unit_id, coalesce(post.event_occurred_at, post.created_at) as occurred_at from operations_case_classification classification join source_post post on post.post_id = classification.post_id where classification.case_kind_code = 'repeat_issue' and post.post_id <> $1 and post.post_body <> '' + and (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($3::text[]) = 0 + or post.process_unit_id::text = any($3::text[])))) and """ f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} " - "order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id", + "order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id limit 8", post_id, + list(account.corporate_entity_ids), + list(account.process_unit_ids), ) candidates = [row for row in rows if _can_see_post(account, row)] diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 2a051d22e..6497b3175 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -392,7 +392,6 @@ async def gather_global_chat_sources( authorized_process_unit_ids: Iterable[str] = (), vision_client: ImageContentClient | None = None, embedding_client: EmbeddingClient | None = None, - embedding_model_code: str = "", *, question: str | None = None, limit: int = 4, @@ -428,7 +427,7 @@ async def gather_global_chat_sources( resolved_time_range = resolve_korean_relative_time( question or "", today=today or _seoul_today() ) - if not (question and question.strip() and embedding_client.available and embedding_model_code): + if not (question and question.strip() and embedding_client.available): return [] try: question_vector = await asyncio.to_thread(embedding_client.embed, question) @@ -436,6 +435,9 @@ async def gather_global_chat_sources( return [] if not question_vector: return [] + embedding_model_code = embedding_client.resolved_model + if not embedding_model_code: + return [] question_norm = sum(value * value for value in question_vector) ** 0.5 if question_norm == 0.0: return [] diff --git a/backend/tests/test_similar_voc_api.py b/backend/tests/test_similar_voc_api.py index f905d767d..8ad9bdded 100644 --- a/backend/tests/test_similar_voc_api.py +++ b/backend/tests/test_similar_voc_api.py @@ -12,8 +12,12 @@ class _Connection: def __init__(self, rows): self.rows = rows + self.query = "" + self.args = () - async def fetch(self, *_args): + async def fetch(self, query, *args): + self.query = query + self.args = args return self.rows @@ -37,8 +41,9 @@ def test_similar_voc_adjudicates_visible_semantic_candidates(monkeypatch) -> Non "post_id": "prior", "post_title": "Prior VOC", "post_body": "Prior seal failed. Replaced gasket.", - "visibility_code": "public", + "visibility_code": "private", "corporate_entity_id": "corp-a", + "process_unit_id": "process-a", "occurred_at": datetime(2026, 8, 20, tzinfo=timezone.utc), } hidden = {**visible, "post_id": "hidden", "visibility_code": "private", "corporate_entity_id": "corp-b"} @@ -55,10 +60,15 @@ def analyze(self, *_args): monkeypatch.setattr(main, "_load_visible_post", load_visible_post) monkeypatch.setattr(main, "_similar_voc_client", Client) - account = SimpleNamespace(corporate_entity_ids={"corp-a"}) + account = SimpleNamespace( + corporate_entity_ids={"corp-a"}, process_unit_ids={"process-a"} + ) - payload = asyncio.run(main.read_similar_voc("focal", account, _Pool([visible, hidden]))) + pool = _Pool([visible, hidden]) + payload = asyncio.run(main.read_similar_voc("focal", account, pool)) assert [item["post_id"] for item in payload["items"]] == ["prior"] assert "score" not in payload["items"][0] assert payload["items"][0]["action_history"] == ("Replaced gasket.",) + assert "process_unit_id::text = any($3::text[])" in pool.connection.query + assert pool.connection.args == ("focal", ["corp-a"], ["process-a"]) diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index 356cd87ad..af1b52ae8 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -9,6 +9,7 @@ class _EmbeddingClient: available = True + resolved_model = "test-embedding" def embed(self, _text: str) -> list[float]: return [1.0, 0.0] @@ -17,7 +18,6 @@ def embed(self, _text: str) -> list[float]: def gather_global_chat_sources(*args, **kwargs): """Exercise Global Ask with an available deterministic semantic channel.""" kwargs.setdefault("embedding_client", _EmbeddingClient()) - kwargs.setdefault("embedding_model_code", "test-embedding") return _gather_global_chat_sources(*args, **kwargs) @@ -303,6 +303,7 @@ async def fetch(self, _query: str, *_args): def test_global_sources_fail_closed_when_embedding_is_unavailable() -> None: class UnavailableEmbedding: available = False + resolved_model = None def embed(self, _text: str) -> list[float]: raise AssertionError("unavailable embedding must not be called") @@ -317,7 +318,32 @@ async def fetch(self, _query: str, *_args): lambda _row: True, question="semantic question", embedding_client=UnavailableEmbedding(), - embedding_model_code="test-embedding", + ) + ) + + assert sources == [] + + +def test_global_sources_fail_closed_without_a_resolved_embedding_model() -> None: + """A vector without its orchestrator-resolved model cannot match persisted rows.""" + + class UnboundEmbedding: + available = True + resolved_model = None + + def embed(self, _text: str) -> list[float]: + return [1.0, 0.0] + + class FakeConnection: + async def fetch(self, _query: str, *_args): + raise AssertionError("an unbound vector must not query persisted embeddings") + + sources = asyncio.run( + _gather_global_chat_sources( + FakeConnection(), + lambda _row: True, + question="semantic question", + embedding_client=UnboundEmbedding(), ) ) From 002d1d5a3dc337094632e5bedff04a6b8764ac16 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:59:00 +0900 Subject: [PATCH 11/27] fix: bind semantic retrieval to resolved model and page VOC --- backend/app/global_ask_queue.py | 11 ----- backend/app/main.py | 25 ++++++++--- backend/app/post_chat_ingestion.py | 6 ++- backend/tests/test_similar_voc_api.py | 45 ++++++++++++++++++- .../adr/0206-evidence-operations-dashboard.md | 5 ++- frontend/src/App.tsx | 15 ++++++- frontend/src/api.ts | 6 ++- .../src/components/SimilarVocPanel.test.tsx | 5 ++- frontend/src/components/SimilarVocPanel.tsx | 4 +- tests/test_global_ask_sources.py | 3 +- 10 files changed, 97 insertions(+), 28 deletions(-) diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 7da346104..c7e570d81 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -215,7 +215,6 @@ async def compute_global_ask_answer( process_scope_limited: bool, chat_client: PostChatClient, embedding_client: EmbeddingClient | None = None, - embedding_model_code: str = "", ) -> dict[str, Any]: """Assemble one complete Ask answer payload from authorized evidence. @@ -247,7 +246,6 @@ def can_see(row: asyncpg.Record) -> bool: question=question_text, today=today, embedding_client=embedding_client, - embedding_model_code=embedding_model_code, ) except Exception as exc: log_internal_fault("global_ask", exc) @@ -352,7 +350,6 @@ async def process_global_ask_job( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, - embedding_model_code: str = "", ) -> None: """Claim, answer, and settle one Ask job. @@ -400,7 +397,6 @@ async def process_global_ask_job( process_scope_limited=process_scope_limited, chat_client=chat_client, embedding_client=embedding_factory(), - embedding_model_code=embedding_model_code, ), timeout=JOB_DEADLINE_SECONDS, ) @@ -515,7 +511,6 @@ async def consume_global_ask_stream_once( last_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, - embedding_model_code: str = "", limiter: asyncio.Semaphore | None = None, tasks: set[asyncio.Task] | None = None, ) -> str: @@ -540,7 +535,6 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, ) else: await limiter.acquire() @@ -550,7 +544,6 @@ async def consume_global_ask_stream_once( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, limiter=limiter, ) ) @@ -567,7 +560,6 @@ async def _process_and_release( job_id: str, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient], - embedding_model_code: str, limiter: asyncio.Semaphore, ) -> None: """Run one dispatched job and free its concurrency slot afterwards.""" @@ -577,7 +569,6 @@ async def _process_and_release( job_id=job_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, ) finally: limiter.release() @@ -599,7 +590,6 @@ async def run_global_ask_worker( *, chat_factory: Callable[[], PostChatClient], embedding_factory: Callable[[], EmbeddingClient] = NullEmbeddingClient, - embedding_model_code: str = "", ) -> None: """Run the at-least-once Ask consumer with periodic queued-row recovery.""" last_id = await _stream_tail(client) @@ -619,7 +609,6 @@ async def run_global_ask_worker( last_id=last_id, chat_factory=chat_factory, embedding_factory=embedding_factory, - embedding_model_code=embedding_model_code, limiter=limiter, tasks=tasks, ) diff --git a/backend/app/main.py b/backend/app/main.py index f33ee3eee..486a4347a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -221,6 +221,7 @@ _POST_READ = "post_read" _POST_ADMIN = "post_admin" +_SIMILAR_VOC_PAGE_SIZE = 8 @asynccontextmanager @@ -274,7 +275,6 @@ async def lifespan(app: FastAPI): timeout=load_settings().orchestrator_answer_timeout_seconds ), embedding_factory=_embedding_client, - embedding_model_code=settings.embedding_model, ) ) app.state.global_ask_worker = global_ask_worker @@ -1793,6 +1793,7 @@ async def _load_visible_post( @app.get("/api/posts/{post_id}/similar-voc") async def read_similar_voc( post_id: str, + offset: int = Query(0, ge=0), account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: @@ -1813,19 +1814,28 @@ async def read_similar_voc( rows = await conn.fetch( """ select post.post_id, post.post_title, post.post_body, - post.visibility_code, post.corporate_entity_id, + post.visibility_code, post.corporate_entity_id, post.process_unit_id, coalesce(post.event_occurred_at, post.created_at) as occurred_at from operations_case_classification classification join source_post post on post.post_id = classification.post_id where classification.case_kind_code = 'repeat_issue' and post.post_id <> $1 and post.post_body <> '' + and (post.visibility_code = 'public' + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($3::text[]) = 0 + or post.process_unit_id::text = any($3::text[])))) and """ f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} " - "order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id", + "order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id " + "offset $4 limit $5", post_id, + list(account.corporate_entity_ids), + list(account.process_unit_ids), + offset, + _SIMILAR_VOC_PAGE_SIZE + 1, ) - candidates = [row for row in rows if _can_see_post(account, row)] + candidates = [row for row in rows[:_SIMILAR_VOC_PAGE_SIZE] if _can_see_post(account, row)] async def _adjudicate(candidate: asyncpg.Record): with use_llm_metadata(build_post_llm_metadata(post_id, focal)): @@ -1855,7 +1865,12 @@ async def _adjudicate(candidate: asyncpg.Record): "occurred_at": candidate["occurred_at"].isoformat(), } ) - return {"items": items} + return { + "items": items, + "next_offset": offset + _SIMILAR_VOC_PAGE_SIZE + if len(rows) > _SIMILAR_VOC_PAGE_SIZE + else None, + } async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> str: diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 2a051d22e..6497b3175 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -392,7 +392,6 @@ async def gather_global_chat_sources( authorized_process_unit_ids: Iterable[str] = (), vision_client: ImageContentClient | None = None, embedding_client: EmbeddingClient | None = None, - embedding_model_code: str = "", *, question: str | None = None, limit: int = 4, @@ -428,7 +427,7 @@ async def gather_global_chat_sources( resolved_time_range = resolve_korean_relative_time( question or "", today=today or _seoul_today() ) - if not (question and question.strip() and embedding_client.available and embedding_model_code): + if not (question and question.strip() and embedding_client.available): return [] try: question_vector = await asyncio.to_thread(embedding_client.embed, question) @@ -436,6 +435,9 @@ async def gather_global_chat_sources( return [] if not question_vector: return [] + embedding_model_code = embedding_client.resolved_model + if not embedding_model_code: + return [] question_norm = sum(value * value for value in question_vector) ** 0.5 if question_norm == 0.0: return [] diff --git a/backend/tests/test_similar_voc_api.py b/backend/tests/test_similar_voc_api.py index f905d767d..c0d0df09a 100644 --- a/backend/tests/test_similar_voc_api.py +++ b/backend/tests/test_similar_voc_api.py @@ -39,6 +39,7 @@ def test_similar_voc_adjudicates_visible_semantic_candidates(monkeypatch) -> Non "post_body": "Prior seal failed. Replaced gasket.", "visibility_code": "public", "corporate_entity_id": "corp-a", + "process_unit_id": "unit-a", "occurred_at": datetime(2026, 8, 20, tzinfo=timezone.utc), } hidden = {**visible, "post_id": "hidden", "visibility_code": "private", "corporate_entity_id": "corp-b"} @@ -55,10 +56,50 @@ def analyze(self, *_args): monkeypatch.setattr(main, "_load_visible_post", load_visible_post) monkeypatch.setattr(main, "_similar_voc_client", Client) - account = SimpleNamespace(corporate_entity_ids={"corp-a"}) + account = SimpleNamespace(corporate_entity_ids={"corp-a"}, process_unit_ids={"unit-a"}) - payload = asyncio.run(main.read_similar_voc("focal", account, _Pool([visible, hidden]))) + payload = asyncio.run(main.read_similar_voc("focal", 0, account, _Pool([visible, hidden]))) assert [item["post_id"] for item in payload["items"]] == ["prior"] assert "score" not in payload["items"][0] assert payload["items"][0]["action_history"] == ("Replaced gasket.",) + assert payload["next_offset"] is None + + +def test_similar_voc_pages_orchestrator_work(monkeypatch) -> None: + """One request adjudicates only one bounded page and exposes continuation.""" + focal = {"post_id": "focal", "post_title": "Current", "post_body": "Current issue."} + + async def load_visible_post(*_args): + return focal + + calls: list[str] = [] + + class Client: + def analyze(self, _title, _body, candidate_id, _candidate_title, candidate_body): + calls.append(candidate_id) + return SimilarVocEvidence( + candidate_id, "Equivalent issue", "Current issue.", candidate_body, + None, (), + ) + + rows = [ + { + "post_id": f"prior-{index}", + "post_title": f"Prior {index}", + "post_body": f"Prior issue {index}.", + "visibility_code": "public", + "corporate_entity_id": "corp-a", + "process_unit_id": "unit-a", + "occurred_at": datetime(2026, 8, 20, tzinfo=timezone.utc), + } + for index in range(9) + ] + monkeypatch.setattr(main, "_load_visible_post", load_visible_post) + monkeypatch.setattr(main, "_similar_voc_client", Client) + account = SimpleNamespace(corporate_entity_ids={"corp-a"}, process_unit_ids={"unit-a"}) + + payload = asyncio.run(main.read_similar_voc("focal", 16, account, _Pool(rows))) + + assert len(calls) == 8 + assert payload["next_offset"] == 24 diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index f8e79942d..51dc6ae9b 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -71,7 +71,10 @@ provenance. adjudicate each pair with verbatim evidence from both records. Results are displayed by source event time, not a similarity score. It does not reuse Event Lineage channel weights, and it does not invoke RankWeave without a - separately authorized Similar-VOC measurement contract. + separately authorized Similar-VOC measurement contract. Candidate + adjudication is paged in eight-record resource batches with an explicit + continuation offset; the page boundary caps request fan-out but does not + discard older candidates or become a relevance threshold. 11. The Dashboard uses existing design tokens and native HTML controls. Tables and ordered journey steps remain usable without color, with visible focus, keyboard activation, responsive overflow, and reduced-motion support. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8031e9c6f..e17632d9c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1808,6 +1808,7 @@ function PostDetailPopup({ const [vocEvidence, setVocEvidence] = useState(null); const [similarVoc, setSimilarVoc] = useState(null); const [similarVocError, setSimilarVocError] = useState(null); + const [similarVocNextOffset, setSimilarVocNextOffset] = useState(null); const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); @@ -1908,6 +1909,7 @@ function PostDetailPopup({ setVocEvidence(null); setSimilarVoc(null); setSimilarVocError(null); + setSimilarVocNextOffset(null); setEvaluation(null); setFocusPerson(null); setFocusEntity(null); @@ -1965,7 +1967,10 @@ function PostDetailPopup({ .catch(() => setAffiliateTrees([])); fetchPostVocEvidence(accessToken, postId).then(setVocEvidence).catch(() => setVocEvidence(null)); fetchSimilarVoc(accessToken, postId) - .then((result) => setSimilarVoc(result.items)) + .then((result) => { + setSimilarVoc(result.items); + setSimilarVocNextOffset(result.next_offset); + }) .catch(() => { setSimilarVoc([]); setSimilarVocError("유사 VOC 판정을 사용할 수 없습니다. 잠시 후 다시 확인하세요."); @@ -2479,6 +2484,14 @@ function PostDetailPopup({ items={similarVoc} error={similarVocError} onOpenPost={(candidatePostId) => onSelectPost?.(candidatePostId)} + onLoadMore={similarVocNextOffset === null ? null : () => { + fetchSimilarVoc(accessToken, postId, similarVocNextOffset) + .then((result) => { + setSimilarVoc((current) => [...(current ?? []), ...result.items]); + setSimilarVocNextOffset(result.next_offset); + }) + .catch(() => setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요.")); + }} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 70c83f503..e13ea5c2b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -770,8 +770,10 @@ export interface SimilarVocItem { export function fetchSimilarVoc( accessToken: string, postId: string, -): Promise<{ items: SimilarVocItem[] }> { - return backendFetch(`/api/posts/${postId}/similar-voc`, accessToken); + offset = 0, +): Promise<{ items: SimilarVocItem[]; next_offset: number | null }> { + const query = offset ? `?offset=${offset}` : ""; + return backendFetch(`/api/posts/${postId}/similar-voc${query}`, accessToken); } export interface PersonRoleHistoryEntry { diff --git a/frontend/src/components/SimilarVocPanel.test.tsx b/frontend/src/components/SimilarVocPanel.test.tsx index be5659820..12573ba9a 100644 --- a/frontend/src/components/SimilarVocPanel.test.tsx +++ b/frontend/src/components/SimilarVocPanel.test.tsx @@ -6,15 +6,18 @@ import { SimilarVocPanel } from "./SimilarVocPanel"; describe("SimilarVocPanel", () => { it("opens a cited prior VOC and shows its action history", async () => { const onOpenPost = vi.fn(); + const onLoadMore = vi.fn(); render(); + }]} onOpenPost={onOpenPost} onLoadMore={onLoadMore} />); expect(screen.getByText("가스켓을 교체하고 압력을 재검증했습니다.")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "근거 글 열기" })); expect(onOpenPost).toHaveBeenCalledWith("post-2"); + await userEvent.click(screen.getByRole("button", { name: "이전 VOC 더 보기" })); + expect(onLoadMore).toHaveBeenCalledOnce(); }); it("explains an empty semantic result", () => { diff --git a/frontend/src/components/SimilarVocPanel.tsx b/frontend/src/components/SimilarVocPanel.tsx index 81963744f..fbd575804 100644 --- a/frontend/src/components/SimilarVocPanel.tsx +++ b/frontend/src/components/SimilarVocPanel.tsx @@ -6,10 +6,11 @@ type Props = { items: SimilarVocItem[] | null; error?: string | null; onOpenPost: (postId: string) => void; + onLoadMore?: (() => void) | null; }; /** Shows semantically adjudicated prior VOCs and their source-supported actions. */ -export function SimilarVocPanel({ items, error, onOpenPost }: Props) { +export function SimilarVocPanel({ items, error, onOpenPost, onLoadMore }: Props) { return (
    @@ -44,6 +45,7 @@ export function SimilarVocPanel({ items, error, onOpenPost }: Props) { ))}
)} + {items?.length && onLoadMore ? : null}
); } diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index 356cd87ad..e63d66981 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -9,6 +9,7 @@ class _EmbeddingClient: available = True + resolved_model = "test-embedding" def embed(self, _text: str) -> list[float]: return [1.0, 0.0] @@ -17,7 +18,6 @@ def embed(self, _text: str) -> list[float]: def gather_global_chat_sources(*args, **kwargs): """Exercise Global Ask with an available deterministic semantic channel.""" kwargs.setdefault("embedding_client", _EmbeddingClient()) - kwargs.setdefault("embedding_model_code", "test-embedding") return _gather_global_chat_sources(*args, **kwargs) @@ -317,7 +317,6 @@ async def fetch(self, _query: str, *_args): lambda _row: True, question="semantic question", embedding_client=UnavailableEmbedding(), - embedding_model_code="test-embedding", ) ) From 08a689a7a48c919a99faf6952d7ef8cc2918ac93 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:00:53 +0900 Subject: [PATCH 12/27] fix(ui): preserve settled calendar status semantics --- frontend/src/App.test.tsx | 3 +++ frontend/src/components/WorkspaceCalendar.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1fb7233fd..0e07764cb 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1160,6 +1160,9 @@ describe("App, authenticated", () => { ); } const postOneUrl = new URL(url, "https://backend.test"); + if (postOneUrl.pathname === "/api/posts/post-1/similar-voc") { + return Promise.resolve(jsonResponse({ items: [] })); + } if (postOneUrl.pathname === "/api/posts/post-1") { const asOf = postOneUrl.searchParams.get("as_of"); return postOneReady.then(() => diff --git a/frontend/src/components/WorkspaceCalendar.tsx b/frontend/src/components/WorkspaceCalendar.tsx index 5f2631f39..0b37ae2dc 100644 --- a/frontend/src/components/WorkspaceCalendar.tsx +++ b/frontend/src/components/WorkspaceCalendar.tsx @@ -33,7 +33,7 @@ export function WorkspaceCalendar({

{t("Observed calendar events")}

{events.length === 0 ? ( -

+

{naruonAvailable ? t("No observed calendar events are available.") : failClosedCopy} From 718aa1f43944c64512769b8eb550aa20f46c9596 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:28:46 +0900 Subject: [PATCH 13/27] fix(lineage): preserve focused isolation reason --- frontend/src/App.tsx | 35 ++++++++++++++++++++++++++++------- frontend/src/api.ts | 9 ++++++++- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 447941e41..5abf3634c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -489,11 +489,17 @@ function EventLineageSection({ const scoped = graph ? subgraphForPost(graph, postId) : { nodes: [], edges: [] }; const hasLinks = lineage.direct.length > 0 || lineage.indirect.length > 0; if (scoped.nodes.length === 0) { + const isolationMessage = + graph.isolation_reason === "comparison_candidates_available" + ? t("Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.") + : graph.isolation_reason === "no_comparison_group" + ? t("No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.") + : t("No linked posts yet."); return (

{hasLinks ? t("The linked records are listed above. The graph is not available for this view.") - : t("No linked posts yet.")} + : isolationMessage}

); } @@ -1809,6 +1815,8 @@ function PostDetailPopup({ const [similarVoc, setSimilarVoc] = useState(null); const [similarVocError, setSimilarVocError] = useState(null); const [similarVocNextOffset, setSimilarVocNextOffset] = useState(null); + const [similarVocLoadingMore, setSimilarVocLoadingMore] = useState(false); + const similarVocLoadingMoreRef = useRef(false); const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); @@ -1910,6 +1918,8 @@ function PostDetailPopup({ setSimilarVoc(null); setSimilarVocError(null); setSimilarVocNextOffset(null); + setSimilarVocLoadingMore(false); + similarVocLoadingMoreRef.current = false; setEvaluation(null); setFocusPerson(null); setFocusEntity(null); @@ -2484,13 +2494,22 @@ function PostDetailPopup({ items={similarVoc} error={similarVocError} onOpenPost={(candidatePostId) => onSelectPost?.(candidatePostId)} + loadingMore={similarVocLoadingMore} onLoadMore={similarVocNextOffset === null ? null : () => { + if (similarVocLoadingMoreRef.current) return; + similarVocLoadingMoreRef.current = true; + setSimilarVocLoadingMore(true); + setSimilarVocError(null); fetchSimilarVoc(accessToken, postId, similarVocNextOffset) .then((result) => { setSimilarVoc((current) => [...(current ?? []), ...result.items]); setSimilarVocNextOffset(result.next_offset); }) - .catch(() => setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요.")); + .catch(() => setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요.")) + .finally(() => { + similarVocLoadingMoreRef.current = false; + setSimilarVocLoadingMore(false); + }); }} /> @@ -4826,13 +4845,15 @@ function AskAgentPanel({ {answer.answer_text ?

{answer.answer_text}

: null} {answer.next_action ?

{t(answer.next_action)}

: null} {answer.delivery ? ( -
); From 82fd36f82c3e0fe3a753ddd83ae3e5a659eb5dd4 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:59:03 +0900 Subject: [PATCH 21/27] fix(ui): drop stale similar VOC pages after navigation --- frontend/src/App.test.tsx | 36 ++++++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 11 ++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e02a25221..ac8088d72 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3092,6 +3092,42 @@ describe("App, authenticated", () => { expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument(); }); + it("drops a prior post's in-flight similar-VOC page after navigation", async () => { + const backend = stubBackend(); + const original = backend.getMockImplementation()!; + let releasePage!: (response: Response) => void; + const deferredPage = new Promise((resolve) => { releasePage = resolve; }); + backend.mockImplementation((...args) => { + const requestUrl = new URL(String(args[0]), "https://backend.test"); + if (requestUrl.pathname === "/api/posts/post-1/similar-voc") { + if (requestUrl.searchParams.get("offset") === "50") return deferredPage; + return Promise.resolve(jsonResponse({ + items: [{ + post_id: "prior-1", post_title: "Prior evidence", issue_summary: "Prior issue", + focal_evidence_text: "Current evidence", candidate_evidence_text: "Prior evidence", + customer_cohort_text: null, action_history: [], occurred_at: "2025-12-01T00:00:00Z", + }], + next_offset: 50, + })); + } + return original(...args); + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); + await userEvent.click(await screen.findByRole("button", { name: "이전 VOC 더 보기" })); + await userEvent.click((await screen.findAllByLabelText("Open post: Linked post"))[0]); + await screen.findByText("The evidence panel should show exactly this text."); + releasePage(jsonResponse({ + items: [{ + post_id: "stale-prior", post_title: "Stale prior VOC", issue_summary: "Stale issue", + focal_evidence_text: "Stale current", candidate_evidence_text: "Stale prior", + customer_cohort_text: null, action_history: [], occurred_at: "2025-11-01T00:00:00Z", + }], + next_offset: null, + })); + await waitFor(() => expect(screen.queryByText("Stale prior VOC")).not.toBeInTheDocument()); + }, 15_000); + it("opens an accepted ranking hit without inventing a fused score", async () => { stubBackend({ rankings: { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 394ec1af3..921c04672 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1817,6 +1817,8 @@ function PostDetailPopup({ const [similarVocNextOffset, setSimilarVocNextOffset] = useState(null); const [similarVocLoadingMore, setSimilarVocLoadingMore] = useState(false); const similarVocLoadingMoreRef = useRef(false); + const similarVocScopeRef = useRef({ postId }); + if (similarVocScopeRef.current.postId !== postId) similarVocScopeRef.current = { postId }; const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); @@ -2499,16 +2501,23 @@ function PostDetailPopup({ loadingMore={similarVocLoadingMore} onLoadMore={similarVocNextOffset === null ? null : () => { if (similarVocLoadingMoreRef.current) return; + const requestScope = similarVocScopeRef.current; similarVocLoadingMoreRef.current = true; setSimilarVocLoadingMore(true); setSimilarVocError(null); fetchSimilarVoc(accessToken, postId, similarVocNextOffset) .then((result) => { + if (similarVocScopeRef.current !== requestScope) return; setSimilarVoc((current) => [...(current ?? []), ...result.items]); setSimilarVocNextOffset(result.next_offset); }) - .catch(() => setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요.")) + .catch(() => { + if (similarVocScopeRef.current === requestScope) { + setSimilarVocError("이전 VOC를 더 불러오지 못했습니다. 다시 시도하세요."); + } + }) .finally(() => { + if (similarVocScopeRef.current !== requestScope) return; similarVocLoadingMoreRef.current = false; setSimilarVocLoadingMore(false); }); From 5abc5dd7fd425c1016d15251b3ef2dc132e85516 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:21:05 +0900 Subject: [PATCH 22/27] test: type similar VOC fetch fallback --- frontend/src/App.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ac8088d72..e311d5f02 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3094,7 +3094,10 @@ describe("App, authenticated", () => { it("drops a prior post's in-flight similar-VOC page after navigation", async () => { const backend = stubBackend(); - const original = backend.getMockImplementation()!; + const original = backend.getMockImplementation() as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; let releasePage!: (response: Response) => void; const deferredPage = new Promise((resolve) => { releasePage = resolve; }); backend.mockImplementation((...args) => { @@ -3110,7 +3113,7 @@ describe("App, authenticated", () => { next_offset: 50, })); } - return original(...args); + return original(args[0] as RequestInfo | URL, args[1] as RequestInit | undefined); }); render(); await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); From 801f6aa47f567649785a9da79c15eedb80d7ef4e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:25:05 +0900 Subject: [PATCH 23/27] feat(dashboard): cite linked evidence posts --- backend/app/operations_case_ingestion.py | 12 ++-- backend/app/operations_dashboard.py | 6 +- backend/app/post_content_worker.py | 46 ++++++++++++- .../adr/0206-evidence-operations-dashboard.md | 7 ++ frontend/src/api.ts | 2 + .../OperationsDashboard.stories.tsx | 10 +-- .../components/OperationsDashboard.test.tsx | 10 +-- .../src/components/OperationsDashboard.tsx | 4 +- lineageweave/operations_case_analysis.py | 65 ++++++++++++++----- .../0209_operations_case_evidence_source.sql | 49 ++++++++++++++ .../0209_operations_case_evidence_source.sql | 10 +++ tests/test_operations_case_analysis.py | 30 ++++++++- tests/test_operations_case_ingestion.py | 16 ++++- tests/test_operations_dashboard.py | 4 ++ tests/test_post_content_worker.py | 37 ++++++++++- 15 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 migrations/0209_operations_case_evidence_source.sql create mode 100644 migrations/rollback/0209_operations_case_evidence_source.sql diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index d40964b07..a2a1fc84f 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -23,7 +23,7 @@ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any: def source_body_digest(body: str) -> str: - """Return the digest that binds inference to an exact source body.""" + """Return the digest that binds inference to an exact focal source body.""" return source_body_sha256(body) @@ -40,22 +40,24 @@ async def persist_operations_cases( await conn.execute( "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)", post_id, - source_body_digest(source_body), + source_body_sha256(source_body), orchestrator_session_id, ) for case in cases: await conn.execute( - "insert into operations_case_classification (post_id, case_kind_code, summary_text, evidence_text) values ($1, $2, $3, $4)", + "insert into operations_case_classification (post_id, case_kind_code, summary_text, evidence_text, evidence_post_id, evidence_input_sha256) values ($1, $2, $3, $4, $5, $6)", post_id, case.case_kind_code, case.summary_text, case.evidence_text, + case.evidence_post_id, + case.evidence_input_sha256, ) if case.facts: await conn.executemany( - "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text) values ($1, $2, $3, $4, $5, $6)", + "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256) values ($1, $2, $3, $4, $5, $6, $7, $8)", [ - (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text) + (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256) for ordinal, fact in enumerate(case.facts) ], ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 832201987..6342d8fd4 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -103,6 +103,7 @@ async def fetch_operations_dashboard( f""" select classification.post_id, classification.case_kind_code, classification.summary_text, classification.evidence_text, + classification.evidence_post_id, coalesce(post.event_occurred_at, post.created_at) as occurred_at, coalesce(nullif(btrim(post.source_project_name), ''), project.project_name) as project_name @@ -124,7 +125,8 @@ async def fetch_operations_dashboard( fact_rows = await conn.fetch( f""" select fact.post_id, fact.case_kind_code, fact.fact_type_code, - fact.value_text, fact.evidence_text, fact.fact_ordinal + fact.value_text, fact.evidence_text, fact.evidence_post_id, + fact.fact_ordinal from operations_case_fact fact join source_post post on post.post_id = fact.post_id where {visible} @@ -141,6 +143,7 @@ async def fetch_operations_dashboard( "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], "value_text": row["value_text"], "evidence_text": row["evidence_text"], + "evidence_post_id": str(row["evidence_post_id"]), } ) total = int(metrics["total_post_count"]) @@ -161,6 +164,7 @@ async def fetch_operations_dashboard( "project_name": row["project_name"], "summary_text": row["summary_text"], "evidence_text": row["evidence_text"], + "evidence_post_id": str(row["evidence_post_id"]), "occurred_at": row["occurred_at"].isoformat(), "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), } diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 45bcf7430..825c35b77 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -26,12 +26,16 @@ transition_post_content_job, ) from backend.app.operations_case_ingestion import persist_operations_cases +from backend.app.post_chat_ingestion import gather_chat_sources from lineageweave.embedding_client import EmbeddingClient from lineageweave.http_client import HttpClientError from lineageweave.image_content import ImageContentClient from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata from lineageweave.observability import record_server_failure, traced -from lineageweave.operations_case_analysis import ContextualOrchestratorOperationsCaseAnalysisClient +from lineageweave.operations_case_analysis import ( + ContextualOrchestratorOperationsCaseAnalysisClient, + OperationsEvidenceSource, +) from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_content_persistence import persist_post_content from lineageweave.post_structure import PostStructureClient @@ -45,6 +49,40 @@ _UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job" +async def _operations_evidence_sources( + pool: asyncpg.Pool, + post_id: str, + focal_row: asyncpg.Record, + vision_client: ImageContentClient, +) -> tuple[OperationsEvidenceSource, ...]: + """Reuse authorized lineage/semantic chat retrieval for case inference.""" + focal_entity = str(focal_row["corporate_entity_id"]) + focal_process = focal_row.get("process_unit_id") + + def can_see(row: asyncpg.Record) -> bool: + """Keep linked private evidence inside the focal entity and PU scope.""" + return row["visibility_code"] == "public" or ( + str(row["corporate_entity_id"]) == focal_entity + and row.get("process_unit_id") == focal_process + ) + + async with pool.acquire() as conn: + sources = await gather_chat_sources(conn, post_id, can_see, vision_client) + return tuple( + OperationsEvidenceSource( + source.post_id, + source.post_title, + source.post_body + + ( + "\nPersisted semantic evidence:\n" + "\n".join(source.evidence_facts) + if source.evidence_facts + else "" + ), + ) + for source in sources + ) + + async def _stream_tail(client: redis.Redis) -> str: """Start after historical wake-ups; the normalized ledger drives recovery.""" with traced( @@ -302,10 +340,12 @@ async def process_post_content_job( ) if row.get(name) is not None and str(row[name]).strip() ) + evidence_sources = await _operations_evidence_sources( + pool, post_id, row, vision_client + ) cases = await asyncio.to_thread( case_client.analyze, - str(row["post_title"]), - raw_body, + evidence_sources, context, ) async with pool.acquire() as conn: diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 204586457..3f9f5505d 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -56,6 +56,13 @@ provenance. When the focal post lacks an answer, the orchestrator follows authorized Event Lineage and semantic project evidence before concluding the fact is absent from the authorized corpus. + The analysis input reuses the post-chat source assembler: focal post first, + then bounded Event Lineage and semantic-neighborhood posts after the same + corporate-entity/process-unit ABAC check. Every classification and fact + persists its evidence post id and the SHA-256 of the exact numbered input + document. A span that does not occur in that identified document rejects + the whole provider response; linked evidence is never rewritten as focal + post evidence. 8. Claim-investigation and rebid/handover panels include positively classified cases and show extracted answers plus cited spans. A required answer that the source does not support is stored as an explicit missing fact, so the diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d046a1648..a0826e242 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -46,6 +46,7 @@ export interface OperationsDashboardFact { fact_type_label: string; value_text: string; evidence_text: string; + evidence_post_id: string; } export interface OperationsDashboardCase { @@ -55,6 +56,7 @@ export interface OperationsDashboardCase { project_name: string | null; summary_text: string; evidence_text: string; + evidence_post_id: string; occurred_at: string; facts: OperationsDashboardFact[]; } diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 80addc27a..5187bafcc 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -14,10 +14,10 @@ export const EvidenceReady: Story = { external_post_count: 9, external_percent: 22.5, pending_analysis_count: 3, failed_analysis_count: 0, cases: [ - { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20" }] }, - { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal" }] }, - { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "business_relation", fact_type_label: "사업 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice" }] }, - { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification." }] }, + { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }] }, + { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }] }, + { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "business_relation", fact_type_label: "사업 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }] }, + { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }] }, ], }, onOpenPost: () => undefined, @@ -25,7 +25,7 @@ export const EvidenceReady: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument(); - await expect(canvas.getByRole("button", { name: "근거 글 열기" })).toBeVisible(); + await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible(); }, }; diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 6fdad9c28..02f2089e8 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -19,8 +19,8 @@ const data = { failed_analysis_count: 0, cases: [{ post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", - project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", occurred_at: "2026-08-12T00:00:00Z", - facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100" }], + project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", + facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }], }], }; @@ -30,8 +30,10 @@ describe("OperationsDashboardView", () => { render(); expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "근거 글 열기" })); - expect(onOpenPost).toHaveBeenCalledWith("post-1"); + await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); + expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); + await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" })); + expect(onOpenPost).toHaveBeenCalledWith("evidence-post-2"); }); it("shows an actionable empty external-information state", () => { diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 0d5e74686..e207f474e 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -97,8 +97,8 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}

{item.summary_text}

{item.evidence_text}
-
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
- +
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
+ ))} diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index b258b1170..43decd006 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from dataclasses import dataclass from typing import Protocol @@ -27,6 +28,8 @@ class OperationsCaseFact: fact_type_code: str value_text: str evidence_text: str + evidence_post_id: str = "" + evidence_input_sha256: str = "" @dataclass(frozen=True) @@ -37,6 +40,22 @@ class OperationsCase: summary_text: str evidence_text: str facts: tuple[OperationsCaseFact, ...] + evidence_post_id: str = "" + evidence_input_sha256: str = "" + + +@dataclass(frozen=True) +class OperationsEvidenceSource: + """One authorized source document supplied to case analysis.""" + + post_id: str + title: str + text: str + + @property + def input_sha256(self) -> str: + """Digest the exact evidence text submitted to the orchestrator.""" + return hashlib.sha256(self.text.encode("utf-8")).hexdigest() class OperationsCaseAnalysisClient(Protocol): @@ -44,7 +63,9 @@ class OperationsCaseAnalysisClient(Protocol): available: bool - def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + def analyze( + self, sources: tuple[OperationsEvidenceSource, ...], context: str + ) -> tuple[OperationsCase, ...]: """Return every source-supported case and its facts.""" raise NotImplementedError @@ -54,7 +75,9 @@ class NullOperationsCaseAnalysisClient: available = False - def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + def analyze( + self, sources: tuple[OperationsEvidenceSource, ...], context: str + ) -> tuple[OperationsCase, ...]: """Refuse to fabricate a case when the orchestrator is unavailable.""" raise RuntimeError("operations case analysis is unavailable") @@ -62,20 +85,26 @@ def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, _PROMPT = """Analyze this business record semantically. Do not use keyword matching. Return ONLY a JSON array. Each item must have case_kind_code (one of claim_investigation, rebid_handover, external_information, repeat_issue), summary_text, -evidence_text (a verbatim span from the body), and facts. Each fact has +evidence_post_id, evidence_text (a verbatim span from that numbered source), and facts. Each fact has fact_type_code (one of order, specification_change, originating_order, sales_pool, discussion, counterparty, our_owner, decision, external_relation, -issue_pattern, improvement_action), value_text, and evidence_text (a verbatim body span). Return [] only when the +issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). Return [] only when the record supports none of the case kinds. Never fill an unsupported fact. Stored context (hints, not proof): {context} -Title: {title} -Body: {body} +Authorized numbered sources: +{sources} """ -def parse_operations_case_response(content: str, source_body: str) -> tuple[OperationsCase, ...] | None: - """Validate a JSON response and require every evidence span to occur in the source.""" +def parse_operations_case_response( + content: str, sources: tuple[OperationsEvidenceSource, ...] | str +) -> tuple[OperationsCase, ...] | None: + """Require every evidence span and post id to match an authorized source.""" + legacy_focal = isinstance(sources, str) + if legacy_focal: + sources = (OperationsEvidenceSource("focal", "focal", sources),) + sources_by_id = {source.post_id: source for source in sources} try: payload = json.loads(content.strip()) except json.JSONDecodeError: @@ -92,8 +121,10 @@ def parse_operations_case_response(content: str, source_body: str) -> tuple[Oper seen_case_kinds.add(item["case_kind_code"]) summary = item.get("summary_text") evidence = item.get("evidence_text") + evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None) facts = item.get("facts") - if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence not in source_body or not isinstance(facts, list): + evidence_source = sources_by_id.get(evidence_post_id) + if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list): return None parsed_facts: list[OperationsCaseFact] = [] for fact in facts: @@ -101,10 +132,12 @@ def parse_operations_case_response(content: str, source_body: str) -> tuple[Oper return None value = fact.get("value_text") fact_evidence = fact.get("evidence_text") - if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or not fact_evidence.strip() or fact_evidence not in source_body: + fact_post_id = fact.get("evidence_post_id") or ("focal" if legacy_focal else None) + fact_source = sources_by_id.get(fact_post_id) + if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or not fact_evidence.strip() or fact_source is None or fact_evidence not in fact_source.text: return None - parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence)) - cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts))) + parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256)) + cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256)) return tuple(cases) @@ -118,15 +151,17 @@ def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> No self._api_key = api_key self._timeout = timeout - def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + def analyze( + self, sources: tuple[OperationsEvidenceSource, ...], context: str + ) -> tuple[OperationsCase, ...]: """Classify cases and reject any uncited or malformed result.""" response = post_json( f"{self._base_url}/v1/chat/completions", - {"messages": [{"role": "user", "content": _PROMPT.format(context=context, title=title, body=body)}], "mode": "auto", "reasoning_effort": "auto"}, + {"messages": [{"role": "user", "content": _PROMPT.format(context=context, sources="\n\n".join(f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}" for index, source in enumerate(sources, 1)))}], "mode": "auto", "reasoning_effort": "auto"}, headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - parsed = parse_operations_case_response(chat_completion_content(response), body) + parsed = parse_operations_case_response(chat_completion_content(response), sources) if parsed is None: raise ValueError("operations case response did not match the evidence contract") return parsed diff --git a/migrations/0209_operations_case_evidence_source.sql b/migrations/0209_operations_case_evidence_source.sql new file mode 100644 index 000000000..90e1dd405 --- /dev/null +++ b/migrations/0209_operations_case_evidence_source.sql @@ -0,0 +1,49 @@ +-- ADR 0206: every inferred classification and fact names its analyzed source document. +alter table operations_case_classification + add column if not exists evidence_post_id uuid, + add column if not exists evidence_input_sha256 text; + +update operations_case_classification + set evidence_post_id = post_id + where evidence_post_id is null; +update operations_case_classification classification + set evidence_input_sha256 = analysis.source_body_sha256 + from operations_case_analysis analysis + where analysis.post_id = classification.post_id + and classification.evidence_input_sha256 is null; + +alter table operations_case_classification + alter column evidence_post_id set not null, + alter column evidence_input_sha256 set not null; + +alter table operations_case_classification + drop constraint if exists operations_case_classification_evidence_post_fk, + add constraint operations_case_classification_evidence_post_fk + foreign key (evidence_post_id) references source_post(post_id) on delete restrict, + drop constraint if exists operations_case_classification_evidence_digest_check, + add constraint operations_case_classification_evidence_digest_check + check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'); + +alter table operations_case_fact + add column if not exists evidence_post_id uuid, + add column if not exists evidence_input_sha256 text; + +update operations_case_fact fact + set evidence_post_id = classification.evidence_post_id, + evidence_input_sha256 = classification.evidence_input_sha256 + from operations_case_classification classification + where classification.post_id = fact.post_id + and classification.case_kind_code = fact.case_kind_code + and (fact.evidence_post_id is null or fact.evidence_input_sha256 is null); + +alter table operations_case_fact + alter column evidence_post_id set not null, + alter column evidence_input_sha256 set not null; + +alter table operations_case_fact + drop constraint if exists operations_case_fact_evidence_post_fk, + add constraint operations_case_fact_evidence_post_fk + foreign key (evidence_post_id) references source_post(post_id) on delete restrict, + drop constraint if exists operations_case_fact_evidence_digest_check, + add constraint operations_case_fact_evidence_digest_check + check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'); diff --git a/migrations/rollback/0209_operations_case_evidence_source.sql b/migrations/rollback/0209_operations_case_evidence_source.sql new file mode 100644 index 000000000..c9bdcd0b3 --- /dev/null +++ b/migrations/rollback/0209_operations_case_evidence_source.sql @@ -0,0 +1,10 @@ +alter table operations_case_fact + drop constraint if exists operations_case_fact_evidence_post_fk, + drop constraint if exists operations_case_fact_evidence_digest_check, + drop column if exists evidence_post_id, + drop column if exists evidence_input_sha256; +alter table operations_case_classification + drop constraint if exists operations_case_classification_evidence_post_fk, + drop constraint if exists operations_case_classification_evidence_digest_check, + drop column if exists evidence_post_id, + drop column if exists evidence_input_sha256; diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 4e6f3bae6..312189879 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -2,7 +2,7 @@ import json -from lineageweave.operations_case_analysis import parse_operations_case_response +from lineageweave.operations_case_analysis import OperationsEvidenceSource, parse_operations_case_response def test_parses_multiple_cases_and_grounded_facts() -> None: @@ -45,3 +45,31 @@ def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None: ] assert parse_operations_case_response(json.dumps(duplicate), "body") is None assert parse_operations_case_response(json.dumps(blank), "body") is None + + +def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> None: + """A linked specification fact is never attributed to the focal record.""" + sources = ( + OperationsEvidenceSource("focal", "Claim", "A claim was received."), + OperationsEvidenceSource("linked", "Specification", "Specification S2 replaced S1."), + ) + payload = [{ + "case_kind_code": "claim_investigation", + "summary_text": "Specification changed before the claim", + "evidence_post_id": "focal", + "evidence_text": "A claim was received.", + "facts": [{ + "fact_type_code": "specification_change", + "value_text": "S2 replaced S1", + "evidence_post_id": "linked", + "evidence_text": "Specification S2 replaced S1.", + }], + }] + + result = parse_operations_case_response(json.dumps(payload), sources) + + assert result is not None + assert result[0].facts[0].evidence_post_id == "linked" + assert result[0].facts[0].evidence_input_sha256 == sources[1].input_sha256 + payload[0]["facts"][0]["evidence_post_id"] = "unauthorized" + assert parse_operations_case_response(json.dumps(payload), sources) is None diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py index e68306c8f..d1270d5ca 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -32,11 +32,23 @@ async def executemany(self, _sql: str, args: list[tuple[object, ...]]) -> None: def test_digest_and_atomic_normalized_persistence() -> None: """The parent, classifications, and facts retain exact-body lineage.""" conn = _Connection() - cases = (OperationsCase("claim_investigation", "Claim", "source", (OperationsCaseFact("order", "A-1", "source"),)),) + digest = "a" * 64 + cases = ( + OperationsCase( + "claim_investigation", + "Claim", + "source", + (OperationsCaseFact("order", "A-1", "source", "post-1", digest),), + "post-1", + digest, + ), + ) asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", cases)) assert len(source_body_digest("source")) == 64 assert "delete from operations_case_analysis" in conn.calls[0][0] - assert conn.batches == [[("post-1", "claim_investigation", 0, "order", "A-1", "source")]] + assert conn.batches == [ + [("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest)] + ] def test_persists_supported_empty_analysis() -> None: diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 983f5d53d..65fdbb7ce 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -33,6 +33,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "fact_type_code": "originating_order", "value_text": "Synthetic order 7", "evidence_text": "Synthetic cited sentence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", "fact_ordinal": 0, } ] @@ -42,6 +43,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "case_kind_code": "claim_investigation", "summary_text": "원인 수주가 연결됨", "evidence_text": "Synthetic cited sentence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", "project_name": "Synthetic Project", "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), } @@ -72,6 +74,7 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "project_name": "Synthetic Project", "summary_text": "원인 수주가 연결됨", "evidence_text": "Synthetic cited sentence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", "occurred_at": "2026-08-12T00:00:00+00:00", "facts": [ { @@ -79,6 +82,7 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "fact_type_label": "원인 수주", "value_text": "Synthetic order 7", "evidence_text": "Synthetic cited sentence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", } ], } diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 080983261..7bd661b44 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -14,6 +14,7 @@ RUNNING, SUCCEEDED, ) +from lineageweave.operations_case_analysis import OperationsEvidenceSource class _Transaction: @@ -78,6 +79,36 @@ async def xrevrange(self, key: str, *, count: int): assert asyncio.run(post_content_worker._stream_tail(Client())) == "123-0" +def test_operations_sources_apply_focal_entity_and_process_scope(monkeypatch) -> None: + """Private linked evidence outside the focal PU never reaches the orchestrator.""" + decisions: list[bool] = [] + + async def gather(_conn, _post_id, can_see, _vision): + decisions.extend( + can_see(row) + for row in ( + {"visibility_code": "private", "corporate_entity_id": "corp", "process_unit_id": "pu"}, + {"visibility_code": "private", "corporate_entity_id": "corp", "process_unit_id": "other"}, + {"visibility_code": "private", "corporate_entity_id": "other", "process_unit_id": "pu"}, + {"visibility_code": "public", "corporate_entity_id": "other", "process_unit_id": "other"}, + ) + ) + return [] + + monkeypatch.setattr(post_content_worker, "gather_chat_sources", gather) + sources = asyncio.run( + post_content_worker._operations_evidence_sources( + _Pool(_Connection()), + "post-1", + {"corporate_entity_id": "corp", "process_unit_id": "pu"}, + SimpleNamespace(available=False), + ) + ) + + assert sources == () + assert decisions == [True, False, False, True] + + def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None: connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS)) @@ -179,12 +210,16 @@ async def incomplete(*_args, **_kwargs): "normalize_post_body", lambda *_args: SimpleNamespace(text="synthetic source body"), ) + async def evidence_sources(*_args, **_kwargs): + return (OperationsEvidenceSource("post-1", "Synthetic", "A synthetic post body with a retrieval unit."),) + + monkeypatch.setattr(post_content_worker, "_operations_evidence_sources", evidence_sources) analyzed_bodies: list[str] = [] monkeypatch.setattr( post_content_worker, "ContextualOrchestratorOperationsCaseAnalysisClient", lambda *_args: SimpleNamespace( - analyze=lambda _title, body, _context: analyzed_bodies.append(body) or () + analyze=lambda sources, _context: analyzed_bodies.append(sources[0].text) or () ), ) monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist) From 64cf83bad121d1598065c5ce3131921479582a02 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:27:21 +0900 Subject: [PATCH 24/27] fix(api): keep similar VOC query in one audited template --- backend/app/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 1d359ffe9..54042e1f4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1816,7 +1816,7 @@ async def read_similar_voc( ) async with pool.acquire() as conn: rows = await conn.fetch( - """ + f""" select post.post_id, post.post_title, post.post_body, post.visibility_code, post.corporate_entity_id, post.process_unit_id, coalesce(post.event_occurred_at, post.created_at) as occurred_at @@ -1829,10 +1829,10 @@ async def read_similar_voc( or (post.corporate_entity_id::text = any($2::text[]) and (cardinality($3::text[]) = 0 or post.process_unit_id::text = any($3::text[])))) - and """ - f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} " - "order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id " - "offset $4 limit $5", + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + order by coalesce(post.event_occurred_at, post.created_at) desc, post.post_id + offset $4 limit $5 + """, post_id, list(account.corporate_entity_ids), list(account.process_unit_ids), From e6a18b27f21eee4afd719b342500f02928019118 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:30:46 +0900 Subject: [PATCH 25/27] fix(ask): restrict evidence to semantic candidates --- backend/app/post_chat_ingestion.py | 1 + tests/test_global_ask_sources.py | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index dc567c7f1..4ca9e2f2d 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -540,6 +540,7 @@ async def gather_global_chat_sources( or (corporate_entity_id::text = any($1::text[]) and (cardinality($2::text[]) = 0 or process_unit_id::text = any($2::text[])))) + and source_post.post_id = any($3::uuid[]) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} and ($5::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $5) and ($6::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $6) diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index af1b52ae8..f7fc31ae7 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -132,6 +132,7 @@ async def fetch(self, query: str, *args): assert candidate_args[0] == [1.0, 0.0] assert candidate_args[2] == "test-embedding" assert "array_position($3::uuid[], post_id)" in source_query + assert "source_post.post_id = any($3::uuid[])" in source_query assert source_args[3] == 8 # The database returns candidates in cosine-rank order; no local lexical # weights or reranking may alter that order. From cbb5911098f441407d28f4506452a953b08cbd91 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:30:39 +0900 Subject: [PATCH 26/27] fix(api): bound similar VOC adjudication --- backend/app/main.py | 17 ++++++++++--- backend/tests/test_similar_voc_api.py | 36 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 54042e1f4..a9cd25a66 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -222,6 +222,7 @@ _POST_READ = "post_read" _POST_ADMIN = "post_admin" _SIMILAR_VOC_PAGE_SIZE = 8 +_SIMILAR_VOC_REQUEST_TIMEOUT_SECONDS = 180.0 @asynccontextmanager @@ -491,7 +492,9 @@ def _similar_voc_client(): if not (settings.orchestrator_base_url and settings.orchestrator_api_key): return None return ContextualOrchestratorSimilarVocAnalysisClient( - base_url=settings.orchestrator_base_url, api_key=settings.orchestrator_api_key + base_url=settings.orchestrator_base_url, + api_key=settings.orchestrator_api_key, + timeout=_SIMILAR_VOC_REQUEST_TIMEOUT_SECONDS, ) @@ -1852,10 +1855,16 @@ async def _adjudicate(candidate: asyncpg.Record): candidate["post_body"], ) + try: + results = await asyncio.wait_for( + asyncio.gather(*(_adjudicate(candidate) for candidate in candidates), return_exceptions=True), + timeout=_SIMILAR_VOC_REQUEST_TIMEOUT_SECONDS, + ) + except TimeoutError: + results = () items = [] - for candidate in candidates: - evidence = await _adjudicate(candidate) - if evidence is None: + for candidate, evidence in zip(candidates, results): + if evidence is None or isinstance(evidence, BaseException): continue items.append( { diff --git a/backend/tests/test_similar_voc_api.py b/backend/tests/test_similar_voc_api.py index a2d655787..54e83dc9c 100644 --- a/backend/tests/test_similar_voc_api.py +++ b/backend/tests/test_similar_voc_api.py @@ -110,3 +110,39 @@ def analyze(self, _title, _body, candidate_id, _candidate_title, candidate_body) assert len(calls) == 8 assert payload["next_offset"] == 24 + + +def test_similar_voc_keeps_success_when_one_adjudication_fails(monkeypatch) -> None: + """One provider failure does not discard evidence from sibling candidates.""" + focal = {"post_id": "focal", "post_title": "Current", "post_body": "Current issue."} + + async def load_visible_post(*_args): + return focal + + class Client: + def analyze(self, _title, _body, candidate_id, _candidate_title, candidate_body): + if candidate_id == "failed": + raise OSError("synthetic provider failure") + return SimilarVocEvidence( + candidate_id, "Equivalent issue", "Current issue.", candidate_body, None, () + ) + + rows = [ + { + "post_id": candidate_id, + "post_title": candidate_id, + "post_body": f"{candidate_id} issue.", + "visibility_code": "public", + "corporate_entity_id": "corp-a", + "process_unit_id": "unit-a", + "occurred_at": datetime(2026, 8, 20, tzinfo=timezone.utc), + } + for candidate_id in ("failed", "succeeded") + ] + monkeypatch.setattr(main, "_load_visible_post", load_visible_post) + monkeypatch.setattr(main, "_similar_voc_client", Client) + account = SimpleNamespace(corporate_entity_ids={"corp-a"}, process_unit_ids={"unit-a"}) + + payload = asyncio.run(main.read_similar_voc("focal", 0, account, _Pool(rows))) + + assert [item["post_id"] for item in payload["items"]] == ["succeeded"] From 3e3b3eee954bcd33aae555bec2596cb3a0391386 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 04:32:57 -0700 Subject: [PATCH 27/27] docs: fix temporal topic compute contracts (#617) Co-authored-by: seonghobae --- ARCHITECTURE.md | 20 +- ...-externalize-local-mathematical-compute.md | 116 +++++++++ ...poral-topic-context-influence-dashboard.md | 236 ++++++++++++++++++ docs/adr/README.md | 2 + ...hon-mathematical-compute-boundary-audit.md | 64 +++++ ...mporal-topic-context-influence-research.md | 72 ++++++ tests/test_math_boundary_inventory.py | 47 ++++ 7 files changed, 546 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0208-externalize-local-mathematical-compute.md create mode 100644 docs/adr/0210-temporal-topic-context-influence-dashboard.md create mode 100644 docs/doctoring/python-mathematical-compute-boundary-audit.md create mode 100644 docs/temporal-topic-context-influence-research.md create mode 100644 tests/test_math_boundary_inventory.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b12c6c2c..c7f17f666 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,17 +15,15 @@ repos in the ecosystem: trajectories, uncertainty-quantified estimates) is [TEPP](https://github.com/ContextualWisdomLab/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). +ADR 0208 fixes the end state: LineageWeave retains wire validation, +authorization, provenance persistence, and UI projection only. The current +Python IRT/report, residual-map, similarity, graph-ranking, and fusion paths +are explicitly inventoried migration debt rather than evidence that this +repository owns their mathematics. They move by construct to TEPP, +fast-mlsirm, or RankWeave after versioned Rust CPU/GPU owner contracts pass +recovery/equivalence checks; affected product paths fail closed during each +cutover rather than substituting a local estimate. See +`docs/doctoring/python-mathematical-compute-boundary-audit.md`. ## Data flow diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md new file mode 100644 index 000000000..42a5a0591 --- /dev/null +++ b/docs/adr/0208-externalize-local-mathematical-compute.md @@ -0,0 +1,116 @@ +# ADR 0208 — Externalize local mathematical computation + +**Decision status:** Accepted +**Date:** 2026-08-25 +**Amends:** ADR 0003, ADR 0024, ADR 0064, ADR 0084, ADR 0132, ADR 0145, +ADR 0148, ADR 0167, ADR 0168, ADR 0182, ADR 0185, ADR 0200, ADR 0201, and +ADR 0205 + +## Context + +LineageWeave's product boundary says that it reconstructs, authorizes, +persists, and presents evidence but does not own calibrated estimation. The +current exact head nevertheless contains Python implementations of IRT report +fitting and scoring, expected-information channel weights, residual SVD and +Gabriel coordinates, embedding cosine, graph random-walk ranking, score +normalization, and RRF contribution arithmetic. Calling a Rust-backed Python +package does not remove the local arithmetic that prepares, transforms, or +interprets its numerical result. + +The ecosystem product boundaries are already sufficient: + +- TEPP's approved PRD owns multilingual temporal and relational measurement, + shared-latent topic identity, trajectories, uncertainty, and event lineage. +- fast-mlsirm's PRD owns reusable IRT/LSIRM estimation, prediction, + diagnostics, recovery, multilevel and multiple-membership computation. +- RankWeave owns retrieval fusion, ranking, evaluation, comparison, and + policy selection. Its calculation core must itself move behind a Rust + CPU/GPU implementation before LineageWeave treats a new result as governed + numerical evidence. + +LineageWeave has no standalone canonical PRD file on this exact head. Until +one lands, `ARCHITECTURE.md` and the accepted ADR set are the product baseline; +this absence remains a product-documentation gap, not permission to infer a +different responsibility. + +## Decision + +1. **No new local numerical model.** LineageWeave adds no Python + mathematical, statistical, psychometric, ranking, fusion, optimization, + matrix-factorization, graph-centrality, or similarity implementation. +2. **Owner by construct.** TEPP owns temporal/topic/event/trajectory + measurement. fast-mlsirm owns psychometric estimation, item information, + expected responses, residual interaction maps, uncertainty, recovery, and + multilevel/multiple-membership post importance. RankWeave owns retrieval + fusion, ranking metrics, contribution evidence, comparisons, and policy + selection. A construct is not moved merely to obtain a preferred language. +3. **Rust execution contract.** New or migrated owner computation executes in + the owner's Rust core with GPU acceleration when supported and a + deterministic multithreaded CPU path. Python may be a generated binding or + transport adapter only; it may not reproduce a formula. +4. **Consumer-only LineageWeave.** This repository retains request/envelope + validation, ABAC filtering, immutable input/output digests, run and model + versions, knowledge cutoff, provenance persistence, and UI projection. + Missing, malformed, non-converged, mixed-snapshot, or unsupported results + fail closed. It never repairs, normalizes, estimates, or substitutes a + numerical result. +5. **No big-bang rewrite.** Existing local computation is frozen as named + migration debt in + `docs/doctoring/python-mathematical-compute-boundary-audit.md`. Each owner + contract lands and proves recovery/equivalence before the corresponding + LineageWeave implementation is deleted. Existing behavior is not relabeled + as compliant while it remains local. +6. **Independent TEPP anchor.** Event-Lineage channel-weight activation keeps + ADR 0205's exact TEPP anchor requirement. fast-mlsirm may estimate weights + conditional on that accepted independent anchor; it does not manufacture + the criterion. +7. **No heuristic exception.** Candidate windows, score floors, token overlap, + string similarity, or equal weights are not promoted to measurement. + Operational bounds may remain only as disclosed resource limits and may + not determine a scientific score or ground truth. + +## Stacked delivery order + +1. Owner PRs publish versioned request/result schemas, model identity, + convergence/uncertainty evidence, input digest, and deterministic recovery + tests: TEPP first, fast-mlsirm second, RankWeave third. +2. A LineageWeave contract-only PR adds strict clients and provenance tables; + no UI activates from an unpersisted envelope. +3. A shadow-validation PR compares owner outputs with frozen synthetic + fixtures and records aggregate, non-identifying evidence. +4. Separate deletion PRs remove `channel_weight_estimation.py`, numerical + portions of `period_report.py` and `leftover_pairs.py`, local cosine/RWR, + and local ranking contribution/normalization code after their owner path is + accepted. +5. The final PR removes NumPy/fast-mlsirm/RankWeave calculation imports from + LineageWeave, updates architecture/PRD/ADRs, and makes the transition guard + require an empty debt inventory. + +## Consequences + +The Dashboard may show TEPP topics and fast-mlsirm importance only from exact, +persisted owner artifacts. It can explain the source posts, memberships, +levels, time window, model version, uncertainty, and provenance, but cannot +recalculate or rank them locally. During migration, affected capabilities +remain explicitly legacy or unavailable rather than presenting local results +as Rust/GPU-backed. + +## References (APA 7th) + +Fox, J.-P., & Glas, C. A. W. (2001). Bayesian estimation of a multilevel item +response model using Gibbs sampling. *Psychometrika, 66*(2), 271–288. +https://doi.org/10.1007/BF02294839 + +Gabriel, K. R. (1971). The biplot graphic display of matrices with application +to principal component analysis. *Biometrika, 58*(3), 453–467. +https://doi.org/10.1093/biomet/58.3.453 + +Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping unobserved +item-respondent interactions: A latent space item response model with +interaction map. *Psychometrika, 86*(2), 378–403. +https://doi.org/10.1007/s11336-021-09762-5 + +Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for +structural topic models. *Journal of Statistical Software, 91*(2), 1–40. +https://doi.org/10.18637/jss.v091.i02 + diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md new file mode 100644 index 000000000..e9be55d48 --- /dev/null +++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md @@ -0,0 +1,236 @@ +# ADR 0210: TEPP temporal topics and fast-mlsirm context influence + +- Status: Accepted +- Implementation maturity: producer-contract required; consumer projection not yet shipped +- Date: 2026-08-25 +- Depends on: ADR 0132 (TEPP topic-lineage boundary), ADR 0206 (operations Dashboard) +- Upstream authorities: TEPP ADR 0012; fast-mlsirm ADR 0002 and ADR 0007 + +## Context + +The operations Dashboard must show how topics evolve through Event Lineage and +which posts materially influence a topic's fitted state at business-unit, +process-unit (PU), team, and person levels. A lexical cluster, one topic model +per time bin, raw topic proportion, engagement count, or hand-authored weighted +sum cannot answer that question. Those approaches lose stable topic identity, +ignore multiple membership, understate dependence, or silently redefine +"important". + +TEPP's approved PRD and ADR 0012 already own Temporal Relational Shared-Latent +Topic Measurement (TRSL-TM): global topic identities, event time, explicit +document relations, weighted cross-classified memberships, posterior +uncertainty, and topic activity over time. LineageWeave therefore consumes a +TEPP artifact; it does not fit or label topics locally. + +For this surface, **important post** has one exact statistical meaning: +case-deletion influence on a fitted topic-by-context parameter. For topic +`k`, context dimension `l`, and post `d`, fast-mlsirm reports + +\[ +D_{dkl}=(\hat\psi_{kl,-d}-\hat\psi_{kl})^\top +I_{kl}(\hat\psi)(\hat\psi_{kl,-d}-\hat\psi_{kl}), +\] + +where `I` is the same fitted model's observed-information block and +`psi[-d]` is the estimate after removing that post's complete observation. +This is a multilevel case-deletion diagnostic, not business value, causal +impact, author performance, or an outlier-removal instruction (Shi & Chen, +2008). It is selected because it is defined by the fitted likelihood and +observed information, so no arbitrary cross-level weights or score constants +are introduced. + +## Product requirements (PRD) + +1. The Dashboard presents TEPP topics on one event-time axis with stable topic + identity and explicit active, dormant, and reactivated states. Topic + birth/split/merge/retirement appears only when the TEPP artifact explicitly + supplies that lineage event. +2. Selecting a topic shows separate business-unit, PU, team, and person views. + A post may belong to more than one context in the same dimension and to + contexts in several dimensions. The UI never flattens those assignments + into a single owner. +3. Each level lists posts by fast-mlsirm case-deletion influence `D[d,k,l]`, + with exact value, uncertainty/diagnostic status, source event time, topic + state, membership provenance, and a link to the authorized source post. + No score threshold is applied. Equal values remain ties; deterministic + source time and post identity order only stabilize rendering and do not + break the statistical tie. +4. Copy names the estimand as **model influence**. It must not say business + importance, performance, causality, risk, or priority unless a separately + validated outcome model establishes that construct. +5. Pending, failed, non-converged, unidentified, incomplete-membership, + CPU/GPU-parity-failed, or contract-mismatched runs render an actionable + unavailable state. LineageWeave never fills them with keyword search, + engagement counts, RankWeave output, default weights, or a local estimate. +6. All rows are authorization-filtered before topic/context aggregation. A + hidden source post contributes neither a displayed rank nor an exact value + that could disclose its influence. +7. The topic view is a Dashboard section, not a new external-information + board. It reuses the existing GNB destination and post-detail navigation. + +## Technical requirements (TRD) + +### TEPP producer contract + +The accepted TEPP result schema must include: + +- immutable model-run, source-snapshot SHA-256, knowledge cutoff, model/schema + version, event clock, and posterior-draw identity; +- global topic identity and activity interval, plus explicit lineage event and + provenance when present; +- per-post posterior logistic-normal topic coordinates or plausible values, + not a hard topic label derived from a threshold; +- Event Lineage/document-relation edges admitted by the TEPP run; +- versioned, time-valid business-unit, PU, team, and person membership edges + with source-derived weights and evidence. A missing weight is unavailable; + equal membership is never invented. + +LineageWeave verifies the exact snapshot and cutoff before persisting a 3NF +projection. It does not inspect TEPP's private tables or reinterpret posterior +coordinates. + +TEPP protected main currently exposes `tepp.trsl_topic_lineage.v1`, a +digest-bound CPU-`f64` artifact containing fitted forward sequence edges and +aggregate counts. That is real producer progress, but it does not contain the +per-post posterior coordinates/plausible values or dimension-qualified +membership evidence required by this decision. LineageWeave must reject that +schema for the context-influence surface rather than reconstruct the omitted +inputs from its association-strength field. + +### fast-mlsirm producer contract + +fast-mlsirm owns a versioned `topic_context_influence` estimand over TEPP +posterior plausible values. It jointly retains topic, event time, and the four +dimension-qualified multiple-membership designs. Rust owns likelihood, +gradients, observed information, deletion refits, posterior-draw combination, +and influence arithmetic. The CPU `f64` path is the numerical reference; +GPU execution is a Rust device path and must pass identification-aware parity. +Python may validate and marshal only. + +The result envelope contains the exact TEPP run/snapshot/cutoff, fast-mlsirm +version and code revision, estimand/schema version, backend/precision, +convergence and identification diagnostics, posterior-draw coverage, context +membership fingerprint, post/topic/context identities, `D[d,k,l]`, and its +uncertainty evidence. A result for one context dimension cannot be copied to +another dimension. + +### LineageWeave consumer and persistence + +Use normalized objects such as `topic_model_run`, `topic_definition`, +`topic_activity_interval`, `topic_lineage_relation`, `topic_post_coordinate`, +`topic_context_membership`, `topic_influence_run`, and +`topic_post_context_influence`. Large result tables are partitioned by tenant +and modeled-period identity rather than one global time partition. Foreign +keys bind every influence row to the exact TEPP and fast-mlsirm artifacts. + +The API returns only persisted accepted rows after ABAC. It returns exact +ties, producer diagnostics, and provenance rather than computing or +renormalizing scores. The frontend renders an exact-value table alongside the +temporal topic view, uses text/pattern as well as color for topic state, and +supports keyboard, touch, reduced motion, narrow viewports, and screen readers. + +```mermaid +sequenceDiagram + participant Source as Authorized source snapshot + participant TEPP + participant MLS as fast-mlsirm Rust CPU/GPU + participant LW as LineageWeave projection + participant UI as Dashboard + Source->>TEPP: snapshot + cutoff + Event Lineage + memberships + TEPP-->>MLS: versioned posterior topic plausible values + MLS->>MLS: multilevel multiple-membership fit + MLS->>MLS: post deletion refits + observed-information D + MLS-->>LW: accepted topic_context_influence envelope + LW->>LW: exact contract, cutoff, digest, diagnostics, ABAC validation + LW-->>UI: temporal topics + level-specific tied influence rows +``` + +## Verification and acceptance + +The feature is not release-ready until all of the following are protected-main +evidence rather than a local or contract-only claim: + +1. TEPP simulation recovers known global topic identity, temporal prevalence, + relation effects, dormancy/reactivation, and cross-classified membership + effects with reported bias, RMSE, interval coverage, and posterior-draw + diagnostics; relation-aware splits prove no future leakage. +2. fast-mlsirm simulation recovers known context effects and ranks known + injected influential posts by the declared deletion estimand. Tests include + nested, crossed, weighted multiple-membership, time-varying membership, + sparse/unbalanced levels, missing observations, exact ties, and masked or + jointly influential cases. Correlation alone is not acceptance evidence. +3. Rust CPU worker-count determinism and CPU/GPU parity pass on the same + estimand. A GPU test proves actual device execution; fallback is explicit. +4. Contract tests reject wrong snapshot/cutoff/model/schema, missing posterior + draws, invented membership weights, non-convergence, unidentified + information blocks, non-finite influence, mixed producer runs, and partial + result sets. +5. Integration tests prove 3NF foreign-key integrity, idempotent replay, hot- + partition distribution, pre-aggregation ABAC, and no hidden-post leakage. +6. Storybook and browser screenshots cover populated, ties, dormant/reactivated, + multiple-membership, unavailable, narrow, dark, reduced-motion, keyboard, + and touch scenes. The exact-value table remains usable without the chart. +7. Public docstring, production line/branch, interaction, design-token, i18n, + and edge-case coverage remain 100% under repository gates. + +## Alternatives considered + +1. **LineageWeave fits a local dynamic topic model.** Rejected because TEPP + owns the temporal/relational posterior and measurement contract. +2. **Rank by posterior topic share, recency, engagement, or a weighted sum.** + Rejected because it ignores contextual influence or invents a construct and + weights. RankWeave may present an independently authorized retrieval rank, + but it is not this measurement. +3. **Use fast-mlsirm's current crossed binary kernel unchanged.** Rejected + because thresholding TEPP posterior coordinates into binary responses + discards uncertainty and changes the estimand. The producer must expose the + versioned topic-context influence contract above. +4. **Call the diagnostic business impact.** Rejected. Statistical influence + measures sensitivity of fitted topic/context parameters, not causal or + economic value. + +## Consequences + +The user receives a precise, reproducible answer to “which posts shape this +topic at this organizational level?” without arbitrary weights. Activation +depends on two upstream protected contracts and full recovery evidence; until +then the Dashboard truthfully shows why the result is unavailable rather than +inventing a ranking. + +## References (APA 7th) + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American Educational +Research Association. + +Blei, D. M., & Lafferty, J. D. (2006). Dynamic topic models. In *Proceedings +of the 23rd International Conference on Machine Learning* (pp. 113–120). +Association for Computing Machinery. https://doi.org/10.1145/1143844.1143859 + +Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership +multiple classification (MMMC) models. *Statistical Modelling, 1*(2), +103–124. https://doi.org/10.1177/1471082X0100100202 + +Fox, J.-P., & Glas, C. A. W. (2001). Bayesian estimation of a multilevel IRT +model using Gibbs sampling. *Psychometrika, 66*(2), 271–288. +https://doi.org/10.1007/BF02294839 + +Jin, I. H., Jeon, M., Schweinberger, M., Yun, J., & Lin, L. (2022). +Multilevel network item response modelling for discovering differences +between innovation and regular school systems in Korea. *Journal of the Royal +Statistical Society: Series C (Applied Statistics), 71*(5), 1225–1244. +https://doi.org/10.1111/rssc.12569 + +Molenaar, D., & Jeon, M. (2026). Regularized joint maximum likelihood +estimation of latent space item response models. *Psychometrika, 91*(1), +335–359. https://doi.org/10.1017/psy.2025.10068 + +Shi, L., & Chen, G. (2008). Case deletion diagnostics in multilevel models. +*Journal of Multivariate Analysis, 99*(9), 1860–1877. +https://doi.org/10.1016/j.jmva.2008.01.023 + +Zhang, D. C., & Lauw, H. (2022). Dynamic topic models for temporal document +networks. In *Proceedings of the 39th International Conference on Machine +Learning* (pp. 26281–26292). PMLR. +https://proceedings.mlr.press/v162/zhang22n.html diff --git a/docs/adr/README.md b/docs/adr/README.md index 6aca500f1..39c7c8bd3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,8 @@ decision from them. | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | +| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | +| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md new file mode 100644 index 000000000..f1dbeee83 --- /dev/null +++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md @@ -0,0 +1,64 @@ +# Python mathematical-compute boundary audit + +**Exact-head audit date:** 2026-08-25 +**Normative decision:** [ADR 0208](../adr/0208-externalize-local-mathematical-compute.md) + +This inventory names migration debt; it is not evidence that the current +Python paths satisfy the Rust/GPU requirement. + +## Product-boundary sources read + +- LineageWeave `ARCHITECTURE.md` and accepted ADRs 0003, 0132, 0145, + 0200, 0201, and 0205. This exact head has no standalone canonical PRD. +- TEPP `docs/product/prd-v0.4-approved.md`, whose approved TRSL-TM scope + owns temporal, relational, multilingual, topic, event, and trajectory + measurement. +- fast-mlsirm `docs/PRD.md`, whose reusable library scope owns + multilevel/contextual/longitudinal psychometric estimation, diagnostics, + recovery, and versioned artifacts rather than hosted product storage. +- RankWeave `README.md` and `ARCHITECTURE.md`. Its exact head has no PRD; + those files define the current fusion/ranking/evaluation responsibility. + A canonical RankWeave PRD is required before expanding that contract. + +| Current LineageWeave path | Local computation | Owner | Consumer replacement | Principal callers / tests | +|---|---|---|---|---| +| `lineageweave/channel_weight_estimation.py` | dichotomization, synthetic simulation, MLS2PLM input construction, expected item information and normalization | fast-mlsirm, conditional on TEPP anchor | versioned anchored-weight artifact; strict digest/convergence validation | estimation scripts, seed/server/rebuild paths; `tests/test_channel_weight_estimation.py`, estimator-script tests | +| `lineageweave/period_report.py` | response matrix, GRM/GPCM fit/FIPC/EAP, likelihood, category expectation, information ordering | fast-mlsirm | period-measurement artifact with item bank, scores, uncertainty, diagnostics | report ingestion and demo seed; period-report and report API tests | +| `lineageweave/leftover_pairs.py` | residual matrix, complete-case selection, SVD/Gabriel coordinates, distances, reconstruction, axis shares | fast-mlsirm | residual-interaction artifact with observed/expected identity and coverage | `period_report.py`, report ingestion/seed; `tests/test_leftover_pairs.py`, report tests | +| `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests | +| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests | +| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests | +| `lineageweave/rankweave_client.py` | channel construction, token overlap, RRF weights and contribution arithmetic | RankWeave | strict ranking artifact exposing owner-computed contributions | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests | + +`lineageweave/post_evaluation.py` imports fast-mlsirm only for its published +judge contract and `to_irt_row` projection. It performs no fitted numerical +estimation, but remains in the transition guard because any direct owner-package +import must be reviewed before LineageWeave's final wire-only state. + +Validation-only uses of `math.isfinite` and database aggregation are not model +ownership and remain. Date ordering, counts, pagination, authorization, schema +validation, and presentation formatting also remain LineageWeave concerns. + +## Required owner contracts + +- **TEPP:** temporal-relational topic identity and Event-Lineage criterion + artifacts, with snapshot/cutoff, posterior uncertainty, evidence status, + lineage transitions, and deterministic Rust CPU/GPU execution evidence. +- **fast-mlsirm:** anchored channel information; GRM/GPCM fit, score and item + information; Gabriel residual interaction map; and topic-conditional + multiple-membership multilevel importance for business unit, PU, team, and + person, with recovery/RMSE and coverage evidence. +- **RankWeave:** Rust-backed similarity, graph ranking, fusion, contribution, + evaluation, and policy-selection artifacts. Its present Python calculation + core is the correct product owner but not the final execution architecture. + +## Persistence and UI blast radius + +Owner envelopes require normalized run/artifact tables keyed by analysis run, +owner contract version, model version, source snapshot SHA-256, knowledge +cutoff, and authorization scope. Topic, membership, level-specific importance, +uncertainty, and source-post evidence occupy separate child rows; arrays or +labels do not replace foreign keys. Dashboard and post detail endpoints read +only accepted persisted rows and preserve source-post ABAC. Storybook covers +accepted, pending, failed, stale-digest, non-converged, hidden-evidence, and +multiple-membership cases before UI activation. diff --git a/docs/temporal-topic-context-influence-research.md b/docs/temporal-topic-context-influence-research.md new file mode 100644 index 000000000..05a80c68a --- /dev/null +++ b/docs/temporal-topic-context-influence-research.md @@ -0,0 +1,72 @@ +# Temporal topic and context-influence evidence brief + +## Research question + +How can LineageWeave present time-aware, lineage-aware topics and identify the +posts that influence topic estimates at business-unit, PU, team, and person +levels without keyword rules or arbitrary weights? + +## Search and source selection + +- Concepts: dynamic topic models, temporal document networks, multilevel IRT, + multiple-membership multiple-classification, latent-space IRT, and + multilevel case-deletion diagnostics. +- Priority: peer-reviewed primary papers, official proceedings, accepted + author manuscripts, TEPP's approved PRD/ADR, and fast-mlsirm's normative + ADR/research register. +- Excluded as authorities: review-only pages, vendor summaries, lexical topic + matching, engagement ranking, and methods that do not preserve posterior, + time, relation, or membership identity. + +## Findings + +### Temporal topic identity and document relations + +Blei and Lafferty (2006) establish state-space topic evolution rather than +independent time-bin models. Zhang and Lauw (2022) jointly model temporal +document topics and network structure; this directly supports consuming +explicit Event Lineage as relational evidence rather than matching topic +labels after fitting. TEPP PRD v0.4 and ADR 0012 combine those concerns in the +TRSL-TM producer boundary, including global topic identity, posterior +coordinates, multiple clocks, relations, and cross-classified membership. + +The evidence does not establish that every relation is causal or that a +reactivated topic is newly born. Those states and lineage events must arrive +from a versioned TEPP result. + +### Multilevel and multiple-membership measurement + +Fox and Glas (2001) show why latent rather than observed scores should be +modeled jointly with cluster effects and measurement error. Browne, Goldstein, +and Rasbash (2001) define crossed and weighted multiple-membership structures. +Jin et al. (2022) demonstrate a multilevel network item-response model that +can expose differences missed by conventional multilevel models. These papers +support distinct business-unit, PU, team, and person dimensions with explicit +time-valid membership; they do not support inferring equal weights when the +source has none. + +### “Important post” estimand + +Shi and Chen (2008) define case-deletion diagnostics at multiple levels for +fixed and random parameters. ADR 0210 therefore gives importance the bounded +name **model influence** and defines it as observed-information-scaled change +in the topic-by-context estimate after deleting the complete post +observation. This answers sensitivity of the fitted model, not business value +or causality. Molenaar and Jeon (2026) support recovery-tested regularized JML +for latent-space IRT, but do not by themselves validate this product-specific +construct; the fast-mlsirm producer must implement and recover the exact +versioned influence estimand before LineageWeave activates it. + +## Architecture consequence + +LineageWeave is a strict consumer. TEPP owns temporal/relational topic +posterior arithmetic. fast-mlsirm owns multilevel multiple-membership fitting, +observed information, deletion refits, posterior-draw combination, and CPU/GPU +parity in Rust. LineageWeave persists exact accepted artifacts, applies ABAC, +and renders tied exact values; it adds no threshold, fallback score, or local +numerical formula. + +## Primary sources (APA 7th) + +See [ADR 0210](adr/0210-temporal-topic-context-influence-dashboard.md#references-apa-7th) +for the full APA 7 bibliography and exact architecture mapping. diff --git a/tests/test_math_boundary_inventory.py b/tests/test_math_boundary_inventory.py new file mode 100644 index 000000000..d4e07eb8c --- /dev/null +++ b/tests/test_math_boundary_inventory.py @@ -0,0 +1,47 @@ +"""Freeze known Python numerical ownership while ADR 0208 moves it upstream.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NUMERICAL_OWNER_MODULES = {"fast_mlsirm", "numpy", "rankweave", "scipy", "sklearn"} +KNOWN_LOCAL_NUMERICAL_FILES = { + "lineageweave/channel_weight_estimation.py", + "lineageweave/leftover_pairs.py", + "lineageweave/period_report.py", + "lineageweave/post_evaluation.py", + "lineageweave/rankweave_client.py", + "lineageweave/reconstruct.py", +} + + +def _numerical_import_files() -> set[str]: + """Return production Python files importing a numerical owner package.""" + + found: set[str] = set() + for base in (ROOT / "lineageweave", ROOT / "backend" / "app"): + for path in base.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + imports = { + alias.name.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + imports.update( + node.module.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + ) + if imports & NUMERICAL_OWNER_MODULES: + found.add(path.relative_to(ROOT).as_posix()) + return found + + +def test_no_new_local_numerical_owner_imports() -> None: + """Require an ADR 0208 inventory update before local numerical scope grows.""" + + assert _numerical_import_files() == KNOWN_LOCAL_NUMERICAL_FILES