diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
index a31885cb6..c7398198a 100644
--- a/backend/app/analysis_run_start.py
+++ b/backend/app/analysis_run_start.py
@@ -48,8 +48,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"
@@ -107,6 +108,8 @@ class _DeliveryOutcome:
status_code: str = _SUCCEEDED
failure_code: str = ""
envelope: dict[str, Any] | None = None
+ source_snapshot_sha256: str | None = None
+ knowledge_cutoff: datetime | None = None
def reconstruction_result_digest(edges: list[Edge]) -> str:
@@ -199,7 +202,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,
)
@@ -227,7 +230,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,
)
@@ -318,8 +321,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
@@ -339,6 +344,53 @@ 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:
+ 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")
+ )
+ 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 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"
+ 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
@@ -948,6 +1000,8 @@ def _execute_delivery_plan(
status_code=status_code,
failure_code=failure_code,
envelope=envelope,
+ source_snapshot_sha256=str(plan.locked["snapshot_sha256"]),
+ knowledge_cutoff=plan.locked["knowledge_cutoff"],
)
@@ -980,14 +1034,24 @@ async def _persist_delivery_outcome(
conn, analysis_run_id=analysis_run_id, edges=outcome.edges, finished=finished
)
elif outcome.status_code == _SUCCEEDED and outcome.envelope is not None:
- persist = (
- _persist_topic_lineage_result
- if outcome.work_kind_code == _TOPIC_LINEAGE_KIND
- else _persist_tepp_result
- )
- if not await persist(
- conn, analysis_run_id=analysis_run_id, envelope=outcome.envelope
+ if outcome.work_kind_code == _TOPIC_LINEAGE_KIND:
+ persisted = await _persist_topic_lineage_result(
+ conn, analysis_run_id=analysis_run_id, envelope=outcome.envelope
+ )
+ elif (
+ outcome.source_snapshot_sha256 is not None
+ and outcome.knowledge_cutoff is not None
):
+ persisted = await _persist_tepp_result(
+ conn,
+ analysis_run_id=analysis_run_id,
+ envelope=outcome.envelope,
+ expected_snapshot_sha256=outcome.source_snapshot_sha256,
+ expected_knowledge_cutoff=outcome.knowledge_cutoff,
+ )
+ else:
+ persisted = False
+ if not persisted:
status_code = _FAILED
failure_code = "tepp_result_not_persisted"
if outcome.work_kind_code != _LINEAGE_KIND:
diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py
index 04bad77fe..260712d1c 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 (
@@ -251,6 +252,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": [],
@@ -260,6 +262,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(
@@ -297,14 +300,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/lineage_ingestion.py b/backend/app/lineage_ingestion.py
index 64fb05c04..b765c4ffb 100644
--- a/backend/app/lineage_ingestion.py
+++ b/backend/app/lineage_ingestion.py
@@ -31,14 +31,9 @@
ISOLATION_NO_COMPARISON_GROUP = "no_comparison_group"
ISOLATION_COMPARISON_CANDIDATES_AVAILABLE = "comparison_candidates_available"
-# 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"}
)
@@ -165,12 +160,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
@@ -184,6 +178,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.
@@ -194,14 +193,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:
@@ -264,6 +279,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/backend/app/main.py b/backend/app/main.py
index 17de54dd5..5e482147e 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, timezone
+from datetime import date, datetime, timezone
from typing import Any, Literal
from uuid import UUID
@@ -160,6 +160,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,
@@ -743,6 +744,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..4150b436b
--- /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."""
+ pass
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one parameterized statement."""
+ pass
+
+ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any:
+ """Execute one parameterized statement for several rows."""
+ pass
+
+
+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..7bcb70dcb
--- /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."""
+ pass
+
+ async def fetch(self, query: str, *args: object) -> list[Any]:
+ """Fetch projected rows."""
+ pass
+
+
+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 132305c43..bb08242f1 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(
require_embedding=require_embedding,
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(
@@ -276,6 +286,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/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..ac1a3a8c1
--- /dev/null
+++ b/docs/adr/0205-tepp-lineage-anchor.md
@@ -0,0 +1,60 @@
+# 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 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
+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/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
new file mode 100644
index 000000000..ecbfade38
--- /dev/null
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -0,0 +1,106 @@
+# ADR 0206: 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 77a7cec4e..6aca500f1 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -17,6 +17,7 @@ decision from them.
| [`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) |
| [`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) |
[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/storybook-inventory.md b/docs/storybook-inventory.md
index 4dbaad58b..b77187547 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/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
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 98877418a..b14345548 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -4091,12 +4091,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 e76a3b16a..11c35fa05 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -94,6 +94,8 @@ 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 { initialWorkspaceDestination } from "./gnbChrome";
import { LineageDag } from "./LineageDag";
import { PostBody } from "./PostBody";
import { decodeHtmlEntities } from "./postBodyDisplay";
@@ -4791,6 +4793,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")}
@@ -4866,7 +4880,12 @@ 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(() =>
+ initialWorkspaceDestination(
+ typeof window === "undefined" ? "" : window.location.search,
+ import.meta.env.MODE === "test",
+ ),
+ );
const [postToOpen, setPostToOpen] = useState(() => {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("post");
@@ -4969,6 +4988,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;
@@ -327,6 +359,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.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) => (
+ -
+
+
+ ))}
+
+
+ ))}
+
+ ) : 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 (
+
+
+ {items.length === 0 ? (
+ 같은 문제 유형으로 판정된 과거 VOC가 없습니다.
+ ) : (
+
+ {items.map((item) => (
+ -
+
+
추천 {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}
)}
: "기록된 조치 없음"}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/WorkspaceNav.test.tsx b/frontend/src/components/WorkspaceNav.test.tsx
index b19e0a415..8bdc3325f 100644
--- a/frontend/src/components/WorkspaceNav.test.tsx
+++ b/frontend/src/components/WorkspaceNav.test.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { ANALYST_GNB_LABELS } from "../gnbChrome";
+import { ANALYST_GNB_LABELS, initialWorkspaceDestination } from "../gnbChrome";
import { SUPPORTED_LOCALES, setLocale } from "../i18n";
import { WorkspaceNav } from "./WorkspaceNav";
@@ -9,7 +9,12 @@ afterEach(() => {
});
describe("WorkspaceNav", () => {
- it("renders exactly the four Korean analyst destinations and marks the current page", () => {
+ it("opens shared post links on the board before rendering the dashboard", () => {
+ expect(initialWorkspaceDestination("?post=synthetic-post", false)).toBe("board");
+ expect(initialWorkspaceDestination("", false)).toBe("dashboard");
+ });
+
+ it("renders the Dashboard and four analyst destinations and marks the current page", () => {
render();
const nav = screen.getByRole("navigation");
@@ -21,7 +26,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 +35,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..77177fe49 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: "달력" },
@@ -12,3 +13,8 @@ export type AnalystGnbId = (typeof ANALYST_GNB_ITEMS)[number]["id"];
export const ANALYST_GNB_LABELS = ANALYST_GNB_ITEMS.map((item) => item.label);
export const CALENDAR_CONSUME_UNAVAILABLE = "이 범위의 일정을 아직 받을 수 없습니다";
+
+/** Keep shared post links on the board; otherwise use the product landing page. */
+export function initialWorkspaceDestination(search: string, testMode: boolean): "board" | "dashboard" {
+ return testMode || new URLSearchParams(search).has("post") ? "board" : "dashboard";
+}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index c894f0275..00650624c 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -104,8 +104,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/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/0208_operations_case_analysis.sql b/migrations/0208_operations_case_analysis.sql
new file mode 100644
index 000000000..fac54ca5c
--- /dev/null
+++ b/migrations/0208_operations_case_analysis.sql
@@ -0,0 +1,31 @@
+-- Evidence-grounded operational case inference (ADR 0206). 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/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 364c58bc0..3c442e9f8 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
from functools import lru_cache
@@ -9,6 +10,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,
@@ -222,11 +224,22 @@ 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()
+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())
@@ -246,6 +259,81 @@ 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)
+
+ 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(
idempotency_key="run-topic-lineage-2026-w07",
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_lineage_ingestion.py b/tests/test_lineage_ingestion.py
index 3e4c20394..b1edaf542 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),
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 3acc2f570..2ead6ac2f 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -174,7 +174,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),
+ )