diff --git a/.dev.vars.example b/.dev.vars.example index 076d5003..89be1115 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -7,3 +7,30 @@ # Web worker privacy-preserving request log IP HMAC key. LOG_IP_KEY=dev-only-log-ip-key-change-me + +# ── AI assistant (docs/spec/ai-assistant.md) ─────────────────────────────────────────────────────── +# Provider API key (OpenRouter today) — SECRET. Without it /assistant/chat returns a controlled 503 +# (endpoint unprovisioned). In the cloud set via `wrangler secret put ASSISTANT_API_KEY --name `, +# never committed. +# ASSISTANT_API_KEY= +# +# All model traffic MUST transit the Cloudflare AI Gateway (fail closed): without AI_GATEWAY_BASE_URL the +# assistant returns 503 rather than calling the provider directly. Set both to your gateway (account +# ); AI_GATEWAY_ID is the gateway slug used to route Workers AI embeddings. +# ASSISTANT_MODEL is the provider-scoped model id — swap models by editing it alone. +# AI_GATEWAY_BASE_URL=https://gateway.ai.cloudflare.com/v1///openrouter/v1 +# AI_GATEWAY_ID= +# ASSISTANT_MODEL=google/gemma-4-31b-it # append ':free' for the rate-limited free tier +# +# NOTE: Workers AI (`AI`) and Vectorize (`VECTORIZE`) have NO local miniflare emulation, and +# vite.config.ts configures no remote bindings — so under a plain `pnpm dev` the assistant route +# degrades (RAG grounding/semantic_search no-op, then 503 without a key). To exercise the assistant +# locally, run with remote bindings (`wrangler dev --remote`) against the dev account's resources. +# +# Optional: enables the one-shot schema-corpus seed route POST /assistant/reindex (off when unset). +# High-entropy value; sent as `Authorization: Bearer `. See app/lib/assistant/README.md. +# ASSISTANT_SEED_TOKEN= +# +# Transcript signing key (HMAC-SHA-256 over server-emitted messages). +# Prod: `wrangler secret put ASSISTANT_HMAC_KEY`. Never commit the real value. +ASSISTANT_HMAC_KEY=dev-only-assistant-hmac-key-change-me diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5045ffe..de54a104 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,11 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + # Pinned to an exact minor: node:sqlite bundles SQLite, whose version (and thus the EXPLAIN + # opcode universe the assistant opcode-guard test pins) changes with the Node minor. 22.23.0 + # ships SQLite 3.51.3, which READ_ONLY_OPCODES/KNOWN_SQLITE_VERSIONS cover. Bump deliberately + # and re-harvest the allowlist (see sql-opcode-guard.test.ts) rather than letting 22.x float. + node-version: 22.23.0 cache: pnpm - run: pnpm install --frozen-lockfile - name: Dependency audit @@ -86,9 +90,19 @@ jobs: - name: Typecheck if: ${{ !cancelled() }} run: pnpm typecheck + # `pnpm test` also runs the assistant adversarial suites — the two-layer SQL guard + # (sql-guard-adversarial), the EXPLAIN-opcode allowlist (sql-opcode-guard), and the + # prompt-injection boundary tests (system-prompt / tool-results / chat-input) — so a regression in + # any read-only / injection defence blocks merge (launch gate §9.9, issue #83). - name: Test if: ${{ !cancelled() }} run: pnpm test + # Launch gate §9.9 (issue #83): replays the golden report fixtures through the real bind/reconcile + # pipeline and asserts A–E2 (canonical amount_eur, default filters, rollup reconciliation, no prose + # figures). Blocking, so a model / schema / prompt change can't silently regress a published report. + - name: Golden reports + if: ${{ !cancelled() }} + run: pnpm test:golden # Docs integrity (#102): fail on a dangling docs reference in source or an # unindexed doc. The checker self-tests first, so the gate is itself gated. - name: Docs integrity @@ -96,3 +110,9 @@ jobs: run: | pnpm check:docs:test pnpm check:docs + # Root-level tooling (scripts/*.mjs) lives outside the turbo/vitest workspaces, so `pnpm test` never + # ran its node:test suites. Gate them here — the CI-provisioning + teardown tools (ensure-kv-namespace, + # ensure-voice-provider, teardown-remote, reap-previews, load-eop) are deploy-critical and must stay covered. + - name: Script tests + if: ${{ !cancelled() }} + run: node --test scripts/*.test.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c6cbe4b5..1de17c29 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,11 +8,17 @@ name: Deploy (web + etl → Cloudflare) # Event → target mapping (see the `detect` job): # - push to `main` → staging (continuous staging on every merge to main, so staging leads prod) # - version TAG (v*) → production (cut a release: git tag v1.0.1 && git push origin v1.0.1) -# - workflow_dispatch → the chosen environment -# midt-bg/sigma is the single repo carrying both the `staging` and `production` Environments (the former +# - workflow_dispatch → the chosen environment (incl. `dev`) +# midt-bg/sigma is the single repo carrying the `dev`, `staging` and `production` Environments (the former # midt-bg/sigma-stage mirror was consolidated into it). Production is cut deliberately as a tag; staging # tracks `main`. (To deploy on GitHub *Releases* instead of tags, swap the tag trigger for # `release: { types: [published] }`.) +# +# `dev` is a long-lived, manual-only target: deploy ANY branch to it on demand from the Actions tab or +# gh workflow run deploy.yml --ref -f environment=dev +# It reuses the same render/guard machinery — it just needs a `dev` GitHub Environment provisioned with +# its own SIGMA_* names + a dev D1 id. Ephemeral per-PR previews are a separate workflow (preview.yml). +# Setup runbook: docs/dev-environments.md. on: push: @@ -24,7 +30,14 @@ on: description: Deploy target type: choice options: + - dev - staging + # Once provision-environments.sh has been applied, selecting `production` here only + # works when the workflow is dispatched from a `v*` tag ref, e.g.: + # gh workflow run deploy.yml --ref v1.0.1 -f environment=production + # Dispatching from a branch ref (e.g. main) is refused by the environment tag policy. + # Admins may bypass this via the `admins_can_bypass` setting (Settings → Environments + # → production) for emergency situations. - production default: staging @@ -44,12 +57,19 @@ jobs: # workflow_dispatch -> the chosen environment # tag v* -> production # push to main -> staging (continuous staging — main always leads prod) + # Pass the GitHub expressions through `env:` and reference the quoted shell vars, rather than + # interpolating `${{ github.ref }}` (attacker-influenceable via a crafted tag/branch name) + # directly into the script body — that would let a ref like `v"; ; "` break out of the test. + env: + GITHUB_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + INPUT_ENV: ${{ inputs.environment }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "env=${{ inputs.environment }}" >> "$GITHUB_OUTPUT" - elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "env=$INPUT_ENV" >> "$GITHUB_OUTPUT" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then echo "env=production" >> "$GITHUB_OUTPUT" - elif [ "${{ github.ref }}" = "refs/heads/main" ]; then + elif [ "$GITHUB_REF" = "refs/heads/main" ]; then echo "env=staging" >> "$GITHUB_OUTPUT" else echo "env=" >> "$GITHUB_OUTPUT" @@ -59,6 +79,11 @@ jobs: needs: detect if: needs.detect.outputs.env != '' runs-on: ubuntu-latest + # Explicit cap instead of the 360-min GitHub default: a full build + two-Worker wrangler deploy + # runs in single-digit minutes, so a job past 10 min is hung (stalled network I/O, wedged + # wrangler) — fail fast rather than burn a 6-hour slot. The required-reviewers wait on + # `production` does NOT count against this; the timer starts only once the job hits a runner. + timeout-minutes: 10 environment: ${{ needs.detect.outputs.env }} concurrency: group: deploy-${{ needs.detect.outputs.env }} @@ -76,6 +101,20 @@ jobs: SIGMA_CSV_CACHE_NAME: ${{ vars.SIGMA_CSV_CACHE_NAME }} SIGMA_REPORTS_NAME: ${{ vars.SIGMA_REPORTS_NAME }} SIGMA_VECTORIZE_NAME: ${{ vars.SIGMA_VECTORIZE_NAME }} + # Per-build dedup freshness `c` — wrangler-render stamps this over the committed "dev" so a code-only + # deploy (report shape / FX / CPV logic) busts the report cache, and each env's build keys stay distinct. + SIGMA_BUILD_ID: ${{ github.sha }} + # Master kill-switch override (#83), per-environment: set the SIGMA_ASSISTANT_ENABLED *variable* to + # "true" on the environments that should serve the assistant (dev now; staging at go-live). Unset → + # wrangler-render leaves the committed fail-dark "false", so production stays dark until deliberately + # flipped. Killing a live assistant is then just clearing this var + redeploy (or the runtime flag). + SIGMA_ASSISTANT_ENABLED: ${{ vars.SIGMA_ASSISTANT_ENABLED }} + # Runtime deploy-env for the §9.3 HMAC gate (ADR-0012). Set the SIGMA_ENVIRONMENT *variable* per + # GitHub Environment: "production" and "staging" are public/unauthenticated → the gate REQUIRES the + # signing key (fail-closed 503 without it); any other value (or unset → committed "development") + # fails open. wrangler-render stamps this into ENVIRONMENT. Not derived from import.meta.env.PROD, + # which Vite inlines true for the staging build too (it would misclassify staging as production). + SIGMA_ENVIRONMENT: ${{ vars.SIGMA_ENVIRONMENT }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -121,8 +160,34 @@ jobs: check "$SIGMA_VECTORIZE_NAME" sigma-assistant SIGMA_VECTORIZE_NAME [ "$fail" = 0 ] || { echo "Refusing to deploy '${{ needs.detect.outputs.env }}' with production resource names."; exit 1; } + # GitOps: enforce this environment's report-dedup KV namespace from git (idempotent create-if-absent) + # and hand its id to wrangler-render via SIGMA_DEDUP_KV_ID. One namespace per real env — dev (shared + # with the ephemeral previews), staging, prod — kept distinct by title so no env can touch another's + # cache. `production` maps to the short `prod` suffix; dev/staging use the env name verbatim. + - name: Ensure DEDUP_KV namespace + if: steps.guard.outputs.ok == 'true' && success() + run: | + case "${{ needs.detect.outputs.env }}" in + production) title=sigma-dedup-prod ;; + *) title="sigma-dedup-${{ needs.detect.outputs.env }}" ;; + esac + id="$(node scripts/ensure-kv-namespace.mjs "$title")" + echo "::add-mask::$id" + echo "SIGMA_DEDUP_KV_ID=$id" >> "$GITHUB_ENV" + + # GitOps: ensure the account-scoped `bggpt-voice` custom provider (+ the shared `sigma-assistant` + # gateway) the VOICE lane calls directly. Provider-only — the assistant hits the gateway's provider + # endpoints for transcription, NOT a dynamic route (dynamic routing can't carry audio; see ADR-0011). + # Idempotent: a no-op when the provider already exists. VOICE_ASSISTANT_API_KEY is used only to + # first-create the provider; absent ⇒ the existing provider is reused as-is. + - name: Ensure voice AI-Gateway provider + if: steps.guard.outputs.ok == 'true' && success() + env: + VOICE_ASSISTANT_API_KEY: ${{ secrets.VOICE_ASSISTANT_API_KEY }} + run: node scripts/ensure-voice-provider.mjs --apply + - name: Typecheck - if: steps.guard.outputs.ok == 'true' + if: steps.guard.outputs.ok == 'true' && success() run: pnpm typecheck # The base schema was created out-of-band with `d1 execute --file`, so wrangler's migration @@ -196,11 +261,11 @@ jobs: # `run deploy`, not `deploy` — bare `pnpm deploy` is a pnpm built-in, not our package script. - name: Deploy explorer (sigma) - if: steps.guard.outputs.ok == 'true' + if: steps.guard.outputs.ok == 'true' && success() run: pnpm --filter @sigma/web run deploy - name: Initialize LOG_IP_KEY secret if absent - if: steps.guard.outputs.ok == 'true' + if: steps.guard.outputs.ok == 'true' && success() run: | # LOG_IP_KEY is long-lived so IP HMAC tokens remain stable for same-client correlation. # Never overwrite it; a new blue-green worker name starts without a key and correlation @@ -238,8 +303,22 @@ jobs: fi key="$(openssl rand -hex 32)" + # Register the value as a secret with the runner BEFORE it is used anywhere else, so an + # accidental echo / set -x trace in a later edit can never leak it. Masking is not + # retroactive within a line, hence add-mask on the line right after generation. + echo "::add-mask::$key" printf '%s' "$key" | pnpm --filter @sigma/web exec wrangler secret put LOG_IP_KEY --name "$SIGMA_WEB_NAME" + # The §9.3 transcript-signing key (ADR-0011/0012). Same generate-if-absent story as LOG_IP_KEY above: + # a purely internal HMAC key with no human value, generated once per worker and left stable across + # redeploys (rotating it would invalidate every in-flight client transcript at once). On production + + # staging the runtime gate fails CLOSED without it, so this step must run on every deploy to keep the + # required key present. The key is generated in-process and streamed to wrangler over stdin — it never + # appears in a log line, so no ::add-mask:: is needed. Runs after the deploy: the worker must exist first. + - name: Initialize ASSISTANT_HMAC_KEY secret if absent + if: steps.guard.outputs.ok == 'true' && success() + run: node scripts/ensure-worker-secret.mjs ASSISTANT_HMAC_KEY + - name: Deploy refresh Workflow (sigma-etl) - if: steps.guard.outputs.ok == 'true' + if: steps.guard.outputs.ok == 'true' && success() run: pnpm --filter @sigma/etl run deploy diff --git a/.gitignore b/.gitignore index d5004100..7f4f5f15 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ worker-configuration.d.ts # Local-only policy/regulatory source materials copied in for review (never commit) .policy-source/ + +# Playwright MCP session artifacts (screenshots / a11y snapshots) +.playwright-mcp/ diff --git a/apps/etl/package.json b/apps/etl/package.json index b75c48a9..2e7f081a 100644 --- a/apps/etl/package.json +++ b/apps/etl/package.json @@ -11,6 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@sigma/ingest": "workspace:*" + "@sigma/config": "workspace:*", + "@sigma/ingest": "workspace:*", + "@sigma/shared": "workspace:*" } } diff --git a/apps/etl/src/cron-guard.test.ts b/apps/etl/src/cron-guard.test.ts new file mode 100644 index 00000000..b913e3b5 --- /dev/null +++ b/apps/etl/src/cron-guard.test.ts @@ -0,0 +1,27 @@ +/// +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { PROMPTS_CRON, REFRESH_CRON } from './crons'; + +// Routing safety: scheduled() branches on controller.cron against the named constants. A typo in +// wrangler.toml's `crons` (or in the constants) would silently misroute a trigger, so this parses the +// committed `crons` array and asserts it equals exactly [REFRESH_CRON, PROMPTS_CRON] — a mismatch +// fails CI instead of misfiring in production. + +const wranglerPath = resolve(dirname(fileURLToPath(import.meta.url)), '../wrangler.toml'); + +function parseCrons(toml: string): string[] { + const match = toml.match(/crons\s*=\s*\[([^\]]*)\]/); + const inner = match?.[1]; + if (inner === undefined) throw new Error('no `crons = [...]` array found in wrangler.toml'); + return [...inner.matchAll(/"([^"]*)"/g)].map((m) => m[1] ?? ''); +} + +describe('cron routing guard', () => { + it('wrangler crons equal [REFRESH_CRON, PROMPTS_CRON] in order', () => { + const crons = parseCrons(readFileSync(wranglerPath, 'utf8')); + expect(crons).toStrictEqual([REFRESH_CRON, PROMPTS_CRON]); + }); +}); diff --git a/apps/etl/src/crons.ts b/apps/etl/src/crons.ts new file mode 100644 index 00000000..c12942db --- /dev/null +++ b/apps/etl/src/crons.ts @@ -0,0 +1,5 @@ +// Cron strings shared by wrangler.toml's `crons`, scheduled()'s routing branch (index.ts), and the +// cron-guard test. Kept in a dependency-free module (no `cloudflare:workers` / `.sql` text imports) so +// the guard test can import them under plain vitest without pulling in the Workflow runtime. +export const REFRESH_CRON = '0 */6 * * *'; +export const PROMPTS_CRON = '0 6 * * 1'; diff --git a/apps/etl/src/index.ts b/apps/etl/src/index.ts index a3ad9afc..67846ef3 100644 --- a/apps/etl/src/index.ts +++ b/apps/etl/src/index.ts @@ -10,7 +10,9 @@ import { } from '@sigma/ingest'; import refreshSliceSql from '../../../scripts/refresh-slice.sql'; import workStagingSchemaSql from '../../../scripts/work-staging-schema.sql'; +import { PROMPTS_CRON, REFRESH_CRON } from './crons'; import { computeWorkerCatchupPlan, ingestBucketWindow, type CatchupPlan } from './eop'; +import { generateSuggestedPrompts } from './suggested-prompts'; import { runServedIntegrityGate } from './integrity'; export interface Env { @@ -160,6 +162,27 @@ export class RefreshWorkflow extends WorkflowEntrypoint { refreshDerivedContractCount(this.env.DB), ); + // Keep the dock's starter chips in step with the freshly-derived slice. The weekly PROMPTS_CRON is a + // coarse fallback; regenerating here means the chip numbers track each 6-hourly refresh instead of + // lagging up to a week behind the data the assistant recomputes live. That skew is the S3 defect: a + // chip computed on partial data showed „140 договора за 21,6 млн €" while the live query returned + // 278 / 61,5 млн for the SAME window once late-arriving contracts backfilled. Best-effort — the slice + // is already committed, so a prompts failure is logged, not fatal to the refresh. + await step.do('refresh-suggested-prompts', async () => { + try { + await generateSuggestedPrompts(this.env.DB); + } catch (error) { + console.error( + JSON.stringify({ + level: 'error', + event: 'etl_prompts_failed', + phase: 'refresh', + message: error instanceof Error ? error.message : String(error), + }), + ); + } + }); + // Reconciliation gate (#97) on the served D1 the refresh just wrote — the CLI paths gate every // derive, but this steady-state path did not. POST-COMMIT alarm: the slice is already applied // and served, so a violation fails the step + surfaces in observability, it does not un-serve @@ -188,9 +211,35 @@ export class RefreshWorkflow extends WorkflowEntrypoint { } export default { - // Cron entrypoint: kick one durable refresh run. No public route or HTTP trigger is configured. - async scheduled(_controller, env): Promise { - const instance = await env.REFRESH.create(); - console.log(JSON.stringify({ level: 'info', event: 'etl_scheduled_refresh', id: instance.id })); + // Cron entrypoint. Two triggers share this worker: the 6-hourly data refresh kicks a durable + // Workflow run; the weekly cron rebuilds the assistant starter prompts. Branch on the cron string + // (named constants above) — an unrecognised cron logs `etl_unknown_cron` rather than misrouting. + async scheduled(controller, env, ctx): Promise { + if (controller.cron === PROMPTS_CRON) { + // Surface a failure as a structured event rather than an anonymous unhandled rejection. The job + // degrades safely (the prior rows stay served), so this is observability, not a fatal path. + ctx.waitUntil( + generateSuggestedPrompts(env.DB).catch((error) => + console.error( + JSON.stringify({ + level: 'error', + event: 'etl_prompts_failed', + message: error instanceof Error ? error.message : String(error), + }), + ), + ), + ); + return; + } + if (controller.cron === REFRESH_CRON) { + const instance = await env.REFRESH.create(); + console.log( + JSON.stringify({ level: 'info', event: 'etl_scheduled_refresh', id: instance.id }), + ); + return; + } + console.log( + JSON.stringify({ level: 'warn', event: 'etl_unknown_cron', cron: controller.cron }), + ); }, } satisfies ExportedHandler; diff --git a/apps/etl/src/suggested-prompts.sql.test.ts b/apps/etl/src/suggested-prompts.sql.test.ts new file mode 100644 index 00000000..d8dbf4fa --- /dev/null +++ b/apps/etl/src/suggested-prompts.sql.test.ts @@ -0,0 +1,130 @@ +/// +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { SLOT1_SQL, SLOT2_SQL, SLOT3_SQL, SLOT4_SQL } from './suggested-prompts'; + +// Integration test for the starter-prompt slot SQL. The pure-logic unit tests (suggested-prompts.test +// .ts) never run the actual aggregation; this builds a real SQLite from the production migrations +// (0000_init + 0003_assistant_prompts) with a deterministic fixture and asserts the numbers each slot +// query returns: the as_of-anchored window, the slot-4 denominator exclusions, and the +// amount_eur-IS-NOT-NULL sum posture. Mirrors the sqlite3-CLI harness of competition-sql.test.ts (no +// better-sqlite3 dependency, same as the rest of the suite). + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql'); +const migration1 = resolve(root, 'packages/db/migrations/0003_assistant_prompts.sql'); + +function sqlite(dbPath: string, sql: string): string { + return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' }).trim(); +} + +function readScript(dbPath: string, path: string): void { + execFileSync('sqlite3', ['-bail', dbPath], { input: `.read ${path}\n`, stdio: 'pipe' }); +} + +// as_of = 2024-01-10. The 7-day window is signed_at ∈ (2024-01-03, 2024-01-10]. +// c:in1 2024-01-05 amount_eur 1000 sector 45 bids 1 open → in window, single-offer +// c:in2 2024-01-08 amount_eur 9000 sector 45 bids 3 open → in window, top contract +// c:in3 2024-01-10 amount_eur 500 sector 15 bids 1 open → in window (boundary inclusive) +// c:edge 2024-01-03 amount_eur 7000 sector 45 bids 1 open → EXCLUDED (boundary exclusive) +// c:late 2024-01-11 amount_eur 8000 sector 45 bids 1 open → EXCLUDED (after as_of) +// c:nul 2024-01-06 amount_eur NULL sector 45 bids 1 open → EXCLUDED from sums +// c:unk 2024-01-07 amount_eur 4000 sector 45 bids 1 неизвестна → EXCLUDED from slot 4 denom +// c:nob 2024-01-07 amount_eur 3000 sector 45 bids NULL open → EXCLUDED from slot 4 denom +const FIXTURE = ` +INSERT INTO home_totals (id, contracts, value_eur, authorities, bidders, suspect, as_of, refreshed_at) + VALUES (1, 8, 25000, 1, 1, 0, '2024-01-10', '2024-01-10T00:00:00Z'); +INSERT INTO authorities (id, name) VALUES ('auth:A', 'Институция А'); +INSERT INTO bidders (id, name) VALUES ('eik:X', 'Фирма Х'); +INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES + ('t:open', 'UNP-O', 'Открита', 'auth:A', '45000000', 'открита процедура', 'awarded'), + ('t:food', 'UNP-F', 'Храни', 'auth:A', '15000000', 'открита процедура', 'awarded'), + ('t:unk', 'UNP-U', 'Синтет.', 'auth:A', '45000000', 'неизвестна', 'awarded'); +INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, value_flag, amount_eur) VALUES + ('c:in1', 't:open', 'eik:X', 1000, 'EUR', '2024-01-05', 1, 'ok', 1000), + ('c:in2', 't:open', 'eik:X', 9000, 'EUR', '2024-01-08', 3, 'ok', 9000), + ('c:in3', 't:food', 'eik:X', 500, 'EUR', '2024-01-10', 1, 'ok', 500), + ('c:edge', 't:open', 'eik:X', 7000, 'EUR', '2024-01-03', 1, 'ok', 7000), + ('c:late', 't:open', 'eik:X', 8000, 'EUR', '2024-01-11', 1, 'ok', 8000), + ('c:nul', 't:open', 'eik:X', 4000, 'EUR', '2024-01-06', 1, 'ok', NULL), + ('c:unk', 't:unk', 'eik:X', 4000, 'EUR', '2024-01-07', 1, 'ok', 4000), + ('c:nob', 't:open', 'eik:X', 3000, 'EUR', '2024-01-07', NULL, 'ok', 3000); +`; + +function withDb(fn: (dbPath: string) => T): T { + const dir = mkdtempSync(resolve(tmpdir(), 'sigma-prompts-')); + const dbPath = resolve(dir, 'test.sqlite'); + try { + readScript(dbPath, migration0); + readScript(dbPath, migration1); + sqlite(dbPath, FIXTURE); + return fn(dbPath); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// The exported SQL binds ?1 = as_of (text) and ?2 = window days (integer). The sqlite3 CLI's +// `.param set` evaluates its value as a SQL expression (so '2024-01-10' would be parsed as 2024−01−10 +// arithmetic), so instead we substitute the two parameters with correctly-typed SQL literals and run +// the exact exported query text. In production D1 these arrive as typed values via .bind(asOf, days). +function runSlot(dbPath: string, sql: string, asOf: string, days: number): string { + const bound = sql.replaceAll('?1', `'${asOf}'`).replaceAll('?2', String(days)); + return execFileSync('sqlite3', ['-bail', dbPath], { + input: `${bound}\n`, + encoding: 'utf8', + }).trim(); +} + +describe('suggested-prompts slot SQL (real SQLite)', () => { + it('slot 1 picks the biggest in-window contract and excludes boundary/after/NULL rows', () => { + withDb((dbPath) => { + // Top row = c:in2 (9000); c:edge (2024-01-03, 7000) and c:late are out of window; c:nul is NULL. + const out = runSlot(dbPath, SLOT1_SQL, '2024-01-10', 7); + const firstLine = out.split('\n')[0]; + expect(firstLine).toBe('Институция А|9000.0|ok|45'); + }); + }); + + it('slot 1 excludes a value_suspect row even when it is the largest in-window amount', () => { + withDb((dbPath) => { + // A repaired-but-flagged row larger than the top 'ok' contract (c:in2 = 9000). The NAMED headline + // gates on value_flag = 'ok', so this must NOT be picked — slot 1 stays on c:in2. + sqlite( + dbPath, + `INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, value_flag, amount_eur) + VALUES ('c:susp', 't:open', 'eik:X', 12000, 'EUR', '2024-01-09', 2, 'value_suspect', 12000);`, + ); + const firstLine = runSlot(dbPath, SLOT1_SQL, '2024-01-10', 7).split('\n')[0]; + expect(firstLine).toBe('Институция А|9000.0|ok|45'); + }); + }); + + it('slot 2 sums signed spend per CPV division over amount_eur IS NOT NULL', () => { + withDb((dbPath) => { + // Division 45 in window: c:in1 1000 + c:in2 9000 + c:unk 4000 + c:nob 3000 = 17000 over 4 rows. + // (c:nul NULL excluded; c:edge/c:late out of window.) Division 15 = 500. Top = 45. + expect(runSlot(dbPath, SLOT2_SQL, '2024-01-10', 7)).toBe('45|17000.0|4'); + }); + }); + + it('slot 3 counts and sums all in-window non-NULL contracts', () => { + withDb((dbPath) => { + // In window, amount_eur NOT NULL: c:in1 1000 + c:in2 9000 + c:in3 500 + c:unk 4000 + c:nob 3000 + // = 17500 over 5 rows. (c:edge/c:late out of window; c:nul NULL.) + expect(runSlot(dbPath, SLOT3_SQL, '2024-01-10', 7)).toBe('5|17500.0'); + }); + }); + + it('slot 4 denominator excludes неизвестна procedure and null bids_received', () => { + withDb((dbPath) => { + // Qualifying (bids>=1, procedure<>неизвестна, in window): c:in1, c:in2, c:in3 = 3 total; + // single-offer (bids=1): c:in1, c:in3 = 2. c:unk (неизвестна) and c:nob (NULL bids) excluded. + expect(runSlot(dbPath, SLOT4_SQL, '2024-01-10', 7)).toBe('2|3'); + }); + }); +}); diff --git a/apps/etl/src/suggested-prompts.test.ts b/apps/etl/src/suggested-prompts.test.ts new file mode 100644 index 00000000..a669e45f --- /dev/null +++ b/apps/etl/src/suggested-prompts.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildSlot1, + buildSlot2, + buildSlot3, + buildSlot4, + generateSuggestedPrompts, + sanitizeName, + slot1OutlierSuppressed, +} from './suggested-prompts'; + +// NBSP — `money`/`count` separate the magnitude and unit with a non-breaking space; we hardcode it in +// the expected label literals so the assertion reads cleanly. +const NB = ' '; + +describe('sanitizeName', () => { + it('strips an embedded hello" />); + // The literal string must appear as text — no live '); + }); + + it('renders safe https: links as anchors', () => { + render(); + const a = screen.getByRole('link', { name: 'example' }); + expect(a).toBeInTheDocument(); + expect(a.getAttribute('href')).toBe('https://example.com'); + expect(a.getAttribute('rel')).toContain('noopener'); + }); + + it('degrades javascript: href to plain text — no anchor rendered', () => { + const { container } = render(); + expect(container.querySelector('a')).toBeNull(); + expect(container.textContent).toContain('click me'); + }); + + it('degrades data: href to plain text', () => { + const { container } = render(); + expect(container.querySelector('a')).toBeNull(); + expect(container.textContent).toContain('xss'); + }); + + it('degrades protocol-relative // href to plain text', () => { + const { container } = render(); + expect(container.querySelector('a')).toBeNull(); + expect(container.textContent).toContain('evil'); + }); + + it('renders relative paths as anchors', () => { + render(); + const a = screen.getByRole('link', { name: 'report' }); + expect(a.getAttribute('href')).toBe('/reports/r_abc123'); + }); + + it('splits on blank lines into paragraphs', () => { + const { container } = render(); + const paras = container.querySelectorAll('p'); + expect(paras).toHaveLength(2); + expect(paras[0].textContent).toBe('paragraph one'); + expect(paras[1].textContent).toBe('paragraph two'); + }); + + it('returns null for empty / whitespace-only input', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); + +describe('MarkdownBlock — block-level forms (lists, hr, tables)', () => { + it('renders an unordered list', () => { + const { container } = render(); + const items = container.querySelectorAll('ul > li'); + expect(items).toHaveLength(2); + expect(items[0].textContent).toBe('едно'); + expect(items[1].textContent).toBe('две'); + }); + + it('renders an ordered list (including multi-digit markers)', () => { + const { container } = render(); + const items = container.querySelectorAll('ol > li'); + expect(items).toHaveLength(3); + expect(items[2].textContent).toBe('c'); + }); + + it('renders inline markup inside list items', () => { + const { container } = render(); + expect(container.querySelector('li strong')?.textContent).toBe('важно'); + }); + + it('renders a horizontal rule between paragraphs', () => { + const { container } = render(); + expect(container.querySelector('hr')).not.toBeNull(); + expect(container.querySelectorAll('p')).toHaveLength(2); + }); + + it('renders a GFM pipe table (header + delimiter + body)', () => { + const md = '| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |'; + const { container } = render(); + const table = container.querySelector('table'); + expect(table).not.toBeNull(); + expect(container.querySelectorAll('thead th')).toHaveLength(2); + const bodyRows = container.querySelectorAll('tbody tr'); + expect(bodyRows).toHaveLength(2); + expect(bodyRows[0].querySelectorAll('td')[0].textContent).toBe('1'); + }); + + it('renders inline markup inside table cells', () => { + const md = '| Име |\n| --- |\n| **X** |'; + const { container } = render(); + expect(container.querySelector('tbody strong')?.textContent).toBe('X'); + }); + + it('does NOT treat a pipe row without a delimiter row as a table (false-match guard)', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeNull(); + expect(container.textContent).toContain('| A | B |'); + }); + + it('does NOT throw and renders text when a pipe row is the last line (streaming partial table)', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeNull(); + expect(container.textContent).toContain('| A | B |'); + }); + + it('separates a paragraph immediately followed by a list (line-grouping, no blank line)', () => { + const { container } = render(); + const p = container.querySelector('p'); + expect(p?.textContent).toBe('въведение'); + expect(container.querySelectorAll('ul > li')).toHaveLength(2); + }); + + it('normalizes CRLF — no carriage return leaks into list items', () => { + const { container } = render(); + const items = container.querySelectorAll('li'); + expect(items[0].textContent).toBe('a'); + expect(container.textContent).not.toContain('\r'); + }); +}); + +describe('MarkdownBlock — XSS safety inside new block forms', () => { + const hasExecutableHref = (container: HTMLElement): boolean => + Array.from(container.querySelectorAll('a')).some((a) => + /^\s*(javascript|data|vbscript):/i.test(a.getAttribute('href') ?? ''), + ); + + it('renders raw HTML in a table cell as inert text', () => { + const md = '| | b |\n| --- | --- |\n| c | d |'; + const { container } = render(); + expect(container.querySelector('script')).toBeNull(); + expect(container.textContent).toContain(''); + }); + + it('renders raw onerror img in a list item as inert text', () => { + const { container } = render('} />); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain(''); + }); + + it('degrades a javascript: link inside a list item to text', () => { + const { container } = render(); + expect(hasExecutableHref(container)).toBe(false); + expect(container.textContent).toContain('клик'); + }); + + it('never emits an executable href for an entity-encoded scheme in a cell', () => { + const md = '| [x](javascript:alert(1)) |\n| --- |\n| ok |'; + const { container } = render(); + expect(hasExecutableHref(container)).toBe(false); + }); + + it('degrades a tab-split scheme in a cell (URL parser strips the tab)', () => { + const md = '| [x](java\tscript:alert(1)) |\n| --- |\n| ok |'; + const { container } = render(); + expect(hasExecutableHref(container)).toBe(false); + }); + + it('does not autolink a bare in a cell (no autolink rule)', () => { + const md = '| |\n| --- |\n| ok |'; + const { container } = render(); + expect(container.querySelector('a')).toBeNull(); + }); +}); diff --git a/apps/web/app/components/MarkdownBlock.tsx b/apps/web/app/components/MarkdownBlock.tsx new file mode 100644 index 00000000..ef566b02 --- /dev/null +++ b/apps/web/app/components/MarkdownBlock.tsx @@ -0,0 +1,210 @@ +// Minimal safe markdown renderer for report text/callout blocks (spec §D3 / §7) and assistant dock prose. +// +// Contract obligations: +// • No dangerouslySetInnerHTML — prose is rendered as React elements only. +// • No raw-HTML passthrough — the tokenizer does not parse or emit HTML tags. +// • Link href allowlist — sanitizeLinkHref(href) is the gate; unsafe hrefs degrade to plain text. +// +// Inline: **bold**, *italic*, `inline code`, [text](url). Nested emphasis is intentionally not supported. +// Block: paragraphs (blank-line separated), unordered/ordered lists, horizontal rules, and GFM pipe +// tables. Tables REQUIRE the delimiter row (`| --- |`) so a stray `|` in prose is not misparsed. +// Nested lists, multi-line cells, escaped `\|`, and `#` headings are out of scope (YAGNI). + +import type { ReactNode } from 'react'; +import { sanitizeLinkHref } from '~/lib/sanitize-markdown'; + +// Single-pass inline tokenizer. Each capturing group corresponds to one inline form: +// m[1]/m[2] → **bold** / inner text +// m[3]/m[4] → *italic* / inner text +// m[5]/m[6] → `code` / inner text +// m[7]/m[8] → [link text](url) / text / href +// The `(?!\*)` look-ahead on *italic* prevents matching `**` as two italic markers. +// Regex is created inside renderInline (not at module scope) to avoid shared mutable lastIndex state. + +function renderInline(text: string): ReactNode[] { + const INLINE_RE = + /(\*\*([^*]+)\*\*)|(\*(?!\*)([^*]+)\*(?!\*))|(`([^`]+)`)|\[([^\]]*)\]\(([^)]*)\)/g; + const nodes: ReactNode[] = []; + let pos = 0; + let key = 0; + let m: RegExpExecArray | null; + + while ((m = INLINE_RE.exec(text)) !== null) { + if (m.index > pos) nodes.push(text.slice(pos, m.index)); + const k = key++; + + if (m[1] !== undefined) { + nodes.push({m[2]}); + } else if (m[3] !== undefined) { + nodes.push({m[4]}); + } else if (m[5] !== undefined) { + nodes.push({m[6]}); + } else { + const linkText = m[7] ?? ''; + const safeHref = sanitizeLinkHref(m[8] ?? ''); + if (safeHref !== null) { + nodes.push( + + {linkText} + , + ); + } else { + // Unsafe href: render the link text as plain text — never a dead/harmful link. + nodes.push({linkText}); + } + } + + pos = INLINE_RE.lastIndex; + } + + if (pos < text.length) nodes.push(text.slice(pos)); + return nodes; +} + +// Block-level line predicates. Module-scope (stable references, no shared lastIndex). +const isUl = (l: string): boolean => /^\s*[-*]\s+/.test(l); +const isOl = (l: string): boolean => /^\s*\d+\.\s+/.test(l); +// A whole line of 3+ identical `-`/`*`/`_` — a horizontal rule. Never has pipes (distinguishes from a +// table delimiter row). +const isHr = (l: string): boolean => /^\s*([-*_])\1{2,}\s*$/.test(l); +// A pipe row: starts and ends with `|`. +const isRow = (l: string): boolean => /^\s*\|.*\|\s*$/.test(l); +// A GFM delimiter row (`| --- | :--: |`): only pipes/dashes/colons/space, with at least one dash and one +// pipe. The `includes` short-circuits keep this linear on adversarial `|`-floods / `-`-floods (no ReDoS). +const isDelim = (l: string): boolean => { + const t = l.trim(); + return t.includes('|') && t.includes('-') && /^[\s|:-]+$/.test(t); +}; +// Split a pipe row into trimmed cells, dropping the leading/trailing border pipes. +const splitRow = (l: string): string[] => + l + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map((c) => c.trim()); + +// A pipe row that is followed by a delimiter row starts a table. Bounds-checked: a row that is the LAST +// line (streaming partial table) must NOT dereference lines[i+1]. +const startsTable = (lines: string[], i: number): boolean => + isRow(lines[i]) && i + 1 < lines.length && isDelim(lines[i + 1]); + +/** Parse markdown into an ordered list of block elements. Every text fragment flows through renderInline. */ +function renderBlocks(md: string): ReactNode[] { + const lines = md.replace(/\r\n?/g, '\n').split('\n'); + const blocks: ReactNode[] = []; + let i = 0; + let key = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.trim() === '') { + i++; + continue; + } + + if (isHr(line)) { + blocks.push(
); + i++; + continue; + } + + if (startsTable(lines, i)) { + const header = splitRow(line); + i += 2; // skip header + delimiter + const rows: string[][] = []; + while (i < lines.length && isRow(lines[i])) { + rows.push(splitRow(lines[i])); + i++; + } + blocks.push( + + + + {header.map((c, ci) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {row.map((c, ci) => ( + + ))} + + ))} + +
{renderInline(c)}
{renderInline(c)}
, + ); + continue; + } + + if (isUl(line)) { + const items: string[] = []; + while (i < lines.length && isUl(lines[i])) { + items.push(lines[i].replace(/^\s*[-*]\s+/, '')); + i++; + } + blocks.push( +
    + {items.map((it, ii) => ( +
  • {renderInline(it)}
  • + ))} +
, + ); + continue; + } + + if (isOl(line)) { + const items: string[] = []; + while (i < lines.length && isOl(lines[i])) { + items.push(lines[i].replace(/^\s*\d+\.\s+/, '')); + i++; + } + blocks.push( +
    + {items.map((it, ii) => ( +
  1. {renderInline(it)}
  2. + ))} +
, + ); + continue; + } + + // Paragraph: accumulate consecutive lines until a blank line or any block-form boundary. + const para: string[] = []; + while ( + i < lines.length && + lines[i].trim() !== '' && + !isHr(lines[i]) && + !isUl(lines[i]) && + !isOl(lines[i]) && + !startsTable(lines, i) + ) { + para.push(lines[i]); + i++; + } + const text = para.join('\n').trim(); + if (text) blocks.push(

