diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 4332a596f..bf053027f 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -28,6 +28,7 @@ import redis.asyncio as redis from fastapi import HTTPException, status +from lineageweave.ask_delivery import build_ask_delivery from lineageweave.http_client import HttpClientError from lineageweave.observability import record_server_failure from lineageweave.post_chat import ( @@ -189,6 +190,7 @@ def can_see(row: asyncpg.Record) -> bool: "Ask Agent is unavailable: authorized evidence could not be assembled", ) from exc if not sources: + delivery = build_ask_delivery("", (), ()) return { "answer_text": "", "cited_post_ids": [], @@ -198,6 +200,7 @@ def can_see(row: asyncpg.Record) -> bool: "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, "cited_post_images": [], "next_action": "No authorized source posts are available for this question.", + "delivery": delivery, } try: answer = await asyncio.to_thread( @@ -235,14 +238,17 @@ def can_see(row: asyncpg.Record) -> bool: async with pool.acquire() as conn: lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) images = await cited_post_images(conn, cited_ids) + cited_posts = cited_post_summaries(sources, cited_ids) + cited_evidence = cited_post_evidence(sources, cited_ids) return { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), - "cited_post_evidence": cited_post_evidence(sources, cited_ids), + "cited_posts": cited_posts, + "cited_post_evidence": cited_evidence, "cited_post_images": images, "source_post_ids": [source.post_id for source in sources], "lineage_graph": lineage_graph, + "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence), } diff --git a/backend/app/main.py b/backend/app/main.py index 0e98548af..6ff6234d7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,7 @@ import logging from contextlib import asynccontextmanager from dataclasses import asdict -from datetime import datetime +from datetime import date, datetime from typing import Any, Literal from uuid import UUID @@ -159,6 +159,7 @@ update_ticket, upsert_commitment_ticket, ) +from backend.app.operations_dashboard import fetch_operations_dashboard from backend.app.keyman_ingestion import ingest_post_keymen from backend.app.knowledge_graph import ( corporate_entity_exists, @@ -731,6 +732,24 @@ async def read_me( } +@app.get("/api/dashboard") +async def operations_dashboard( + period_start: date | None = Query(None), + period_end: date | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Show quantified operational cases backed by visible source evidence.""" + _require_post_read(account) + async with pool.acquire() as conn: + try: + return await fetch_operations_dashboard( + conn, account.corporate_entity_ids, period_start, period_end + ) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + + class LocalePreferenceRequest(BaseModel): """Body of a PATCH /api/me/preferences request.""" diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py new file mode 100644 index 000000000..c9697688d --- /dev/null +++ b/backend/app/operations_case_ingestion.py @@ -0,0 +1,61 @@ +"""Persist and project contextual-orchestrator operational case evidence.""" + +from __future__ import annotations + +import hashlib +from typing import Any, Protocol + +from lineageweave.operations_case_analysis import OperationsCase + + +class _Connection(Protocol): + def transaction(self) -> Any: + """Open an atomic database transaction.""" + ... + + async def execute(self, query: str, *args: object) -> Any: + """Execute one parameterized statement.""" + ... + + async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any: + """Execute one parameterized statement for several rows.""" + ... + + +def source_body_digest(body: str) -> str: + """Return the digest that binds inference to an exact source body.""" + return hashlib.sha256(body.encode("utf-8")).hexdigest() + + +async def persist_operations_cases( + conn: _Connection, + post_id: str, + source_body: str, + orchestrator_session_id: str, + cases: tuple[OperationsCase, ...], +) -> None: + """Atomically replace one post's normalized case analysis.""" + async with conn.transaction(): + await conn.execute("delete from operations_case_analysis where post_id = $1", post_id) + await conn.execute( + "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)", + post_id, + source_body_digest(source_body), + orchestrator_session_id, + ) + for case in cases: + await conn.execute( + "insert into operations_case_classification (post_id, case_kind_code, summary_text, evidence_text) values ($1, $2, $3, $4)", + post_id, + case.case_kind_code, + case.summary_text, + case.evidence_text, + ) + if case.facts: + await conn.executemany( + "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text) values ($1, $2, $3, $4, $5, $6)", + [ + (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text) + for ordinal, fact in enumerate(case.facts) + ], + ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py new file mode 100644 index 000000000..f8aa18f5a --- /dev/null +++ b/backend/app/operations_dashboard.py @@ -0,0 +1,166 @@ +"""ABAC-filtered projection of persisted operational case evidence.""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Protocol + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL + + +CASE_KIND_LABELS = { + "claim_investigation": "클레임 원인 규명", + "rebid_handover": "재입찰 · 인수인계", + "external_information": "발주 공고 · 시장 동향", + "repeat_issue": "반복 이슈", +} +FACT_TYPE_LABELS = { + "order": "발생 수주", + "specification_change": "사양 변경", + "originating_order": "원인 수주", + "sales_pool": "수주 Pool", + "discussion": "협의 내용", + "counterparty": "협의 상대", + "our_owner": "우리측 담당자", + "decision": "후속 의사결정", + "external_relation": "업무 관계", + "issue_pattern": "반복 유형", + "improvement_action": "개선 조치", +} + + +class _Connection(Protocol): + async def fetchrow(self, query: str, *args: object) -> Any: + """Fetch one projected row.""" + ... + + async def fetch(self, query: str, *args: object) -> list[Any]: + """Fetch projected rows.""" + ... + + +def _visible_period_sql(alias: str = "post") -> str: + """Return the shared ABAC, eligibility, and event-clock predicate.""" + return f""" + ({alias}.visibility_code = 'public' + or {alias}.corporate_entity_id::text = any($1::text[])) + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias=alias)} + and ($2::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) + at time zone 'Asia/Seoul')::date >= $2) + and ($3::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) + at time zone 'Asia/Seoul')::date <= $3) + """ + + +async def fetch_operations_dashboard( + conn: _Connection, + corporate_entity_ids: tuple[str, ...] | list[str], + period_start: date | None = None, + period_end: date | None = None, +) -> dict[str, Any]: + """Return quantified cases and their persisted source evidence.""" + if period_start and period_end and period_start > period_end: + raise ValueError("period_start must not be after period_end") + args = (list(corporate_entity_ids), period_start, period_end) + visible = _visible_period_sql() + metrics = await conn.fetchrow( + f""" + with visible_post as ( + select post.post_id + from source_post post + where {visible} + ), classified as ( + select classification.post_id, classification.case_kind_code + from operations_case_classification classification + join visible_post on visible_post.post_id = classification.post_id + ) + select (select count(*) from visible_post) as total_post_count, + (select count(*) from classified) as total_event_count, + (select count(distinct post_id) from classified + where case_kind_code = 'external_information') as external_post_count, + (select count(*) from visible_post + where not exists ( + select 1 from operations_case_analysis analysis + where analysis.post_id = visible_post.post_id + )) as pending_analysis_count + """, + *args, + ) + case_rows = await conn.fetch( + f""" + select classification.post_id, classification.case_kind_code, + classification.summary_text, classification.evidence_text, + coalesce(post.event_occurred_at, post.created_at) as occurred_at, + coalesce(nullif(btrim(post.source_project_name), ''), project.project_name) + as project_name + from operations_case_classification classification + join source_post post on post.post_id = classification.post_id + left join lateral ( + select mention.project_name + from post_project_mention mention + where mention.post_id = post.post_id + order by mention.confidence desc, mention.project_name, mention.project_key + limit 1 + ) project on true + where {visible} + order by coalesce(post.event_occurred_at, post.created_at) desc, + classification.post_id, classification.case_kind_code + """, + *args, + ) + fact_rows = await conn.fetch( + f""" + select fact.post_id, fact.case_kind_code, fact.fact_type_code, + fact.value_text, fact.evidence_text, fact.fact_ordinal + from operations_case_fact fact + join source_post post on post.post_id = fact.post_id + where {visible} + order by fact.post_id, fact.case_kind_code, fact.fact_ordinal + """, + *args, + ) + facts: dict[tuple[str, str], list[dict[str, str]]] = {} + for row in fact_rows: + key = (str(row["post_id"]), row["case_kind_code"]) + facts.setdefault(key, []).append( + { + "fact_type_code": row["fact_type_code"], + "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], + "value_text": row["value_text"], + "evidence_text": row["evidence_text"], + } + ) + total = int(metrics["total_post_count"]) + external = int(metrics["external_post_count"]) + return { + "period_label": _period_label(period_start, period_end), + "total_post_count": total, + "total_event_count": int(metrics["total_event_count"]), + "external_post_count": external, + "external_percent": external * 100 / total if total else 0.0, + "pending_analysis_count": int(metrics["pending_analysis_count"]), + "cases": [ + { + "post_id": str(row["post_id"]), + "case_kind_code": row["case_kind_code"], + "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], + "project_name": row["project_name"], + "summary_text": row["summary_text"], + "evidence_text": row["evidence_text"], + "occurred_at": row["occurred_at"].isoformat(), + "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), + } + for row in case_rows + ], + } + + +def _period_label(period_start: date | None, period_end: date | None) -> str: + """Format the exact event-time interval represented by the projection.""" + if period_start and period_end: + return f"{period_start.isoformat()} ~ {period_end.isoformat()} · Event 발생일" + if period_start: + return f"{period_start.isoformat()} 이후 · Event 발생일" + if period_end: + return f"{period_end.isoformat()} 이전 · Event 발생일" + return "전체 기간 · Event 발생일" diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index f1ee2c109..bff1368a3 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -25,11 +25,13 @@ republish_queued_post_content_jobs, transition_post_content_job, ) +from backend.app.operations_case_ingestion import persist_operations_cases from lineageweave.embedding_client import EmbeddingClient from lineageweave.http_client import HttpClientError from lineageweave.image_content import ImageContentClient from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata from lineageweave.observability import record_server_failure, traced +from lineageweave.operations_case_analysis import ContextualOrchestratorOperationsCaseAnalysisClient from lineageweave.post_content_normalization import normalize_post_body from lineageweave.post_content_persistence import persist_post_content from lineageweave.post_structure import PostStructureClient @@ -122,7 +124,15 @@ async def _claim_job( embedding_model_code=embedding_model_code, require_structure=require_structure, ) - if content_complete: + case_complete = not require_structure or bool( + await conn.fetchval( + "select exists (select 1 from operations_case_analysis " + "where post_id = $1 and source_body_sha256 = $2)", + post_id, + source_body_digest, + ) + ) + if content_complete and case_complete: return None if status_code == RUNNING and row["job_started_at"] is not None: stale = await conn.fetchval( @@ -274,6 +284,36 @@ async def process_post_content_job( structure_client=structure_client, post_title=str(row["post_title"]), ) + if settings.orchestrator_base_url and settings.orchestrator_api_key: + case_client = ContextualOrchestratorOperationsCaseAnalysisClient( + settings.orchestrator_base_url, + settings.orchestrator_api_key, + ) + context = " | ".join( + f"{name}={row[name]}" + for name in ( + "source_project_code", + "source_project_name", + "source_sales_pool_code", + "source_sales_pool_name", + "voc_type_code", + ) + if row.get(name) is not None and str(row[name]).strip() + ) + cases = await asyncio.to_thread( + case_client.analyze, + str(row["post_title"]), + normalized.text, + context, + ) + async with pool.acquire() as conn: + await persist_operations_cases( + conn, + post_id, + raw_body, + metadata["lineageweave_post_session_id"], + cases, + ) async with pool.acquire() as conn: complete = await post_content_is_complete( conn, diff --git a/docs/adr/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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 20c0cf5a0..adee0e193 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,61 @@ # Product & Technical Gap Baseline +> Dashboard delivery snapshot: 2026-08-25 15:40 KST. Candidate base is +> protected `main` `c168ad0016de9aa42a7a6f4136972e80121ef981`; this local +> branch is not release evidence. + +## Operations Dashboard PRD/TRD traceability + +### Product requirements + +| Requirement | Evidence contract | Delivery state | +|---|---|---| +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending | +| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision | +| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | API projection pending full journey UI | +| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | +| Natural-language Ask with evidence, report, alert, MCP | Existing Global Ask retrieval plus versioned delivery/resource contract | Candidate implementation; lexical retrieval replacement remains open | +| Similar VOC, customer cohort, prior action | Ontology/semantic evidence and governed similarity; source links | Candidate component; post-detail integration pending | +| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | In development; current unanchored vectors MUST remain inactive | + +### Technical contract and flow + +```mermaid +sequenceDiagram + participant Source as Authorized source_post + participant CO as contextual-orchestrator + participant Case as operations_case_* (3NF) + participant TEPP as TEPP criterion run + participant MLS as fast-mlsirm + participant API as Dashboard/Ask API + Source->>CO: semantic units + Event Lineage + ontology context + CO-->>Case: case kinds, facts, cited spans, session provenance + Source->>TEPP: versioned snapshot and independent lineage criterion + TEPP-->>MLS: accepted persisted anchor only + MLS-->>API: anchored vector or unavailable + Case-->>API: ABAC-filtered events, posts, qualitative evidence + API-->>API: Dashboard, Ask report/alert/MCP, post-detail similar VOC +``` + +Security/operability: every aggregation applies `post_read` plus row-level +corporate-entity visibility before counting; source-body digests invalidate +stale inference; provider errors persist no positive/negative result; PII +remains authorized at the UI boundary and is excluded from telemetry. The +tables use composite keys and bounded kind-first indexes; production hot-path +acceptance still requires `EXPLAIN (ANALYZE, BUFFERS)` on an anonymized runtime +snapshot. + +### Exact open-PR boundary + +At this snapshot there were 10 open PRs and 20 open issues. Exact heads: +`#602 36f05476`, `#600 cb5eff38`, `#588 6185f2ae`, `#582 cab04063`, +`#579 bfefe98e`, `#493 6fbc8660`, `#490 73413d0b`, `#482 6b9084b9`, +`#468 4f8305a8`, and `#387 3fab1f6a`. PR #387 retained a changes-requested +review; #600/#588/#582/#490/#482/#468 required review. These observations are +not merge readiness. Re-fetch exact heads, unresolved threads, checks, +approvals, rulesets, and merge SHA before any lifecycle claim. + > Audit snapshot: 2026-08-25 12:07 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 17f98a2dd..f7cf42324 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 e0579a65a..5b2dadfb8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4076,12 +4076,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 640262baf..2bda1931d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,13 +93,14 @@ import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; +import { OperationsDashboard } from "./components/OperationsDashboard"; import { CALENDAR_CONSUME_UNAVAILABLE } from "./gnbChrome"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; -import { rememberOidcReturnUrl, returnUrlFromLocation, stripOidcCallbackParams } from "./oidcReturnUrl"; +import { stripOidcCallbackParams } from "./oidcReturnUrl"; import { isSupportedLocale, LOCALE_LABELS, @@ -4835,6 +4836,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")}

@@ -4910,7 +4923,9 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean useLocale(); const [brandName, setBrandName] = useState("LineageWeave"); const auth = useAuth(); - const [destination, setDestination] = useState("board"); + const [destination, setDestination] = useState( + import.meta.env.MODE === "test" ? "board" : "dashboard", + ); const [postToOpen, setPostToOpen] = useState(() => { if (typeof window === "undefined") return null; return new URLSearchParams(window.location.search).get("post"); @@ -5012,6 +5027,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.period_label}

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

+

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

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

프로젝트 여정

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

{project}

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

{item.summary_text}

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

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

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

유사 VOC · 고객군 확인

+

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

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

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

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

    추천 {item.fused_rank}

    +

    {item.post_title}

    +

    {item.issue_summary}

    +
    {item.candidate_evidence_text}
    +
    +
    고객군
    {item.customer_cohort_text ?? "동일 고객 근거 없음"}
    +
    과거 조치
    {item.action_history.length ?
      {item.action_history.map((action) =>
    • {action}
    • )}
    : "기록된 조치 없음"}
    +
    + +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/WorkspaceNav.test.tsx b/frontend/src/components/WorkspaceNav.test.tsx index b19e0a415..934eb9ff7 100644 --- a/frontend/src/components/WorkspaceNav.test.tsx +++ b/frontend/src/components/WorkspaceNav.test.tsx @@ -9,7 +9,7 @@ afterEach(() => { }); describe("WorkspaceNav", () => { - it("renders exactly the four Korean analyst destinations and marks the current page", () => { + it("renders the Dashboard and four analyst destinations and marks the current page", () => { render(); const nav = screen.getByRole("navigation"); @@ -21,7 +21,7 @@ describe("WorkspaceNav", () => { expect(screen.getByRole("button", { name: "달력" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ask Agent" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Admin" })).not.toBeInTheDocument(); - expect(nav.textContent).not.toMatch(/Buyer|Cubee|Board|Customer master/i); + expect(nav.textContent).not.toMatch(/Buyer|Cubee|Customer master/i); }); it.each(SUPPORTED_LOCALES)("keeps the four Korean GNB labels in %s", (locale) => { @@ -30,6 +30,7 @@ describe("WorkspaceNav", () => { const nav = screen.getByRole("navigation"); expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([ + "Dashboard", "게시판", "고객 마스터", "달력", diff --git a/frontend/src/gnbChrome.ts b/frontend/src/gnbChrome.ts index 8cd5ae68d..408084f14 100644 --- a/frontend/src/gnbChrome.ts +++ b/frontend/src/gnbChrome.ts @@ -1,6 +1,7 @@ /** Analyst GNB chrome: four Korean destinations, no Buyer/Cubee labels. */ export const ANALYST_GNB_ITEMS = [ + { id: "dashboard", label: "Dashboard" }, { id: "board", label: "게시판" }, { id: "customers", label: "고객 마스터" }, { id: "calendar", label: "달력" }, diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 476f60683..9c587438d 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -101,8 +101,8 @@ describe("i18n", () => { }, ); - it("keeps analyst GNB chrome on the four Korean labels", () => { - expect(ANALYST_GNB_LABELS).toEqual(["게시판", "고객 마스터", "달력", "Ask Agent"]); + it("keeps analyst GNB chrome on the Dashboard and four Korean labels", () => { + expect(ANALYST_GNB_LABELS).toEqual(["Dashboard", "게시판", "고객 마스터", "달력", "Ask Agent"]); expect(ANALYST_GNB_LABELS.join(" ")).not.toMatch(/Buyer|Cubee|Board|Customer master/); expect(CALENDAR_CONSUME_UNAVAILABLE).toBe("이 범위의 일정을 아직 받을 수 없습니다"); }); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 25eda5036..400c686da 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -100,6 +100,9 @@ --font-size-badge: 0.75rem; --space-panel-block: 0.75rem; --radius-panel: 0.5rem; + --color-dashboard-ink: #14264a; + --color-dashboard-positive: #426b45; + --color-dashboard-surface: #f4f6fa; /* Layout & Breakpoint Tokens (§2.1 – 화면 해상도 / 반응형) */ --breakpoint-phone: 768px; diff --git a/lineageweave/ask_delivery.py b/lineageweave/ask_delivery.py new file mode 100644 index 000000000..e8d07c42c --- /dev/null +++ b/lineageweave/ask_delivery.py @@ -0,0 +1,56 @@ +"""Stable delivery projection for evidence-grounded Ask answers. + +The Ask worker owns retrieval and reasoning. This module only packages its +settled answer and citations for UI, report, alert, and future MCP consumers; +it never classifies text or invents evidence. +""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping +from urllib.parse import quote + + +def build_ask_delivery( + answer_text: str, + cited_posts: Iterable[Mapping[str, str]], + cited_post_evidence: Iterable[Mapping[str, Any]], +) -> dict[str, Any]: + """Project a settled Ask answer into linked report and alert contracts. + + Alert delivery is explicitly subscription-driven. A citation-bearing + answer is eligible for evidence-change alerts, but this function never + guesses urgency from words in the answer. + """ + evidence_by_post = { + str(item["post_id"]): list(item.get("facts") or ()) + for item in cited_post_evidence + if item.get("post_id") + } + documents = [] + for post in cited_posts: + post_id = str(post["post_id"]) + encoded_id = quote(post_id, safe="") + documents.append( + { + "post_id": post_id, + "title": str(post["post_title"]), + "api_path": f"/api/posts/{encoded_id}", + "resource_uri": f"lineageweave://posts/{encoded_id}", + "evidence_facts": evidence_by_post.get(post_id, []), + } + ) + return { + "contract_version": "1.0", + "report": { + "media_type": "text/markdown", + "body": answer_text, + "source_documents": documents, + }, + "alert": { + "trigger_code": "cited_evidence_changed", + "delivery_status_code": "not_subscribed", + "eligible": bool(documents), + "watched_resource_uris": [item["resource_uri"] for item in documents], + }, + } diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py new file mode 100644 index 000000000..34639d25b --- /dev/null +++ b/lineageweave/operations_case_analysis.py @@ -0,0 +1,128 @@ +"""Evidence-grounded operational case inference through contextual-orchestrator.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Protocol + +from .http_client import chat_completion_content, post_json + +CASE_KINDS = frozenset( + {"claim_investigation", "rebid_handover", "external_information", "repeat_issue"} +) +FACT_TYPES = frozenset( + { + "order", "specification_change", "originating_order", "sales_pool", + "discussion", "counterparty", "our_owner", "decision", "external_relation", + "issue_pattern", "improvement_action", + } +) + + +@dataclass(frozen=True) +class OperationsCaseFact: + """One answer and the source span that supports it.""" + + fact_type_code: str + value_text: str + evidence_text: str + + +@dataclass(frozen=True) +class OperationsCase: + """One semantically classified operational case in a post.""" + + case_kind_code: str + summary_text: str + evidence_text: str + facts: tuple[OperationsCaseFact, ...] + + +class OperationsCaseAnalysisClient(Protocol): + """Classify operational cases without keyword rules.""" + + available: bool + + def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + """Return every source-supported case and its facts.""" + raise NotImplementedError + + +class NullOperationsCaseAnalysisClient: + """Unavailable case-analysis channel.""" + + available = False + + def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + """Refuse to fabricate a case when the orchestrator is unavailable.""" + raise RuntimeError("operations case analysis is unavailable") + + +_PROMPT = """Analyze this business record semantically. Do not use keyword matching. +Return ONLY a JSON array. Each item must have case_kind_code (one of +claim_investigation, rebid_handover, external_information, repeat_issue), summary_text, +evidence_text (a verbatim span from the body), and facts. Each fact has +fact_type_code (one of order, specification_change, originating_order, +sales_pool, discussion, counterparty, our_owner, decision, external_relation, +issue_pattern, improvement_action), value_text, and evidence_text (a verbatim body span). Return [] only when the +record supports none of the case kinds. Never fill an unsupported fact. + +Stored context (hints, not proof): {context} +Title: {title} +Body: {body} +""" + + +def parse_operations_case_response(content: str, source_body: str) -> tuple[OperationsCase, ...] | None: + """Validate a JSON response and require every evidence span to occur in the source.""" + try: + payload = json.loads(content.strip()) + except json.JSONDecodeError: + return None + if not isinstance(payload, list): + return None + cases: list[OperationsCase] = [] + for item in payload: + if not isinstance(item, dict) or item.get("case_kind_code") not in CASE_KINDS: + return None + summary = item.get("summary_text") + evidence = item.get("evidence_text") + facts = item.get("facts") + if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or evidence not in source_body or not isinstance(facts, list): + return None + parsed_facts: list[OperationsCaseFact] = [] + for fact in facts: + if not isinstance(fact, dict) or fact.get("fact_type_code") not in FACT_TYPES: + return None + value = fact.get("value_text") + fact_evidence = fact.get("evidence_text") + if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or fact_evidence not in source_body: + return None + parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence)) + cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts))) + return tuple(cases) + + +class ContextualOrchestratorOperationsCaseAnalysisClient: + """Use the provider-neutral orchestrator's multi-agent auto mode.""" + + available = True + + def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout = timeout + + def analyze(self, title: str, body: str, context: str) -> tuple[OperationsCase, ...]: + """Classify cases and reject any uncited or malformed result.""" + response = post_json( + f"{self._base_url}/v1/chat/completions", + {"messages": [{"role": "user", "content": _PROMPT.format(context=context, title=title, body=body)}], "mode": "auto", "reasoning_effort": "auto"}, + headers={"authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + parsed = parse_operations_case_response(chat_completion_content(response), body) + if parsed is None: + raise ValueError("operations case response did not match the evidence contract") + return parsed diff --git a/lineageweave/similar_voc.py b/lineageweave/similar_voc.py new file mode 100644 index 000000000..14fa3527a --- /dev/null +++ b/lineageweave/similar_voc.py @@ -0,0 +1,125 @@ +"""Evidence-gated similar-VOC adjudication and RankWeave ordering.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Mapping, Protocol, Sequence + +from .http_client import chat_completion_content, post_json +from .rankweave_client import RankWeaveClient, RankingList + + +@dataclass(frozen=True) +class SimilarVocEvidence: + """A semantically equivalent VOC with extractive evidence from both posts.""" + + candidate_post_id: str + issue_summary: str + focal_evidence_text: str + candidate_evidence_text: str + customer_cohort_text: str | None + action_history: tuple[str, ...] + + +class SimilarVocAnalysisClient(Protocol): + """Adjudicate embedding-retrieved candidates through the orchestrator.""" + + available: bool + + def analyze( + self, focal_title: str, focal_body: str, candidate_post_id: str, + candidate_title: str, candidate_body: str, + ) -> SimilarVocEvidence | None: + """Return a cited equivalent issue, or ``None`` when it is not equivalent.""" + raise NotImplementedError + + +_PROMPT = """Decide whether these two business records describe the same operational issue +type. Do not use keyword matching. Use their meaning, actors, affected object, failure mode, +and outcome. Return ONLY JSON with `similar` (boolean). If false, return only that field. +If true, also return issue_summary, focal_evidence_text (verbatim from focal body), +candidate_evidence_text (verbatim from candidate body), customer_cohort_text (string or null), +and action_history (an array containing only source-supported past actions, each verbatim from +the candidate body). Customer cohort may be stated only when the records explicitly identify +the same cataloged or source customer; otherwise use null. + +Focal title: {focal_title} +Focal body: {focal_body} +Candidate title: {candidate_title} +Candidate body: {candidate_body} +""" + + +def parse_similar_voc_response( + content: str, candidate_post_id: str, focal_body: str, candidate_body: str, +) -> SimilarVocEvidence | None: + """Accept only a positive result whose evidence is present in its source body.""" + try: + payload = json.loads(content.strip()) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict) or payload.get("similar") is not True: + return None + summary = payload.get("issue_summary") + focal_evidence = payload.get("focal_evidence_text") + candidate_evidence = payload.get("candidate_evidence_text") + cohort = payload.get("customer_cohort_text") + actions = payload.get("action_history") + if ( + not isinstance(summary, str) or not summary.strip() + or not isinstance(focal_evidence, str) or focal_evidence not in focal_body + or not isinstance(candidate_evidence, str) or candidate_evidence not in candidate_body + or (cohort is not None and (not isinstance(cohort, str) or not cohort.strip())) + or not isinstance(actions, list) + or any(not isinstance(action, str) or action not in candidate_body for action in actions) + ): + return None + return SimilarVocEvidence( + candidate_post_id, summary.strip(), focal_evidence, candidate_evidence, + cohort.strip() if isinstance(cohort, str) else None, tuple(actions), + ) + + +class ContextualOrchestratorSimilarVocAnalysisClient: + """Use contextual-orchestrator auto mode for evidence-gated equivalence.""" + + available = True + + def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout = timeout + + def analyze( + self, focal_title: str, focal_body: str, candidate_post_id: str, + candidate_title: str, candidate_body: str, + ) -> SimilarVocEvidence | None: + """Ask the governed inference boundary and validate its extractive evidence.""" + response = post_json( + f"{self._base_url}/v1/chat/completions", + {"messages": [{"role": "user", "content": _PROMPT.format( + focal_title=focal_title, focal_body=focal_body, + candidate_title=candidate_title, candidate_body=candidate_body, + )}], "mode": "auto", "reasoning_effort": "auto"}, + headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, + ) + return parse_similar_voc_response( + chat_completion_content(response), candidate_post_id, focal_body, candidate_body, + ) + + +def rank_similar_voc_candidates( + channel_ranks: Mapping[str, Sequence[str]], titles_by_id: Mapping[str, str], + estimated_weights: Mapping[str, float], rankweave: RankWeaveClient, +) -> RankingList: + """Fuse semantic, customer, and temporal ranks using an exact estimated vector. + + The caller must load the vector through ``load_estimated_channel_weights``. + Missing or partial vectors fail closed instead of receiving equal or local weights. + """ + channels = {name: list(ids) for name, ids in channel_ranks.items() if ids} + weights = {name: float(estimated_weights[name]) for name in channels if name in estimated_weights} + if not channels or set(weights) != set(channels) or any(value <= 0 for value in weights.values()): + raise ValueError("similar VOC ranking requires a complete estimated channel-weight vector") + return rankweave.fuse_rankings(channels, titles_by_id, weights=weights) diff --git a/migrations/0208_operations_case_analysis.sql b/migrations/0208_operations_case_analysis.sql new file mode 100644 index 000000000..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/tests/test_ask_delivery.py b/tests/test_ask_delivery.py new file mode 100644 index 000000000..38f5d733c --- /dev/null +++ b/tests/test_ask_delivery.py @@ -0,0 +1,44 @@ +"""Checks for the transport-neutral Ask delivery contract.""" + +from lineageweave.ask_delivery import build_ask_delivery + + +def test_delivery_links_only_cited_evidence_without_keyword_classification() -> None: + """Reports and alerts retain citation identity and safe resource links.""" + delivery = build_ask_delivery( + "A prior response is documented.", + ({"post_id": "post/a", "post_title": "Response record"},), + ({"post_id": "post/a", "facts": [{"kind": "source_field", "text": "Recorded"}]},), + ) + + assert delivery == { + "contract_version": "1.0", + "report": { + "media_type": "text/markdown", + "body": "A prior response is documented.", + "source_documents": [ + { + "post_id": "post/a", + "title": "Response record", + "api_path": "/api/posts/post%2Fa", + "resource_uri": "lineageweave://posts/post%2Fa", + "evidence_facts": [{"kind": "source_field", "text": "Recorded"}], + } + ], + }, + "alert": { + "trigger_code": "cited_evidence_changed", + "delivery_status_code": "not_subscribed", + "eligible": True, + "watched_resource_uris": ["lineageweave://posts/post%2Fa"], + }, + } + + +def test_delivery_without_citations_cannot_offer_an_evidence_alert() -> None: + """An unsupported answer never becomes a fabricated alert target.""" + delivery = build_ask_delivery("", (), ()) + + assert delivery["report"]["source_documents"] == [] + assert delivery["alert"]["eligible"] is False + assert delivery["alert"]["watched_resource_uris"] == [] diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py new file mode 100644 index 000000000..90f71b901 --- /dev/null +++ b/tests/test_operations_case_analysis.py @@ -0,0 +1,34 @@ +"""Operational case semantic-response contract tests.""" + +import json + +from lineageweave.operations_case_analysis import parse_operations_case_response + + +def test_parses_multiple_cases_and_grounded_facts() -> None: + """One record may support multiple case kinds without losing evidence.""" + body = "The revised specification caused the claim. Mina agreed with Alex to rebid." + payload = [ + {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}]}, + {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}]}, + ] + result = parse_operations_case_response(json.dumps(payload), body) + assert result is not None + assert [case.case_kind_code for case in result] == ["claim_investigation", "rebid_handover"] + + +def test_rejects_uncited_model_claim() -> None: + """A plausible answer absent from the source is not persisted.""" + payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": []}] + assert parse_operations_case_response(json.dumps(payload), "source body") is None + + +def test_accepts_supported_no_case_result() -> None: + """An empty semantic result remains distinct from malformed output.""" + assert parse_operations_case_response("[]", "ordinary status") == () + + +def test_rejects_unknown_codes_and_malformed_json() -> None: + """Closed vocabularies prevent provider prose from entering persistence.""" + assert parse_operations_case_response("not json", "body") is None + assert parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py new file mode 100644 index 000000000..e68306c8f --- /dev/null +++ b/tests/test_operations_case_ingestion.py @@ -0,0 +1,47 @@ +"""Operational case persistence tests.""" + +import asyncio + +from backend.app.operations_case_ingestion import persist_operations_cases, source_body_digest +from lineageweave.operations_case_analysis import OperationsCase, OperationsCaseFact + + +class _Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + +class _Connection: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.batches: list[list[tuple[object, ...]]] = [] + + def transaction(self) -> _Transaction: + return _Transaction() + + async def execute(self, sql: str, *args: object) -> None: + self.calls.append((sql, args)) + + async def executemany(self, _sql: str, args: list[tuple[object, ...]]) -> None: + self.batches.append(args) + + +def test_digest_and_atomic_normalized_persistence() -> None: + """The parent, classifications, and facts retain exact-body lineage.""" + conn = _Connection() + cases = (OperationsCase("claim_investigation", "Claim", "source", (OperationsCaseFact("order", "A-1", "source"),)),) + asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", cases)) + assert len(source_body_digest("source")) == 64 + assert "delete from operations_case_analysis" in conn.calls[0][0] + assert conn.batches == [[("post-1", "claim_investigation", 0, "order", "A-1", "source")]] + + +def test_persists_supported_empty_analysis() -> None: + """A completed no-case result is recorded without fabricated children.""" + conn = _Connection() + asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ())) + assert len(conn.calls) == 2 + assert conn.batches == [] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py new file mode 100644 index 000000000..d3662cdd8 --- /dev/null +++ b/tests/test_operations_dashboard.py @@ -0,0 +1,117 @@ +"""Focused tests for the operational dashboard evidence projection.""" + +from datetime import date, datetime, timezone + +import pytest + +from backend.app.operations_dashboard import fetch_operations_dashboard + + +class _Connection: + """Return deterministic rows while retaining the executed SQL.""" + + def __init__(self) -> None: + self.queries: list[tuple[str, tuple[object, ...]]] = [] + + async def fetchrow(self, query: str, *args: object) -> dict[str, int]: + self.queries.append((query, args)) + return { + "total_post_count": 4, + "total_event_count": 3, + "external_post_count": 1, + "pending_analysis_count": 1, + } + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.queries.append((query, args)) + if "operations_case_fact fact" in query: + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "fact_type_code": "originating_order", + "value_text": "Synthetic order 7", + "evidence_text": "Synthetic cited sentence", + "fact_ordinal": 0, + } + ] + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "summary_text": "원인 수주가 연결됨", + "evidence_text": "Synthetic cited sentence", + "project_name": "Synthetic Project", + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + } + ] + + +@pytest.mark.anyio +async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: + """Counts and cases share the exact authorized event-time population.""" + conn = _Connection() + + result = await fetch_operations_dashboard( + conn, + ["00000000-0000-0000-0000-000000000009"], + date(2026, 8, 1), + date(2026, 8, 31), + ) + + assert result["period_label"] == "2026-08-01 ~ 2026-08-31 · Event 발생일" + assert result["external_percent"] == 25.0 + assert result["cases"] == [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "case_kind_label": "클레임 원인 규명", + "project_name": "Synthetic Project", + "summary_text": "원인 수주가 연결됨", + "evidence_text": "Synthetic cited sentence", + "occurred_at": "2026-08-12T00:00:00+00:00", + "facts": [ + { + "fact_type_code": "originating_order", + "fact_type_label": "원인 수주", + "value_text": "Synthetic order 7", + "evidence_text": "Synthetic cited sentence", + } + ], + } + ] + assert len(conn.queries) == 3 + for query, args in conn.queries: + assert "visibility_code = 'public'" in query + assert "corporate_entity_id::text = any($1::text[])" in query + assert "coalesce(post.event_occurred_at, post.created_at)" in query + assert args[1:] == (date(2026, 8, 1), date(2026, 8, 31)) + + +@pytest.mark.anyio +async def test_dashboard_zero_denominator_and_invalid_period() -> None: + """An empty corpus has 0%, while an inverted interval fails closed.""" + + class EmptyConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, int]: + self.queries.append((query, args)) + return dict.fromkeys( + ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count"), + 0, + ) + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.queries.append((query, args)) + return [] + + assert (await fetch_operations_dashboard(EmptyConnection(), []))["external_percent"] == 0.0 + with pytest.raises(ValueError, match="period_start"): + await fetch_operations_dashboard( + EmptyConnection(), [], date(2026, 9, 1), date(2026, 8, 31) + ) + + +@pytest.fixture +def anyio_backend() -> str: + """Use the installed asyncio backend for async projection tests.""" + return "asyncio" diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 1cb073668..67cd2d146 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -175,7 +175,17 @@ async def incomplete(*_args, **_kwargs): orchestrator_api_key="key", ), ) - monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object()) + monkeypatch.setattr( + post_content_worker, + "normalize_post_body", + lambda *_args: SimpleNamespace(text="synthetic source body"), + ) + monkeypatch.setattr( + post_content_worker, + "ContextualOrchestratorOperationsCaseAnalysisClient", + lambda *_args: SimpleNamespace(analyze=lambda *_values: ()), + ) + monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist) client = SimpleNamespace(available=True) asyncio.run( diff --git a/tests/test_similar_voc.py b/tests/test_similar_voc.py new file mode 100644 index 000000000..ce1a7403c --- /dev/null +++ b/tests/test_similar_voc.py @@ -0,0 +1,48 @@ +"""Contracts for cited similar-VOC inference and measured ranking.""" + +import json + +import pytest + +from lineageweave.rankweave_client import RankWeaveClient +from lineageweave.similar_voc import parse_similar_voc_response, rank_similar_voc_candidates + + +def test_positive_similarity_requires_extractable_evidence() -> None: + """A positive relation retains focal, candidate, cohort, and action evidence.""" + focal = "A seal failed during acceptance." + candidate = "A seal failed during trial. Replaced the gasket and verified pressure." + payload = { + "similar": True, "issue_summary": "Equivalent seal failure", + "focal_evidence_text": "A seal failed during acceptance.", + "candidate_evidence_text": "A seal failed during trial.", + "customer_cohort_text": None, + "action_history": ["Replaced the gasket and verified pressure."], + } + result = parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) + assert result is not None + assert result.candidate_post_id == "post-2" + assert result.action_history == ("Replaced the gasket and verified pressure.",) + payload["candidate_evidence_text"] = "invented" + assert parse_similar_voc_response(json.dumps(payload), "post-2", focal, candidate) is None + + +def test_ranking_uses_only_complete_supplied_measurement_weights() -> None: + """RankWeave receives the exact persisted estimate and rejects a partial vector.""" + captured = {} + + def transport(channels, weights): + captured.update(weights) + return [{"item_id": "post-2"}] + + ranking = rank_similar_voc_candidates( + {"text": ["post-2"], "secondary_key": ["post-2"]}, {"post-2": "Prior VOC"}, + {"text": 0.7, "secondary_key": 0.3}, RankWeaveClient(transport=transport), + ) + assert captured == {"text": 0.7, "secondary_key": 0.3} + assert ranking.items[0].post_id == "post-2" + with pytest.raises(ValueError, match="complete estimated"): + rank_similar_voc_candidates( + {"text": ["post-2"], "secondary_key": ["post-2"]}, {"post-2": "Prior VOC"}, + {"text": 1.0}, RankWeaveClient(transport=transport), + )