{renderInline(text)}

); + } + + return blocks; +} + +interface MarkdownBlockProps { + /** Markdown prose. Report text is server-sanitized (sanitizeProse); dock prose is raw model output — + * safe either way (no raw-HTML passthrough, link hrefs gated). */ + md: string; + className?: string; +} + +/** + * Renders markdown prose as React elements. + * No dangerouslySetInnerHTML, no raw-HTML passthrough, link hrefs gated by sanitizeLinkHref. + */ +export function MarkdownBlock({ md, className }: MarkdownBlockProps) { + const blocks = renderBlocks(md); + if (blocks.length === 0) return null; + return
{blocks}
; +} diff --git a/apps/web/app/components/ReportAiWatermark.tsx b/apps/web/app/components/ReportAiWatermark.tsx new file mode 100644 index 00000000..0d294d0e --- /dev/null +++ b/apps/web/app/components/ReportAiWatermark.tsx @@ -0,0 +1,25 @@ +// D5: "AI-генерирано, неофициално" watermark rendered on every AI report (spec §9.12 / §D5). +// Always shown — the `watermark: 'ai-generated'` field on ResolvedReport is the gate. + +/** + * Prominent strip that labels the report as AI-generated and non-official. + * Appears directly below the report title so it is visible before any data. + */ +export function ReportAiWatermark() { + return ( +
+ +

+ Генерирано с изкуствен интелект — тази справка е изготвена автоматично от + AI модел. Изкуственият интелект може да допуска грешки. Проверявайте важни данни от първичен + източник. +

+
+ ); +} diff --git a/apps/web/app/components/ReportBlockRenderer.test.tsx b/apps/web/app/components/ReportBlockRenderer.test.tsx new file mode 100644 index 00000000..63dac985 --- /dev/null +++ b/apps/web/app/components/ReportBlockRenderer.test.tsx @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { ReportBlockRenderer } from './ReportBlockRenderer'; +import type { ResolvedBlock } from '~/lib/assistant/report-schema'; + +afterEach(() => { + cleanup(); +}); + +// Shared-surface regression: MarkdownBlock renders BOTH report text/callout blocks and dock prose. +// Extending it with lists/hr/tables must (a) newly structure report prose that uses those forms, and +// (b) leave plain report prose unchanged. +describe('ReportBlockRenderer — MarkdownBlock shared-surface', () => { + const blocks = (...b: ResolvedBlock[]): ResolvedBlock[] => b; + + it('renders a list inside a report callout (intended additive change)', () => { + const { container } = render( + , + ); + + const items = container.querySelectorAll('.report-block--callout ul > li'); + expect(items).toHaveLength(2); + expect(items[0].textContent).toBe('едно'); + }); + + it('leaves a plain-prose text block unchanged (no regression)', () => { + const { container } = render( + , + ); + + const paras = container.querySelectorAll('.report-block--text p'); + expect(paras).toHaveLength(1); + expect(paras[0].textContent).toBe('Обикновен абзац без форматиране.'); + expect(container.querySelector('ul')).toBeNull(); + expect(container.querySelector('table')).toBeNull(); + }); +}); + +// WCAG 1.1.1: every chart block must expose its data to assistive technology as a real +// ; the visual representation (CSS bars / SVG) must be hidden from AT. +describe('ReportBlockRenderer — chart blocks expose screen-reader data tables', () => { + const barBlock = { + type: 'bar' as const, + points: [ + { label: 'София', value: 1200 }, + { label: null, value: 300 }, + ], + format: 'number' as const, + }; + + it('bar: renders a hidden data table and hides the visual bar list from AT', () => { + const { container } = render(); + + const table = container.querySelector('.report-block--bar table.ts-data-table'); + expect(table).not.toBeNull(); + expect(table!.getAttribute('aria-label')).toBe('Данни от диаграмата'); + + const headers = Array.from(table!.querySelectorAll('th[scope="col"]')).map( + (th) => th.textContent, + ); + expect(headers).toEqual(['Категория', 'Стойност']); + + expect(container.querySelector('ul.report-bar')!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('bar: table values match the visible bar values byte-for-byte and null labels render as —', () => { + const { container } = render(); + + const tableRows = Array.from(container.querySelectorAll('.ts-data-table tbody tr')); + const listValues = Array.from(container.querySelectorAll('.report-bar__value')).map( + (el) => el.textContent, + ); + expect(tableRows).toHaveLength(2); + expect(tableRows.map((tr) => tr.children[1].textContent)).toEqual(listValues); + expect(tableRows[1].children[0].textContent).toBe('—'); + }); + + it('flows: renders a real table with scoped column headers', () => { + const { container } = render( + , + ); + + const headers = container.querySelectorAll('.report-block--flows th[scope="col"]'); + expect(headers).toHaveLength(3); + expect(container.querySelector('.report-block--flows tbody tr')).not.toBeNull(); + }); + + it('timeseries: renders the hidden data table and an aria-hidden SVG', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.report-block--timeseries table.ts-data-table')).not.toBeNull(); + expect( + container.querySelector('.report-block--timeseries svg')!.getAttribute('aria-hidden'), + ).toBe('true'); + }); + + it('every chart block type exposes its data values in a table', () => { + const cases: { block: ResolvedBlock; expected: string }[] = [ + { block: barBlock, expected: 'София' }, + { + block: { type: 'flows', edges: [{ from: 'МРРБ', to: 'Фирма X', valueEur: 5000 }] }, + expected: 'Фирма X', + }, + { + block: { type: 'timeseries', points: [{ period: '2024-03', value: 7 }] }, + expected: '2024-03', + }, + ]; + for (const { block, expected } of cases) { + const { container, unmount } = render(); + const tables = Array.from(container.querySelectorAll('table')); + expect(tables.length).toBeGreaterThan(0); + expect(tables.some((t) => t.textContent!.includes(expected))).toBe(true); + unmount(); + } + }); +}); diff --git a/apps/web/app/components/ReportBlockRenderer.tsx b/apps/web/app/components/ReportBlockRenderer.tsx new file mode 100644 index 00000000..fc64ce4c --- /dev/null +++ b/apps/web/app/components/ReportBlockRenderer.tsx @@ -0,0 +1,242 @@ +// Renders a ResolvedBlock[] from a StoredReport into UI (spec §D4 / §D5 dependencies). +// +// Block-to-component mapping: +// text → MarkdownBlock (D3: no raw HTML, http/https links only) +// callout → callout section + MarkdownBlock +// totals → TotalsStrip (existing) +// facts → FactsList (existing) +// table → DataTable (existing) — entity links built via entityHref +// bar → CSS proportional bar list (inline, no chart lib) +// flows → summary table (edges from/to/value) +// timeseries → TimeseriesBlock (D1: hand-built SVG) + +import { Link } from 'react-router'; +import { money } from '@sigma/shared'; +import type { ResolvedBlock, CellFormat } from '~/lib/assistant-contract/report'; +import { formatCell, entityHref } from '~/lib/assistant/render-format'; +import { TotalsStrip } from '~/components/TotalsStrip'; +import { FactsList } from '~/components/FactsList'; +import { DataTable } from '~/components/DataTable'; +import { MarkdownBlock } from '~/components/MarkdownBlock'; +import { TimeseriesBlock } from '~/components/TimeseriesBlock'; + +// ── Callout ────────────────────────────────────────────────────────────────── + +function CalloutBlock({ title, md }: { title: string; md: string }) { + return ( + + ); +} + +// ── Bar ────────────────────────────────────────────────────────────────────── + +function BarBlock({ + points, + truncated, + format, +}: { + points: { label: string | number | null; value: number }[]; + truncated?: boolean; + format?: CellFormat; +}) { + if (points.length === 0) return

Няма данни

; + const max = Math.max(1, ...points.map((p) => p.value)); + const rows = points.map((p) => ({ + label: p.label == null || p.label === '' ? '—' : String(p.label), + value: formatCell(p.value, format ?? 'money'), + pct: ((p.value / max) * 100).toFixed(1), + })); + return ( +
+ {/* Visually-hidden data table — the AT-accessible source (WCAG 1.1.1). + The bar list is aria-hidden; screen readers and text-only mode use this table instead. + AccessibilityWidget's SURVIVAL_CSS reveals .ts-data-table when text-only is active. */} +
+ + + + + + + + {rows.map((row, i) => ( + + + + + ))} + +
КатегорияСтойност
{row.label}{row.value}
+ + {truncated && ( +

+ Показани са само първите резултати — данните са отрязани. +

+ )} + + ); +} + +// ── Flows ───────────────────────────────────────────────────────────────────── + +function FlowsBlock({ + edges, + truncated, +}: { + edges: { from: string; to: string; valueEur: number }[]; + truncated?: boolean; +}) { + if (edges.length === 0) return

Няма данни

; + return ( +
+
+ + + + + + + + + + {edges.map((e, i) => ( + + + + + + ))} + +
ОтКъм + Стойност (€) +
{e.from}{e.to}{money(e.valueEur)}
+
+ {truncated && ( +

+ Показани са само първите резултати — данните са отрязани. +

+ )} +
+ ); +} + +// ── Single block ────────────────────────────────────────────────────────────── + +function Block({ block }: { block: ResolvedBlock }) { + switch (block.type) { + case 'text': + return ; + + case 'callout': + return ; + + case 'totals': { + const totals = block.items.map((it) => ({ + num: formatCell(it.value, it.format), + label: it.label, + })); + return ( +
+ +
+ ); + } + + case 'facts': { + const rows = block.items.map((it) => ({ + term: it.term, + value: formatCell(it.value, 'text'), + sub: it.sub, + })); + return ( +
+ +
+ ); + } + + case 'table': { + if (block.rows.length === 0) { + return ( +
+

Няма резултати

+
+ ); + } + const columns = block.columns.map((col, ci) => ({ + key: col.key, + header: col.header, + align: col.align === 'right' ? ('num' as const) : undefined, + cell: (row: (typeof block.rows)[number]) => { + const value = formatCell(row.cells[ci] ?? null, col.format); + if (col.link && row.links?.[ci]) { + const href = entityHref(col.link.kind, row.links[ci]!); + return {value}; + } + return value; + }, + })); + return ( +
+ i} /> + {block.truncated && ( +

+ Показани са само първите резултати — данните са отрязани. +

+ )} +
+ ); + } + + case 'bar': + return ; + + case 'flows': + return ; + + case 'timeseries': + return ( +
+ +
+ ); + + default: + return null; + } +} + +interface ReportBlockRendererProps { + blocks: ResolvedBlock[]; +} + +/** + * Renders a list of resolved report blocks. Each block type maps to its own component. + * Text and callout blocks are always rendered through MarkdownBlock (no raw HTML, safe links). + */ +export function ReportBlockRenderer({ blocks }: ReportBlockRendererProps) { + return ( +
+ {blocks.map((block, i) => ( + // Key by type + position: a report's block list is immutable and never reorders, so this is + // stable across streaming re-renders while keeping React's reconciliation type-aware. + + ))} +
+ ); +} diff --git a/apps/web/app/components/ReportToolbar.tsx b/apps/web/app/components/ReportToolbar.tsx new file mode 100644 index 00000000..486b0723 --- /dev/null +++ b/apps/web/app/components/ReportToolbar.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import type { ResolvedReport } from '~/lib/assistant-contract/report'; +import { + reportToMarkdown, + reportToDocxBlob, + downloadBlob, + safeFilename, +} from '~/lib/report-export'; + +function IconMarkdown() { + return ( + + ); +} + +function IconDocx() { + return ( + + ); +} + +function IconPrint() { + return ( + + ); +} + +interface ReportToolbarProps { + report: ResolvedReport; +} + +export function ReportToolbar({ report }: ReportToolbarProps) { + const [docxLoading, setDocxLoading] = useState(false); + const [docxError, setDocxError] = useState(null); + + function handleMarkdown() { + const md = reportToMarkdown(report); + const blob = new Blob([md], { type: 'text/markdown; charset=utf-8' }); + downloadBlob(blob, safeFilename(report.title, 'md')); + } + + async function handleDocx() { + if (docxLoading) return; + setDocxError(null); + setDocxLoading(true); + try { + const blob = await reportToDocxBlob(report); + downloadBlob(blob, safeFilename(report.title, 'docx')); + } catch { + setDocxError('Грешка при генериране на .docx файла. Опитайте отново.'); + } finally { + setDocxLoading(false); + } + } + + function handlePrint() { + window.print(); + } + + return ( +
+ + + + {docxError && ( +

+ {docxError} +

+ )} +
+ ); +} diff --git a/apps/web/app/components/SiteHeader.tsx b/apps/web/app/components/SiteHeader.tsx index c091ec57..93f05125 100644 --- a/apps/web/app/components/SiteHeader.tsx +++ b/apps/web/app/components/SiteHeader.tsx @@ -16,6 +16,7 @@ const NAV: NavItem[] = [ { to: '/companies', label: 'Компании' }, { to: '/contracts', label: 'Договори' }, { to: '/analytics', label: 'Анализи', activePaths: [...ANALYTICS_NAV_PATHS] }, + { to: '/reports', label: 'Справки' }, { to: '/methodology', label: 'Методология' }, ]; diff --git a/apps/web/app/components/TimeseriesBlock.tsx b/apps/web/app/components/TimeseriesBlock.tsx new file mode 100644 index 00000000..f381c2c2 --- /dev/null +++ b/apps/web/app/components/TimeseriesBlock.tsx @@ -0,0 +1,225 @@ +// Timeseries line chart — hand-built CSS/SVG, no chart library (spec §D1). +// +// Renders the `timeseries` report block. Supports both the single-series variant +// (`points: [{period, value}]`) and the multi-series variant (`series: [{label, points}]`). +// The SVG scales responsively via a fixed viewBox and CSS `width: 100%`. + +import type { CellFormat } from '~/lib/assistant-contract/report'; +import { formatCell } from '~/lib/assistant/render-format'; + +// Chart geometry in SVG user-space. +const W = 540; +const H = 200; +const PAD = { top: 16, right: 24, bottom: 38, left: 64 } as const; +const CHART_W = W - PAD.left - PAD.right; // 452 +const CHART_H = H - PAD.top - PAD.bottom; // 146 + +// Number of Y-axis gridlines (excluding the baseline). +const Y_TICK_COUNT = 4; +// Maximum X-axis period labels before thinning to avoid overlap. +const MAX_X_LABELS = 8; + +// CSS class cycle for multi-series stroke colours (defined in app.css under .ts-s0–.ts-s3). +// Capped at 4 entries; series beyond the 4th are dropped by `.slice(0, MAX_SERIES)` below (only +// single-series is emitted today). If multi-series past 4 ever ships, surface a truncation note. +// Multi-series is a future extension; the public block contract only emits single-series today. +const SERIES_CLASSES = ['ts-s0', 'ts-s1', 'ts-s2', 'ts-s3'] as const; +const MAX_SERIES = SERIES_CLASSES.length; + +type TimeseriesPoint = { period: string | number | null; value: number }; + +export interface TimeseriesBlockProps { + /** Single-series points (the flat variant emitted by bindReport). */ + points?: TimeseriesPoint[]; + /** Multi-series variant (the dock contract's extended form). */ + series?: { label: string; points: TimeseriesPoint[] }[]; + truncated?: boolean; + /** Value display format — applied to the data table and exports (mirrors bar's format prop). */ + format?: CellFormat; +} + +/** Compact number formatter for Y-axis tick labels (avoids importing @sigma/shared for pure UI). */ +function fmtTick(n: number): string { + const abs = Math.abs(n); + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs < 10_000_000 ? 1 : 0)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(abs < 10_000 ? 1 : 0)}k`; + return n.toFixed(abs > 0 && abs < 1 ? 2 : 0); +} + +/** Normalise both variants to a uniform `[{label, pts}]` list. */ +function toSeries(props: TimeseriesBlockProps): { label: string; pts: TimeseriesPoint[] }[] { + if (props.series && props.series.length > 0) { + return props.series.map((s) => ({ label: s.label, pts: s.points })); + } + if (props.points && props.points.length > 0) { + return [{ label: '', pts: props.points }]; + } + return []; +} + +/** + * SVG timeseries line chart for report blocks (spec §D1). + * No chart library — pure SVG path + circle elements, CSS-styled. + */ +export function TimeseriesBlock({ points, series, truncated, format }: TimeseriesBlockProps) { + const allSeries = toSeries({ points, series }).slice(0, MAX_SERIES); + + if (allSeries.length === 0 || allSeries.every((s) => s.pts.length === 0)) { + return

Няма данни

; + } + + // Y domain across all series. + const allValues = allSeries.flatMap((s) => s.pts.map((p) => p.value)); + let minVal = Infinity, + maxVal = -Infinity; + for (const v of allValues) { + if (v < minVal) minVal = v; + if (v > maxVal) maxVal = v; + } + const valueSpan = maxVal - minVal || 1; + + // X axis is driven by the longest series (all series share the same period index). + const longestSeries = allSeries.reduce((a, b) => (a.pts.length >= b.pts.length ? a : b)); + const ptCount = longestSeries.pts.length; + + const xOf = (i: number): number => + PAD.left + (ptCount > 1 ? (i / (ptCount - 1)) * CHART_W : CHART_W / 2); + // Y increases downward in SVG; higher values map to smaller y. + const yOf = (v: number): number => PAD.top + CHART_H - ((v - minVal) / valueSpan) * CHART_H; + + // Y-axis: evenly spaced ticks from minVal to maxVal. + const yTicks = Array.from({ length: Y_TICK_COUNT + 1 }, (_, i) => { + const fraction = i / Y_TICK_COUNT; + return { y: PAD.top + CHART_H - fraction * CHART_H, value: minVal + fraction * valueSpan }; + }); + + // X-axis labels: show every nth point to avoid overlap. + const xStep = Math.ceil(ptCount / MAX_X_LABELS); + const xLabelIndices = longestSeries.pts + .map((_, i) => i) + .filter((i) => i % xStep === 0 || i === ptCount - 1); + + return ( +
+ {/* Visually-hidden data table — the AT-accessible source (WCAG 1.1.1). + The SVG is aria-hidden; screen readers and text-only mode use this table instead. + AccessibilityWidget's SURVIVAL_CSS reveals .ts-data-table when text-only is active. */} + + + + + {allSeries.length > 1 ? ( + allSeries.map((s, si) => ( + + )) + ) : ( + + )} + + + + {longestSeries.pts.map((pt, i) => ( + + + {allSeries.map((s, si) => ( + + ))} + + ))} + +
Период + {s.label || `Серия ${si + 1}`} + Стойност
{String(pt.period ?? '')} + {s.pts[i] != null ? formatCell(s.pts[i].value, format ?? 'money') : '—'} +
+ + + + {truncated && ( +

+ Показани са само първите резултати — данните са отрязани. +

+ )} + + {/* Multi-series colour legend — must be last child of
(HTML spec). */} + {allSeries.length > 1 && ( +
+ {allSeries.map((s, si) => ( + + {s.label} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/lib/assistant-contract/README.md b/apps/web/app/lib/assistant-contract/README.md new file mode 100644 index 00000000..dda65868 --- /dev/null +++ b/apps/web/app/lib/assistant-contract/README.md @@ -0,0 +1,75 @@ +# Assistant contracts + +Three typed shapes at the three seams between nedda76's backend (#80) and our lanes (renderer, +persist, dock). Publish once → four people build in parallel against the fixtures, then swap fixtures +for live data when both sides land. Two of the three already (half-)exist in #80, so this is cheap. + +| # | Seam | Type | Fixture | Status | +| --- | ------------------------------- | ---------------------------------------------------------------- | -------------------------------------- | ----------------------- | +| 1 | block-spec — backend → renderer | `ResolvedReport` (re-exported from #80 `report-schema.ts`) | `fixtures/resolved-report.sample.json` | exists in #80 (spec §4) | +| 2 | R2 object — persist → renderer | `StoredReport` (`report.ts`, our lane) | `fixtures/stored-report.sample.json` | new (spec §5) | +| 3 | chat stream — backend → dock | AI SDK UIMessage stream + `data-report-ready` part (`stream.ts`) | `fixtures/chat-stream.sample.json` | half-exists in #80 | + +## How each lane uses it + +- **Renderer (`/reports/:id`)** — import `StoredReport` from `./report`; render `stored.report` + (`ResolvedReport`) onto `DataTable`/`StackedBar`/`SankeyDiagram`/`FactsList`/`TotalsStrip` + the new + `timeseries`; surface `stored.provenance` (per-source freshness, "view the query", the watermark). + **Render `text`/`callout` markdown with raw-HTML passthrough DISABLED** — values are pre-sanitized by + #80's `bindReport` (spec §7), but the guarantee is lost if the markdown renderer re-introduces an + HTML sink. Build entirely against `stored-report.sample.json`. +- **Persist (⑥)** — import `StoredReport`; after `bindReport()` yields a `ResolvedReport`, wrap it with + provenance and write one immutable JSON to R2 under a random id. The fixture is your output target. +- **Dock** — use `useChat` from `@ai-sdk/react` against `/assistant/chat`; render text + tool parts + normally; on a `data-report-ready` part (`isReportReadyPart`) drop a chip linking to `/reports/:id`. + Build against `chat-stream.sample.json`. + +## Source of truth, base, direction + +- Contract #1's vocabulary lives in **#80's `report-schema.ts`** — we only re-export it (`report.ts`), + never copy it. Change the block vocabulary there, not here. +- **Dependency direction:** `assistant-contract` MAY import from `assistant/`; `assistant/` must NEVER + import from `assistant-contract/`. +- Authored on top of **#80 (`feat/ai-assistant-impl`)** so the re-export resolves. **Rebase onto + `main` once #80 merges.** (Design rationale lives in spec §4/§5/§7 plus the §9 hardening review in + PR #79 — §9 / the agent-team addendum are not on this branch, so code comments cite the stable §4/§5/§7.) +- **Versioning (read contract):** the writer pins `schemaVersion: 1`; `/reports/:id` must switch on + `schemaVersion`, keep old branches forever, and treat an unknown future version as best-effort + render (banner), not a hard failure. Bump `STORED_REPORT_SCHEMA_VERSION` only on a breaking change. +- **Placement:** interim home in `apps/web/app/lib/` because contract #1 must import #80's + `report-schema.ts` (also in `apps/web`). End-state: once #80's schema is stable, promote the + vocabulary into `packages/api-contract` (or a new `@sigma/assistant-contract`) and re-export from + there, inverting today's direction. Note `packages/api-contract` already exports a **different** + `EntityKind` (`company | consortium`) than the assistant's (`company | authority | contract`) — + namespace them on any future merge. + +## Fixtures + +- `resolved-report` / `stored-report` use **fabricated placeholders** (`Компания А`–`Д`, zero-prefixed + EIKs) on purpose: this product's core risk is wrong numbers on a real firm, so sample data must not + name a real entity. `fixtures.test.ts` asserts they conform to the types and that provenance aligns + to the snapshot (run with the web app's test command on a checkout where #80 is present). +- `chat-stream.sample.json` is a `{_note, messages}` wrapper, **not** a bare `UIMessage[]`. Its + `tool-run_sql` part uses the **AI SDK v6** UIMessage tool-part shape (`type: 'tool-'`, `state`, + `input`/`output`) — correct, not the v4 `tool-invocation` shape. The run_sql `output` payload is + illustrative; pin it to #80's `tools.ts`. + +## Open seam questions (resolve with nedda76 before wiring) + +1. **`emit_report` → id.** #80 returns the `ResolvedReport` inline with no id. The persist lane must + store it and stream the `data-report-ready` part. Agree where persist hooks in (after + `finalizeReport`, server-side) so the model never sees the id. +2. **`link.idCol` projection.** A `table` block's `link.idCol` (e.g. `eik`) must be present in the + resolved row for the renderer to build the href. #80's `bindReport` projects only `columns[].key`, + so **`idCol` must currently be a displayed column** (the fixtures keep `eik` visible). Either fix + `bindReport` to always project `link.idCol`, or keep the constraint. +3. **`run_sql` tool-output shape.** Pin the `tool-run_sql` `output` in the stream to #80's `tools.ts` + so the dock's status rendering matches. +4. **Per-source freshness + provenance.** `provenance.freshness` and `provenance.sources` should be + derived from the served `data_freshness` view (per `admin`/`ocds`, + the `eop_fetch` date). Curated + tools and `eop_fetch` produce snapshot rows with no SQL — `sources[].sql` is optional, `tool` names + the path. +5. **R2 lifecycle / 404.** Spec §5 allows stale reports to be deleted (a chip may 404). Define the + renderer + dock behaviour for a missing report (regenerate vs. message) — a persist↔renderer seam. +6. **Corpus version (reproducibility).** `freshness.asOf` dates are a proxy; a stronger anchor would be + a dataset/ingest id. Open whether to add `provenance.corpusVersion`. diff --git a/apps/web/app/lib/assistant-contract/fixtures.test.ts b/apps/web/app/lib/assistant-contract/fixtures.test.ts new file mode 100644 index 00000000..b1454e12 --- /dev/null +++ b/apps/web/app/lib/assistant-contract/fixtures.test.ts @@ -0,0 +1,64 @@ +// Drift guard for the published contract fixtures (repo convention: tests-with-code). +// +// The "build in parallel against fixtures" plan only holds if the fixtures actually match the shapes +// the four lanes import. These checks fail if a fixture drifts from `ResolvedReport` / `StoredReport` +// / the `data-report-ready` part, and additionally verify referential integrity that a pure type +// check can't (JSON imports widen the discriminant to `string`): every provenance source names a real +// snapshot result set, every snapshot result is explained by a source, and snapshot rows align to +// their columns. Run with the web app's test command on a checkout where #80 is present. + +import { describe, expect, it } from 'vitest'; +import { REPORT_READY_PART } from './stream'; +import resolved from './fixtures/resolved-report.sample.json'; +import stored from './fixtures/stored-report.sample.json'; +import chat from './fixtures/chat-stream.sample.json'; + +const BLOCK_TYPES = new Set([ + 'text', + 'callout', + 'totals', + 'facts', + 'table', + 'bar', + 'flows', + 'timeseries', +]); + +describe('assistant-contract fixtures', () => { + it('resolved-report: titled, ai-generated watermark, only known block types', () => { + expect(typeof resolved.title).toBe('string'); + expect(resolved.title.length).toBeGreaterThan(0); + expect(resolved.watermark).toBe('ai-generated'); + expect(resolved.blocks.length).toBeGreaterThan(0); + for (const b of resolved.blocks) expect(BLOCK_TYPES.has(b.type)).toBe(true); + }); + + it('stored-report: schemaVersion 1, watermark, provenance aligned to snapshot', () => { + expect(stored.schemaVersion).toBe(1); + expect(stored.report.watermark).toBe('ai-generated'); + + const snapshotHandles = new Set(stored.provenance.snapshot.map((s) => s.handle)); + const sourceHandles = new Set(stored.provenance.sources.map((s) => s.handle)); + // every provenance source points at a real result set … + for (const h of sourceHandles) expect(snapshotHandles.has(h)).toBe(true); + // … and every result set is explained by a source (each figure is auditable) + for (const h of snapshotHandles) expect(sourceHandles.has(h)).toBe(true); + + expect(stored.provenance.freshness.length).toBeGreaterThan(0); + + // rows align to columns + for (const r of stored.provenance.snapshot) { + for (const row of r.rows) expect(row.length).toBe(r.columns.length); + } + }); + + it('chat-stream: carries a report-ready chip part for the dock', () => { + const parts = chat.messages.flatMap((m) => m.parts ?? []); + const ready = parts.find((p) => p.type === REPORT_READY_PART) as + | { data?: { reportId?: string; title?: string } } + | undefined; + expect(ready).toBeTruthy(); + expect(typeof ready?.data?.reportId).toBe('string'); + expect(typeof ready?.data?.title).toBe('string'); + }); +}); diff --git a/apps/web/app/lib/assistant-contract/fixtures/chat-stream.sample.json b/apps/web/app/lib/assistant-contract/fixtures/chat-stream.sample.json new file mode 100644 index 00000000..1e35065c --- /dev/null +++ b/apps/web/app/lib/assistant-contract/fixtures/chat-stream.sample.json @@ -0,0 +1,31 @@ +{ + "_note": "A sample useChat() turn (Path-B / report). This file is a {_note, messages} WRAPPER, not a bare UIMessage[]. Text deltas and `tool-*` parts are STANDARD Vercel AI SDK v6 UIMessage parts — `type: 'tool-'` with `state` and `input`/`output` (NOT the v4 `tool-invocation` shape); build the dock against useChat, not this literal. The custom contract is the `data-report-ready` part on the assistant message. The `tool-run_sql` output payload is illustrative — pin it to #80's tools.ts.", + "messages": [ + { + "id": "m1", + "role": "user", + "parts": [{ "type": "text", "text": "Сравни топ 5 строителни компании по сума за 2023" }] + }, + { + "id": "m2", + "role": "assistant", + "parts": [ + { + "type": "text", + "text": "Ето петте най-големи строителни компании по обща стойност на договорите за 2023 г." + }, + { + "type": "tool-run_sql", + "toolCallId": "call_1", + "state": "output-available", + "input": { "sql": "WITH c AS (… SUM(amount_eur) … CPV 45 … 2023 …) SELECT …" }, + "output": { "handle": "R1", "rowCount": 5, "truncated": false } + }, + { + "type": "data-report-ready", + "data": { "reportId": "r_8KQ2mZ7v", "title": "Топ 5 строителни компании по сума (2023)" } + } + ] + } + ] +} diff --git a/apps/web/app/lib/assistant-contract/fixtures/resolved-report.sample.json b/apps/web/app/lib/assistant-contract/fixtures/resolved-report.sample.json new file mode 100644 index 00000000..7e2f31d3 --- /dev/null +++ b/apps/web/app/lib/assistant-contract/fixtures/resolved-report.sample.json @@ -0,0 +1,55 @@ +{ + "title": "Топ 5 строителни компании по сума (2023)", + "question": "Сравни топ 5 строителни компании по обща стойност на договорите за 2023", + "blocks": [ + { + "type": "text", + "md": "Петте най-големи изпълнители в строителството (CPV 45) за 2023 г. по обща стойност на подписаните договори. *Примерни данни — измислени компании.*" + }, + { + "type": "totals", + "items": [ + { "label": "Обща стойност (топ 5)", "value": 412300000, "format": "money" }, + { "label": "Брой договори", "value": 882, "format": "number" } + ] + }, + { + "type": "table", + "columns": [ + { + "key": "company", + "header": "Компания", + "align": "left", + "format": "text", + "link": { "kind": "company", "idCol": "eik" } + }, + { "key": "eik", "header": "ЕИК", "align": "left", "format": "text" }, + { "key": "total_eur", "header": "Обща стойност", "align": "right", "format": "money" }, + { "key": "contracts", "header": "Договори", "align": "right", "format": "number" } + ], + "rows": [ + { "cells": ["Компания А", "000000001", 142000000, 312] }, + { "cells": ["Компания Б", "000000002", 98500000, 205] }, + { "cells": ["Компания В", "000000003", 71200000, 150] }, + { "cells": ["Компания Г", "000000004", 58900000, 118] }, + { "cells": ["Компания Д", "000000005", 41700000, 97] } + ] + }, + { + "type": "bar", + "points": [ + { "label": "Компания А", "value": 142000000 }, + { "label": "Компания Б", "value": 98500000 }, + { "label": "Компания В", "value": 71200000 }, + { "label": "Компания Г", "value": 58900000 }, + { "label": "Компания Д", "value": 41700000 } + ] + }, + { + "type": "callout", + "title": "Как е изчислено", + "md": "Броим amount_eur по подписани договори за CPV 45*, signed_at в 2023. Изключени договори с непотвърдена стойност (value_suspect). Примерни данни — измислени компании." + } + ], + "watermark": "ai-generated" +} diff --git a/apps/web/app/lib/assistant-contract/fixtures/stored-report.sample.json b/apps/web/app/lib/assistant-contract/fixtures/stored-report.sample.json new file mode 100644 index 00000000..98e6220b --- /dev/null +++ b/apps/web/app/lib/assistant-contract/fixtures/stored-report.sample.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "id": "r_8KQ2mZ7v", + "createdAt": "2026-06-21T09:30:00Z", + "report": { + "title": "Топ 5 строителни компании по сума (2023)", + "question": "Сравни топ 5 строителни компании по обща стойност на договорите за 2023", + "blocks": [ + { + "type": "text", + "md": "Петте най-големи изпълнители в строителството (CPV 45) за 2023 г. по обща стойност на подписаните договори. *Примерни данни — измислени компании.*" + }, + { + "type": "totals", + "items": [ + { "label": "Обща стойност (топ 5)", "value": 412300000, "format": "money" }, + { "label": "Брой договори", "value": 882, "format": "number" } + ] + }, + { + "type": "table", + "columns": [ + { + "key": "company", + "header": "Компания", + "align": "left", + "format": "text", + "link": { "kind": "company", "idCol": "eik" } + }, + { "key": "eik", "header": "ЕИК", "align": "left", "format": "text" }, + { "key": "total_eur", "header": "Обща стойност", "align": "right", "format": "money" }, + { "key": "contracts", "header": "Договори", "align": "right", "format": "number" } + ], + "rows": [ + { "cells": ["Компания А", "000000001", 142000000, 312] }, + { "cells": ["Компания Б", "000000002", 98500000, 205] }, + { "cells": ["Компания В", "000000003", 71200000, 150] }, + { "cells": ["Компания Г", "000000004", 58900000, 118] }, + { "cells": ["Компания Д", "000000005", 41700000, 97] } + ] + }, + { + "type": "bar", + "points": [ + { "label": "Компания А", "value": 142000000 }, + { "label": "Компания Б", "value": 98500000 }, + { "label": "Компания В", "value": 71200000 }, + { "label": "Компания Г", "value": 58900000 }, + { "label": "Компания Д", "value": 41700000 } + ] + }, + { + "type": "callout", + "title": "Как е изчислено", + "md": "Броим amount_eur по подписани договори за CPV 45*, signed_at в 2023. Изключени договори с непотвърдена стойност (value_suspect). Примерни данни — измислени компании." + } + ], + "watermark": "ai-generated" + }, + "provenance": { + "question": "Сравни топ 5 строителни компании по обща стойност на договорите за 2023", + "sources": [ + { + "handle": "R1", + "tool": "run_sql", + "sql": "WITH c AS (SELECT b.name AS company, b.eik AS eik, SUM(ct.amount_eur) AS total_eur, COUNT(*) AS contracts FROM contracts ct JOIN bidders b ON b.id = ct.bidder_id JOIN tenders t ON t.id = ct.tender_id WHERE ct.amount_eur IS NOT NULL AND substr(t.cpv_code,1,2) = '45' AND strftime('%Y', ct.signed_at) = '2023' GROUP BY b.eik ORDER BY total_eur DESC LIMIT 5) SELECT company, eik, total_eur, contracts FROM c" + } + ], + "snapshot": [ + { + "handle": "R1", + "columns": ["company", "eik", "total_eur", "contracts"], + "rows": [ + ["Компания А", "000000001", 142000000, 312], + ["Компания Б", "000000002", 98500000, 205], + ["Компания В", "000000003", 71200000, 150], + ["Компания Г", "000000004", 58900000, 118], + ["Компания Д", "000000005", 41700000, 97] + ], + "truncated": false + } + ], + "freshness": [ + { "source": "admin", "asOf": "2026-06-18" }, + { "source": "ocds", "asOf": "2026-06-17" } + ], + "model": "bggpt-gemma-3-27b-fp8", + "promptVersion": "2026-06-20" + } +} diff --git a/apps/web/app/lib/assistant-contract/report.ts b/apps/web/app/lib/assistant-contract/report.ts new file mode 100644 index 00000000..87badcb4 --- /dev/null +++ b/apps/web/app/lib/assistant-contract/report.ts @@ -0,0 +1,84 @@ +// Assistant contracts #1 + #2 — the typed seams between nedda76's backend (#80) and our lanes. +// +// #1 Block-spec (backend → renderer): the renderer draws a `ResolvedReport`. SOURCE OF TRUTH is +// #80's `report-schema.ts` (model emits refs → `bindReport()` re-binds real values → resolved +// shape, spec §4). We RE-EXPORT it so the renderer/persist lanes import ONE type, never a copy. +// #2 R2 stored object (persist → renderer): NEW (persist lane). `StoredReport` wraps the resolved +// report with provenance so `/reports/:id` renders LLM-free + D1-free from one immutable object +// (spec §5) and every figure stays auditable. +// +// Dependency direction: this module MAY import from `../assistant`; `../assistant` must NEVER import +// from here. (Design rationale: spec §4/§5/§7 + the §9 hardening review in PR #79.) +// See ./README.md. + +export type { + ResolvedReport, + ResolvedBlock, + QueryResult, + CellFormat, + EntityKind, + EmitTableColumn, +} from '../assistant/report-schema'; + +import type { ResolvedReport, QueryResult } from '../assistant/report-schema'; + +// Renderer obligation: `ResolvedReport`'s text/callout `md` is pre-sanitized by `bindReport` +// (sanitizeProse strips raw HTML, spec §7), but the renderer MUST still render markdown with +// raw-HTML passthrough DISABLED — the sanitization guarantee is lost if the markdown renderer +// re-introduces an HTML sink. Entity links are built by the renderer from `{kind,id}` refs +// (`EmitTableColumn.link`); the model never supplies a URL. + +export type FreshnessSource = 'admin' | 'ocds' | 'eop'; +export interface SourceFreshness { + source: FreshnessSource; + asOf: string; // ISO-8601 date (date-time for the live eop_fetch case) +} + +// One provenance entry per result set in the snapshot, linked by `handle`. Not every result comes +// from SQL: curated tools (`get_company`, `search_entities`) and `eop_fetch` produce snapshot rows +// with NO SQL — so `sql` is optional and `tool` names the path. "View the query" shows `sql` when +// present, otherwise names the tool. (Closes the run_sql-only gap.) +export interface ProvenanceSource { + handle: string; // matches a QueryResult.handle in `snapshot` + tool: string; // 'run_sql' | 'search_entities' | 'get_company' | 'eop_fetch' | … + sql?: string; // present only for run_sql +} + +// Role-④ (LLM Verifier) audit trail — what the risk-scaled verification pass decided for this report +// (spec addendum §1/§2 defense 5). 'skipped' = deterministic gate found no ranking/risk claims (no LLM +// call); 'verified' = verdicts applied; 'error' = the verifier call failed and the fail-closed strip +// removed all extracted prose claims except the structural „Как е изчислено" methodology callout +// (guardrail D — kept + flagged). Claim ids ("C0"…) are the verifier's stable numbering: title +// first, then text/callout blocks in report order (see ../assistant/verifier.ts extractClaims). +export type ReportVerificationStatus = 'skipped' | 'verified' | 'error'; +export interface ReportVerification { + status: ReportVerificationStatus; + strippedClaimIds: string[]; // prose blocks removed from the published report + uncertainClaimIds: string[]; // kept-but-flagged (uncertain verdicts + an unsupported title/methodology callout) + errors?: string[]; // present only on status 'error' — why the pass fail-closed (server-side audit; stripped from the client payload) +} + +export interface ReportProvenance { + question: string; // the asked question (also shown on the report — watermark, spec §4/§7) + sources: ProvenanceSource[]; // how each snapshot result set was produced (one per handle) + snapshot: QueryResult[]; // the bounded result sets, embedded so the view never re-queries D1 (§4/§5) + freshness: SourceFreshness[]; // per-source as-of; a report mixing sources shows each + model: string; // e.g. 'bggpt-gemma-3-27b-fp8' + promptVersion: string; // system-prompt / describe-schema version, for regression tracing + // ADDITIVE (schemaVersion stays 1): absent on reports persisted before the verifier existed. + verification?: ReportVerification; + // (open) `corpusVersion?: string` — a stronger reproducibility anchor than freshness dates; see README. +} + +// Embedded in every stored report so v1/v2/… all render forever. The WRITER pins the literal; the +// READER (/reports/:id) must switch on `schemaVersion`, keep old branches forever, and treat an +// unknown (future) version as best-effort render, not a hard failure. Bump only on a breaking change. +export const STORED_REPORT_SCHEMA_VERSION = 1 as const; + +export interface StoredReport { + schemaVersion: typeof STORED_REPORT_SCHEMA_VERSION; + id: string; // random, unguessable — do not treat as a privacy boundary; /reports enumerates all IDs + createdAt: string; // ISO-8601 UTC + report: ResolvedReport; // contract #1 — renderable content (render md with raw-HTML disabled) + provenance: ReportProvenance; // contract #2 — provenance the renderer also surfaces +} diff --git a/apps/web/app/lib/assistant-contract/stream.test.ts b/apps/web/app/lib/assistant-contract/stream.test.ts new file mode 100644 index 00000000..807380ef --- /dev/null +++ b/apps/web/app/lib/assistant-contract/stream.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { INSUFFICIENT_DATA_MESSAGE, isPhasePart, REPORT_FAILED_MESSAGE } from './stream'; + +describe('isPhasePart', () => { + it('accepts each of the three valid phase keys', () => { + expect(isPhasePart({ type: 'data-phase', data: { phase: 'thinking' } })).toBe(true); + expect(isPhasePart({ type: 'data-phase', data: { phase: 'querying' } })).toBe(true); + expect(isPhasePart({ type: 'data-phase', data: { phase: 'composing' } })).toBe(true); + }); + + it('rejects a part whose type is not data-phase', () => { + expect(isPhasePart({ type: 'data-report-ready', data: { phase: 'thinking' } })).toBe(false); + }); + + it('rejects a phase part with no data', () => { + expect(isPhasePart({ type: 'data-phase' })).toBe(false); + }); + + it('rejects an unknown phase key', () => { + expect(isPhasePart({ type: 'data-phase', data: { phase: 'running' } })).toBe(false); + }); + + it('rejects a non-string phase', () => { + expect(isPhasePart({ type: 'data-phase', data: { phase: 2 } })).toBe(false); + }); +}); + +describe('user-facing failure messages', () => { + it('keeps the technical compose-failure message distinct from the insufficient-data one', () => { + // A thrown/rejected emit_report means the report FAILED — the data may well exist. Labeling it + // "insufficient data" asserts a wrong cause (PR #51 review), so the two can never re-unify. + expect(REPORT_FAILED_MESSAGE).toBe('Справката не можа да бъде съставена. Опитайте отново.'); + expect(REPORT_FAILED_MESSAGE).not.toContain(INSUFFICIENT_DATA_MESSAGE); + expect(INSUFFICIENT_DATA_MESSAGE).not.toContain(REPORT_FAILED_MESSAGE); + }); +}); diff --git a/apps/web/app/lib/assistant-contract/stream.ts b/apps/web/app/lib/assistant-contract/stream.ts new file mode 100644 index 00000000..51458fb9 --- /dev/null +++ b/apps/web/app/lib/assistant-contract/stream.ts @@ -0,0 +1,87 @@ +// Assistant contract #3 — the chat stream (backend → dock). +// +// Tokens and tool-status are NOT a custom protocol: #80's `/assistant/chat` returns the Vercel +// AI SDK UIMessage stream via `result.toUIMessageStreamResponse()`, and the dock consumes it with +// `useChat` from `@ai-sdk/react`. Text deltas and tool parts (AI SDK v6: `type: 'tool-'` with +// states input-streaming → input-available → output-available / output-error, carrying `input` and +// `output`) are STANDARD SDK parts — build the dock against the SDK, not a hand-rolled type. +// +// The ONLY custom addition is below: once our persist lane stores a report to R2, it streams a +// `report-ready` data part carrying the report id, which the dock renders as a clickable chip +// linking to `/reports/:id`. (#80 today returns the resolved report inline from `emit_report` with +// NO id, because the persist lane doesn't exist yet — wiring this part is our seam.) + +/** AI SDK custom data-part name. Custom data parts are namespaced `data-*` and appear in an + * assistant message's `parts` array. */ +export const REPORT_READY_PART = 'data-report-ready' as const; + +export interface ReportReadyData { + reportId: string; // → /reports/:id (the canonical, shareable, immutable URL) + title: string; // chip label +} + +/** The shape the dock matches on inside `message.parts`: + * { type: 'data-report-ready', data: { reportId, title } } + * Emit it server-side with the AI SDK stream writer once the StoredReport is persisted. */ +export interface ReportReadyPart { + type: typeof REPORT_READY_PART; + data: ReportReadyData; +} + +/** Narrowing helper for the dock. Type-tag check ONLY — this part is server-emitted and trusted, so + * `data` is not re-validated here (forged-transcript defenses live server-side, not in the dock). */ +export function isReportReadyPart(part: { type: string }): part is ReportReadyPart { + return part.type === REPORT_READY_PART; +} + +// The canonical user-facing sentence for a turn the assistant cannot answer precisely — no data, an +// empty completion, or a report that could not be composed. One source of truth so the system prompt +// (NO_DATA_RULE), the server fallbacks (agent.ts, stream-phase.ts), and the dock's no-answer line +// can't drift into three different wordings. +export const INSUFFICIENT_DATA_MESSAGE = + 'Не разполагам с достатъчно информация, за да отговоря прецизно на този въпрос.'; + +// The sibling message for TECHNICAL report failures — a thrown emit_report, a validateEmitShape +// rejection, the dock's ok:false line. Deliberately distinct from INSUFFICIENT_DATA_MESSAGE: in these +// cases the data may well exist, so claiming "insufficient data" would assert a wrong cause +// (PR #51 review). Keep the two apart. +export const REPORT_FAILED_MESSAGE = 'Справката не можа да бъде съставена. Опитайте отново.'; + +// The terminal report tool's name and its SDK UI-message part type (`tool-${name}`). One source of +// truth so the server filter, the agent registration, and the dock projection can't drift apart. +export const EMIT_REPORT_TOOL = 'emit_report' as const; +export const EMIT_REPORT_PART = `tool-${EMIT_REPORT_TOOL}` as const; + +// ── Turn phase (backend → dock) ────────────────────────────────────────────────────────────────── +// +// The ONLY progress signal the dock receives during a turn. The backend's stream filter +// (lib/assistant/stream-phase.ts) collapses the internal tool loop — SQL assembly, raw rows, +// reconcile — into these coarse keys; no tool name, SQL, or free text ever crosses the wire. +// Emitted as a `transient` data part: delivered to useChat's onData but never added to +// message.parts, so a phase is never persisted to the transcript. + +/** Closed enum of turn phases. The wire carries only the key; the dock maps it to a fixed label. */ +export const ASSISTANT_PHASES = ['thinking', 'querying', 'composing'] as const; +export type AssistantPhase = (typeof ASSISTANT_PHASES)[number]; + +/** AI SDK custom data-part name (namespaced `data-*`), sibling of REPORT_READY_PART. */ +export const PHASE_PART = 'data-phase' as const; + +export interface PhaseData { + phase: AssistantPhase; +} +export interface PhasePart { + type: typeof PHASE_PART; + data: PhaseData; +} + +const PHASE_KEYS: ReadonlySet = new Set(ASSISTANT_PHASES); + +/** Unlike isReportReadyPart above, this DOES validate `data` against the closed enum: the key drives + * a client-side label lookup, so an unknown key must narrow to "no phase", never render raw. */ +export function isPhasePart(part: { type: string; data?: unknown }): part is PhasePart { + if (part.type !== PHASE_PART) return false; + const data = part.data; + if (typeof data !== 'object' || data === null || !('phase' in data)) return false; + return typeof data.phase === 'string' && PHASE_KEYS.has(data.phase); +} diff --git a/apps/web/app/lib/assistant-dock/AssistantComposer.test.tsx b/apps/web/app/lib/assistant-dock/AssistantComposer.test.tsx new file mode 100644 index 00000000..51d2b641 --- /dev/null +++ b/apps/web/app/lib/assistant-dock/AssistantComposer.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AssistantComposer, appendTranscript } from './AssistantComposer'; + +afterEach(() => { + cleanup(); +}); + +const noop = () => {}; + +describe('AssistantComposer', () => { + it('sends the trimmed text on Enter and clears the field', async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + const input = screen.getByLabelText('Съобщение до асистента'); + + await user.type(input, ' здравей {Enter}'); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith('здравей'); + expect(input).toHaveValue(''); + }); + + it('inserts a newline on Shift+Enter without sending', async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + const input = screen.getByLabelText('Съобщение до асистента'); + + await user.type(input, 'ред1{Shift>}{Enter}{/Shift}ред2'); + + expect(onSend).not.toHaveBeenCalled(); + expect(input).toHaveValue('ред1\nред2'); + }); + + it('disables send when the field is empty', () => { + render(); + + expect(screen.getByRole('button', { name: 'Изпрати' })).toBeDisabled(); + }); + + it('disables the input while busy', () => { + render(); + + expect(screen.getByLabelText('Съобщение до асистента')).toBeDisabled(); + }); + + it('shows the Stop button while busy', () => { + render(); + + expect(screen.getByRole('button', { name: 'Спри' })).toBeInTheDocument(); + }); + + it('hides the Send button while busy', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Изпрати' })).not.toBeInTheDocument(); + }); + + it('calls onStop when Stop is clicked', async () => { + const user = userEvent.setup(); + const onStop = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Спри' })); + + expect(onStop).toHaveBeenCalledTimes(1); + }); + + it('renders an enabled mic toggle with an accessible name', () => { + render(); + + const mic = screen.getByRole('button', { name: 'Гласово въвеждане' }); + expect(mic).toBeEnabled(); + expect(mic).toHaveAttribute('aria-pressed', 'false'); + }); + + it('exposes a polite status region for voice announcements', () => { + render(); + + // Distinct from the transcript's log region; empty at rest but present so aria-live can announce. + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('hides the Clear button while the draft is empty', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Изчисти' })).not.toBeInTheDocument(); + }); + + it('shows Clear once the draft has text and empties it on click', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText('Съобщение до асистента'); + + await user.type(input, 'някакъв текст'); + await user.click(screen.getByRole('button', { name: 'Изчисти' })); + + expect(input).toHaveValue(''); + }); + + it('keeps the textarea usable when NOT in a chat turn (never a dead mic)', () => { + render(); + + // Voice state must never disable the textarea — a user who can't use the mic can always type. + expect(screen.getByLabelText('Съобщение до асистента')).toBeEnabled(); + }); +}); + +describe('appendTranscript', () => { + it('returns the transcript alone when the draft is empty', () => { + expect(appendTranscript('', 'здравей')).toBe('здравей'); + }); + + it('joins with a single space when the draft has no trailing whitespace', () => { + expect(appendTranscript('купи', 'хляб')).toBe('купи хляб'); + }); + + it('adds no extra space when the draft already ends in a space', () => { + expect(appendTranscript('купи ', 'хляб')).toBe('купи хляб'); + }); + + it('appends directly after a trailing newline', () => { + expect(appendTranscript('ред1\n', 'ред2')).toBe('ред1\nред2'); + }); +}); diff --git a/apps/web/app/lib/assistant-dock/AssistantComposer.tsx b/apps/web/app/lib/assistant-dock/AssistantComposer.tsx new file mode 100644 index 00000000..4d230e05 --- /dev/null +++ b/apps/web/app/lib/assistant-dock/AssistantComposer.tsx @@ -0,0 +1,142 @@ +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type FormEvent, + type KeyboardEvent, +} from 'react'; +import { AssistantComposerMic } from './AssistantComposerMic'; +import { micStatusText } from './errors'; +import { useVoiceInput } from './useVoiceInput'; + +interface AssistantComposerProps { + /** Submit a (trimmed, non-empty) message. */ + onSend: (text: string) => void; + /** Cancel the in-flight turn. */ + onStop: () => void; + /** A turn is in flight (status 'submitted' | 'streaming') — disable input, swap Send for Stop. */ + busy: boolean; +} + +// The transcript-ready cue (a11y contract): announced + visible so the user knows the voice text is in the +// box and how to send it — voice never auto-sends. +const TRANSCRIPT_READY = + 'Готово. Текстът е в полето за съобщение - прегледайте го и натиснете Изпрати.'; + +// Append dictated text with exactly one separator — no double space when the draft already ends in whitespace. +export const appendTranscript = (prev: string, next: string): string => + prev === '' ? next : /\s$/.test(prev) ? `${prev}${next}` : `${prev} ${next}`; + +/** + * The message input. Owns its own textarea value (the chat hook owns the message list, not the draft). + * Enter sends; Shift+Enter inserts a newline. Voice input records a clip, transcribes it, and appends the + * text to the draft — editable, never auto-sent; the textarea stays usable through every mic state so a + * user who can't type is never trapped. + */ +export const AssistantComposer = ({ onSend, onStop, busy }: AssistantComposerProps) => { + const [text, setText] = useState(''); + const [transcriptReady, setTranscriptReady] = useState(false); + const inputId = useId(); + const inputRef = useRef(null); + + // A finished transcript appends to the draft (with a separating space). Focus deliberately STAYS on the + // mic button — moving it to the textarea would cut off the screen-reader announcement (a11y contract). + const handleTranscript = useCallback((transcript: string) => { + setText((prev) => appendTranscript(prev, transcript)); + setTranscriptReady(true); + }, []); + const voice = useVoiceInput(handleTranscript); + + // Wipe the whole draft in one action — easier than select-all-delete for motor/cognitive users who + // dislike a dictated result and want to restart rather than edit it word by word. + const clearDraft = useCallback(() => { + setText(''); + setTranscriptReady(false); + inputRef.current?.focus(); + }, []); + + // The composer-level status line: the active voice state, or (once idle) the transcript-ready cue. + const voiceStatus = + voice.state.status === 'idle' + ? transcriptReady + ? TRANSCRIPT_READY + : '' + : micStatusText(voice); + + // Grow the textarea to fit its content (capped by the CSS max-height), and shrink back when cleared. + useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, [text]); + + // One source of truth for "can this draft be sent" — reused by submit, the Send button, and Clear. + const trimmed = text.trim(); + const canSend = !busy && trimmed !== ''; + + const submit = () => { + if (!canSend) return; + onSend(trimmed); + setText(''); + setTranscriptReady(false); + }; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + submit(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(); + } + }; + + return ( +
+ +