Skip to content

[DO NOT MERGE] feat(etl): седмичен дайджест „Седмицата в пари“ — производител + понеделнишки cron (#167A) - #324

Draft
DiyanaDimitrova wants to merge 443 commits into
midt-bg:mainfrom
lyubomir-bozhinov:feat/weekly-digest

Conversation

@DiyanaDimitrova

@DiyanaDimitrova DiyanaDimitrova commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Реализира #167A — Седмичен дайджест: Производител (конвейер · данни · генериране) по плана в docs/implementation-plans/167-weekly-digest.md.

Какво прави функцията

Това е генериращата част на „Седмицата в пари" — автоматичен седмичен обзор на обществените поръчки. Всеки понеделник ETL работникът:

  1. взима изминалата уредена ISO седмица (само след като данните са се уталожили);
  2. смята осем показателя от вече наличните договори — обща стойност, брой поръчки, най-голяма поръчка, дял на едноличните оферти, изменение спрямо предната седмица, топ-10 поръчки, разбивка по сектор (CPV) и по възложител, дневна стойност за графиката;
  3. дава числата на езиков модел (BgGPT през AI Gateway) само за свързващия разказ — числата остават 100% от SQL, моделът не измисля и не преизчислява стойности;
  4. записва готовия артикул в R2, а лек индекс — в D1.

Дев Б консумира готовия StoredReport; това PR само го произвежда (SSR маршрутът /weeks/{ISO} и рендерерът са извън обхвата — #167B).

Ключово за точността: два гейта се изпълняват преди каквато и да е заявка или разход за модел — уредена седмица и ненулев брой редове. Всяко неудостоверено число се маха, вместо да се пренапише, и никога не се записва.

⚠️ Прочети първо — базата е main, и до сливането на #79 това е надмножество

Огледало е на PR-а във fork-а lyubomir-bozhinov/sigma#80, където дайджестът стъпва върху feat/ai-assistant-contracts. Този клон не съществува в това хранилище, затова PR-ът е насочен към main.

Следствие: докато #79 (feat(ai-assistant): conversational analytic layer (BgGPT)) не се слее в main, разликата тук е надмножество — включва несляната работа по ai-assistant/contracts (конвейерът emit→bind→validate→verify на @sigma/report, скелето на ETL cron-а, ADR-0007), от която дайджестът зависи, плюс самата седмична добавка отгоре.

Препоръчана последователност: първо ревю/сливане на #79, след което това се свежда само до седмичния дайджест. Фокусираната, вече ревюирана разлика живее в #80 във fork-а — ползвайте нея за прегледа; това PR пренася същата работа в midt-bg/sigma.

Какво съдържа (седмичната добавка върху #79)

T1 — извличане на @sigma/report

  • Чист, worker-агностичен пакет (без React, без cloudflare:*), за да върви един и същ конвейер emit→bind→validate→verify и в уеб worker-а, и в ETL cron-а. Преместен с git mv (запазена история); @sigma/web реекспортира от него (стеснено, изрични реекспорти, не export *), така че чат-тестовете минават непроменени — доказателството срещу дрейф.
  • Ново persist.ts: StoredReport/Provenance, persistReport(bucket, key, stored), readStoredReport. R2 идва като инжектиран R2Bucket — без зашити имена на binding.
  • Ново ISO-седмичен помощник, тестван на границите W52/W53/W01.

T2 — слой за данни

  • 0013_weekly_digests.sql: weekly_digests(iso_week PK, as_of, refreshed_at, status, total_eur)само архивен индекс; самият доклад стои в R2 като единствен източник на истина. (Номериран 0013 след синхронизацията с напредналата база — веригата вече стига до 0012.)
  • packages/db/src/queries/weekly.ts: по една функция на показател a–h. Всеки показател изключва синтетичните „сирашки" договори (is_synthetic != 1, през споделения WEEK_FILTER, като rollup-ите в precompute.sql); парите са с amount_eur IS NOT NULL, сектор през substr(cpv_code,1,2), единична оферта на bids_received=1 зад праг ≥20 извадка, топ-10 с id-та за връзки.
  • Сверка (tripwire): сравнява седмичното SUM(amount_eur) с home_totals.value_eur и логва при отклонение (валидна свободна горна граница — седмицата е строго подмножество; никога не приравнява броячи).

T3 — ETL cron

  • DIGEST_CRON = '0 7 * * 1' (понеделник 07:00 UTC, след опреснението в 06:00), добавен в реда, който cron-guard очаква.
  • Конвейер на weekly-digest.ts: котва за свежест → ГЕЙТ 1 уредена седмица (ADR-0007) → ГЕЙТ 2 къс път при нула редове → заявки a–h → сверка → emit блокове → разказ от модела (BgGPT през AI Gateway) → bindReport → гейт findProseNumbers → ограничено регенериране → verifier премахва неудостовереното → все още невалидно ⇒ шаблон без AIpersistReport(…) (без immutable — обектът се презаписва на място при преиздаване, така че immutable би бил капан за остаряло сервиране) → UPSERT → структуриран лог.
  • wrangler.toml получава binding-ите AI и REPORTS плюс kill-switch променливата; wrangler-render.mjs е обновен съответно.

Тестове

Писани заедно с кода (изискване за TDD по плана):

  • Преместените тестове минават непроменени (доказва, че извличането не е дрейфнало).
  • persist.ts срещу златната фикстура; ISO-седмичният помощник на W52/W53/W01.
  • Седмичните заявки върху реален SQLite с посята седмица Пн–Нд плюс граничните Нд 23:59 / Пн 00:00; нулева седмица връща празно; NULL сумите се изключват; синтетичен ред с value_flag='ok' и най-високата сума в седмицата се изключва от total/largest/top, а седмица само от синтетични редове дава брой 0 (гейтът за нула редове не се заобикаля).
  • Матрица на гейтовете: неуредена ⇒ пропуск; 0 договора ⇒ проверява, че mock-ът на модела не е викан и няма put; невалидно след N регенерации ⇒ записва fallback; kill-switch изключен ⇒ не публикува. cron-guard разширен за DIGEST_CRON.

Бележки за ревюто

  • Спец. §6.1 казва фирма → /companies/{ЕИК}; това е грешно за bidder-и по име (name:<name>), затова връзките минават през entityHref/hrefForEntity, а не форматиране на ЕИК на ръка.
  • Извън обхвата (Дев Б / #167B): SSR маршрутът /weeks/{ISO}, ReportBlockRenderer, ReportAiWatermark.

cefothe and others added 30 commits July 2, 2026 16:16
…text

Two PROSE_NUMBER_PATTERNS in report-schema.ts false-matched ordinary prose and
made emit_report fail (ok:false) on legitimate reports — observed live: Q18
"Колко похарчи Столична община през 2023 г." failed terminally, Q2 needed a
retry (see docs/ai-assistant-chat-testing-2026-07-02.md).

- percent idiom `на\s+сто`: pin "сто" as a standalone word with `(?!\p{L})`, so
  the "сто" word-family no longer matches — "на стойност" (ubiquitous in
  procurement), the entity "Столична община", "на стотици". "12 на сто" / "на
  сто%" still match.
- grouped thousands `\d{1,3}(?:[.,\s…]\d{3})+`: add a trailing `(?!\d)` so a
  four-digit run is not read as a 3-digit group — `MM.YYYY` / `DD.MM.YYYY` dates
  (01.2026, 01.02.2026) are no longer flagged as "01.202". A real grouped amount
  always ends on a 3-digit group, so nothing valid is lost.

Adds regression tests for both classes (date notation + the сто-family) and
asserts the genuine percent idiom and grouped amounts are still caught.
The assistant answered "no match" for entities that plainly exist (e.g.
Столична община / authority 000696327). Root cause: the model resolved names
with `LIKE`/`=` on `authorities.name`, but SQLite folds case for ASCII only —
Cyrillic is compared case-sensitively and `upper()`/`lower()` leave it
unchanged. Names are stored mixed/UPPERCASE ("СТОЛИЧНА ОБЩИНА"), so a
title-case `LIKE '%Столична община%'` silently returns 0 rows. semantic_search
(vector) also missed it, leaving no working path.

Fix: add a `find_entity` tool that resolves a name to its exact join id via the
site's FTS5 `search_index` (unicode61 tokenizer folds case + diacritics for
Cyrillic and Latin), reusing the same ranked prefix-AND query the website uses
(`searchMatchQuery` from @sigma/db). It returns `search_index.ref` — the
authority_id / bidder_id the model joins on. run_sql can't do this (its parser
rejects FTS MATCH); find_entity runs a server-authored, parameterized query.

Also updates the data dictionary to steer the model to `find_entity` and warn
that `LIKE`/`=` on name is case-sensitive for Cyrillic.

Tests cover the id-per-kind formatting (the Столична община case), the
too-short-term and no-match paths, and graceful degradation on a query error.
…condense

feat(assistant): condense old chat turns into a recap message for the POST
When the weak model gathered real data (run_sql results) but never produced a
VALID emit_report within the step budget — a number in prose (correctly gated),
a malformed block shape, or simply running out of steps — the turn dead-ended
on "Справката не можа да бъде съставена" even though the answer sat in
ctx.results. Observed live for Q18 (Столична община 2023): find_entity resolved
the id and run_sql returned 250,264,972.88 € / 293 contracts, but the model put
the figure in a text block and hit the budget, so no report rendered.

Add a server-side fallback: after the model loop finishes, if no valid report
was emitted but results exist, synthesize a minimal report from the actual data
and inject it as a tool-emit_report part (the shape the dock already renders).

- report-fallback.ts: pure buildFallbackReport(results, question) — picks the
  last non-empty result and renders it as a `totals` block (single numeric row)
  or a `table` (rankings/breakdowns). Values bind through the SAME bindReport
  path, so they stay server-owned (never model-written); a fixed number-free
  title can't trip the E2 gate.
- agent.ts: wrap the model stream in createUIMessageStream; after the loop,
  inject the fallback report unless the model finalized, errored, or gathered no
  data. onError also resolves the finish latch so the stream can never hang.

Tests cover the totals/table/empty/last-non-empty/huge-number cases and
guessFormat. Full assistant suite 319 passed / 1 skipped; typecheck + prettier
clean.
…fields rules

Review follow-ups on PR #24:
- redactEmitReportOutput now also masks a `tool-input-error` (raised when the
  model's report args fail input-schema validation) — its errorText can echo
  schema/column shape, the same leak the output-side redaction already closes.
- Fold INTERNALS_NON_DISCLOSURE_RULE into NO_INTERNAL_FIELDS_RULE: one rule now
  forbids SQL/column/tool internals in BOTH the chat prose and report `text`/
  `callout` blocks (they overlapped after #20 merged).
- Rename the test's `c` chunk-builder to `asChunk` for readability.
…column names)

The server-synthesized fallback showed raw SQL column names to the reader
(total_spent_eur, contracts_count). Map them to Bulgarian display labels via a
curated dictionary keyed on the columns the model actually produces, with a
de-snaked/capitalised degrade for anything unmapped (never the raw identifier).
Only the DISPLAY label changes — bound values still reference the real column.
…lint)

Same one-line formatting as #30; the fallback branch inherits the base's
unformatted describe-schema.ts, so `prettier --check .` reds this PR's Lint
job. Identical change → merges cleanly regardless of order.
Add a 'Resolution — follow-ups landed' section: the emit-shape false-positives
(prose gate) and the newly-found Cyrillic entity-lookup bug are fixed (#27), the
server-side finalizer backstops the never-finalize cases (#31), and finishReason
'other' is documented as a benign provider quirk. Includes the live
re-verification table (Q18/Q2/Q19).
Update PR #24 to the advanced base (find_entity, chat-history condense,
prose-number gate fix). One conflict — useAssistantChat.ts imports: keep BOTH
the phase (isPhasePart/AssistantPhase) and the base's condenseForPost; their
bodies auto-merged (condenseForPost in the POST prep, phase state/reset intact).

Prettier-format describe-schema.ts (the base's find_entity commit left it
unformatted; the PR merge commit includes the base tip, so CI flags it).
…, stuck spinner)

Follow-ups from the PR-31 UI question sweep:

1. Empty completion (Q3) — a model turn that returns no report, no run_sql data,
   and no prose (finishReason 'other', 0 tokens) dead-ended on a BLANK transcript:
   the server fallback needs rows to synthesize, so it can't fire. agent.ts now
   captures finishReason + whether prose was produced, and writes an explicit
   Bulgarian affordance when the turn would otherwise be empty (a legit prose-only
   reply is left untouched). Empty completions are now logged.

2. Broken percentage (Q8) — the single-offer "Дял по стойност" totals metric
   rendered a raw euro sum as "1342360573264,6%". Root causes: the by-value
   canonical query returned only euro sums (no share column), and nothing validated
   a percent magnitude. Fixes: describe-schema now exposes single_offer_share (0..1);
   bindReport rejects a percent slot bound to a non-ratio (→ model retries);
   formatCell renders an em-dash for an impossible ratio; the fallback downgrades a
   name-guessed percent to a number when the value isn't a ratio; system prompt now
   states percent must reference a 0..1 share. Shared isImplausibleRatio threshold
   keeps binder and renderer from drifting.

3. Stuck spinner (Q13) — a fallback-finalized report leaves the model's original
   emit_report part orphaned at input-available, so the "Подготвям справка…" spinner
   never cleared. AssistantTranscript now gates the pending indicator on busy + last
   turn, so it clears once the stream settles.

Also bounds the per-year rollup query to the current period (matching the monthly
query) so stray future-dated rows (2027–2029) no longer appear.

Tests added across report-schema, render-format, report-fallback, and
AssistantTranscript. Full web unit (729) + golden (150) suites pass.
…hat then succeeds

A first emit_report that returns ok:false is normally retried (the loop re-forces
emit_report) and then lands ok:true — but the transcript flashed „Справката не можа
да бъде съставена." during the retry window, contradicting the report that appeared
a moment later. Gate the failure line on the last turn being settled (!busy): while
the turn is in flight the pending indicator carries the state; an earlier turn that
genuinely ended ok:false still shows its failure line.

Observed on Q13 (Разход по години) re-verification after the previous fix commit.
…o stray years)

Airtight follow-up to the year-rollup fix. The describe-schema template bounded the
year query, but that was guidance the weak model could drop on an exploratory query —
which then returned out-of-coverage rows (2016/2029, source date-quality errors) and
led the model to both chart them and narrate a wrong range ("от 2016 до 2026").

Add a conditional gate in assertDefaultFilters: a base-contracts query that BUCKETS by
signed_at (substr(signed_at,1,4|7) in SELECT/GROUP BY) must bracket the date range —
either a raw signed_at range (upper AND lower) or a pinning =/IN/BETWEEN on signed_at
(raw or derived). Fully-unbounded buckets, or ones constrained only by IS NOT NULL /
the GLOB well-formedness check, are rejected so the model adds the window. This mirrors
the site's own trend query (packages/db/src/queries/trend.ts) and is conditional, so
non-temporal totals/top-N are untouched — the totals⇄rollup reconciliation basis is
unchanged. With the model never seeing stray-dated rows, it no longer narrates years
outside coverage either.

- assertDefaultFilters: bucketsBySignedAt + seriesIsDateBounded (range or pin).
- describe-schema DATA_TRAPS: state the mandatory series window + "don't cite years
  outside 2020–2026".
- golden fixtures 03/11/20: add the coverage window to their bucketed SQL so they pass
  the tightened gate (22 already pins via substr(signed_at)= '2023').

Tests: 6 new gate cases (unbounded/GLOB-only rejected; range/pin/period accepted;
non-temporal unaffected). Full unit (737) + golden (150) suites pass.
…rmat + log bind miss

Address three review findings on the report fallback finalizer:

- Single-row [name, number] results dropped the label: a totals block is
  now used only when the single row is ENTIRELY numeric; a row that also
  carries a text/label column (entity name, period) routes to a 1-row
  table so that context is preserved instead of silently dropped.
- guessFormat stole counts into money via the generic "total" token:
  the count shape is now checked before the broad money pattern, gated on
  the absence of a hard currency token (total_count -> number, while
  total_spent_eur / won_eur stay money).
- The fallback failing to bind was a silent no-op: log a warn with the
  bind errors so the "had data, still no report" case is observable.
…t version

- add a 60s settle backstop around the model-loop wait so the stream
  wrapper can never hang if the SDK fails to fire onFinish/onError; bail
  without synthesizing when it trips (loop state is indeterminate)
- derive PROMPT_VERSION from a FNV-1a hash of the canonical system prompt
  so semantic edits to system-prompt.ts / describe-schema.ts re-fingerprint
  automatically instead of relying on a manual date bump
- hoist the duplicated guessFormat call in buildFallbackReport
- assert the exact default-filters callout count (3) instead of > 0
A totals item is a single headline aggregate, but the weak model bound
one to row 0 of a multi-row result — the live 'Разход по години' report
showed 'Общ разход 2020-2026: 762,1 млн. €', which was merely the 2020
row (~61x below the real ~46,6 млрд. € sum). The value is a genuine cell
so no existing gate caught it.

- bindReport now rejects a totals item whose ref points to a result with
  >1 row, so the model retries with a proper SELECT SUM/COUNT (or renders
  the series as a table/timeseries). facts stays exempt (row highlight).
- strengthen the emit-report block guide to state totals must reference a
  single-row aggregate.
- test the reject path; all golden fixtures (single-row totals) still pass.
The server-side finalizer inferred money from the bare aggregate words
total/sum without a currency token, and the count-shape guard omitted
bids/offers — so a bid/offer count aliased total_bids/sum_offers rendered
as euros (e.g. total_bids=5 -> "Обща стойност (€): 5,00 €").

- guessFormat: add оферт|bids|offers to the count shape; return money
  only on a hard currency token or a sum/total/won that isn't count-shaped.
- COLUMN_LABELS: gate the "(€)" label on a currency token (or a non-count
  total/sum) and add a "Брой оферти" label.
- tests: cover the total_bids repro end-to-end plus guessFormat/humanizeColumn.
The find_entity guidance line merged (in #27, at ebec902) before its prettier
fix landed, so the Lint (prettier --check) job fails on the base. Whitespace/
quote-style only — no behavior change.
… a number in the dock

The dock chip's leadStat reads the first totals/facts item, so a table-only
report shows just a title. Add HEADLINE_TOTALS_RULE: for a list/breakdown report
with a meaningful summary aggregate, lead with a totals block (full-population
SUM(amount_eur) / COUNT of contracts) computed by a DEDICATED aggregate query and
referenced by handle — never pointed at a list row, never written in prose. The
chip then surfaces it automatically; no dock change.

Conditional (skips flows / period-only timeseries / no-aggregate cases) to avoid
manufacturing a misleading headline on a public transparency report. Bumps
PROMPT_VERSION. Prompt-only: the golden replay never builds the prompt, so the
fixtures are untouched.
…um-first

Review found the rule listed flows/timeseries in BOTH the trigger set and the
skip clause — a weak model could manufacture a misleading SUM on a Sankey or a
period timeseries. Trigger only on table/bar; flows/timeseries stay in the skip
clause. Also pin the first totals item to the money total (leadStat shows only
the first item, so the chip leads with the sum, not the count).
Resolve PROMPT_VERSION toward the computed sp_${fnv1a(buildSystemPrompt)} form — it
auto-captures #32's HEADLINE_TOTALS_RULE, superseding the manual literal.

# Conflicts:
#	apps/web/app/lib/assistant/agent.ts
…allback

feat(assistant): server-side last-resort report finalizer (never dead-end with real data)
…feat/assistant-stream-phases

# Conflicts:
#	apps/web/app/lib/assistant-dock/AssistantTranscript.test.tsx
#	apps/web/app/lib/assistant-dock/AssistantTranscript.tsx
#	apps/web/app/lib/assistant/agent.ts
#	apps/web/app/lib/assistant/system-prompt.test.ts
…hases

feat(assistant): stream only coarse phases to the dock, not the raw agent loop
…ide)

Verify a Cloudflare Turnstile token before buffering the body or any paid model/D1 work. No-op until
TURNSTILE_SECRET is provisioned (dev/preview/staging), so it activates only at the launch gate.
New turnstile.ts (verify + reject helpers, fail-closed) + tests; hooked after firstPartyRejection;
TURNSTILE_SITE_KEY var + Env typing. Client widget/token header is the paired follow-up.
…ing (#36)

Softens missing table display columns to null cells with a recorded warning (link id cols + chart value cols still hard-error); mobile-dock onOpenReport threading; bindReport tests + warning surfacing.
…nistic gates (#38)

Adds a risk-scaled LLM verifier as role ④ behind the deterministic gates: every report (model path + deterministic fallback) passes through verify() before persist/stream, so nothing reaches the user unverified. Strip-only output channel.
DiyanaDimitrova and others added 15 commits July 27, 2026 12:20
…-artifact 404

nikimilenkov Finding 1 (should-fix): seed-weekly-digest.mjs defaulted BUCKET to the
PRODUCTION `sigma-reports` and printed `--remote` put/delete commands — a copy-paste
could publish fabricated digests (naming real institutions) on the public site and
delete real cron artifacts (recentWeeks() overlaps live weeks). Default to
sigma-reports-dev; withhold prod `--remote` commands unless SIGMA_REPORTS_NAME=sigma-
reports AND ALLOW_PROD_SEED=1, with a loud warning either way.

lyubomir MINOR (defense-in-depth): the meta noindex on /weeks/:iso doesn't cover the
React Router `.data` twin (JSON, bypasses <head>). Set `X-Robots-Tag: noindex` at the
worker for the detail path (matches `/weeks/:iso` + `/weeks/:iso.data`), leaving the
archive `/weeks` indexable. Integration tests for both twins + the archive.

nikimilenkov Finding 2 (minor): readStoredReport does no shape validation, so a valid-
JSON-but-wrong-shape artifact 500'd every request. The loader now treats a malformed
artifact (missing report.blocks / provenance.freshness) as absent → 404. Test added.

1203 web + 71 etl tests pass; typecheck + prettier clean.
…umer

feat(weekly-digest): consumer render layer + /weeks routes (#167B)
…af-index+JSON-LD midt-bg#212, semgrep midt-bg#255, osv midt-bg#271); keep sharp pin + all security overrides
…o feat/weekly-digest

# Conflicts:
#	apps/etl/src/index.ts
#	apps/web/app/lib/assistant/describe-schema.ts
#	osv-scanner.toml
#	pnpm-lock.yaml
… workers-types dep

Resolves two review findings on PR #80:

- MINOR (review 5078506825): the base seed only paired value_flag='value_suspect'
  with a NULL amount_eur, so „summed in total, excluded from largest/top" held by
  construction. Add a suspect row carrying the week's HIGHEST non-NULL amount_eur and
  assert it lands in getWeeklyTotal (flag-independent basis) yet is filtered from
  getWeeklyLargestContract / getWeeklyTopContracts (value_flag='ok' guard).

- MEDIUM (strict review 5094463521): @sigma/report used R2Bucket/ExecutionContext
  ambient types without declaring @cloudflare/workers-types; add it as a devDependency
  so the package type-resolves in isolation.
Review of #80 flagged that the two shim modules used `export * from '@sigma/report'`,
which re-exported the ENTIRE barrel — making ./emit-report-schema and ./report-schema
identical aliases of the whole package and silently hiding any name collision in the
barrel. Replace the wildcards with explicit named re-exports that mirror exactly the
surface each real module (packages/report/src/{emit-report-schema,report-schema}.ts)
always exposed, keeping the module boundaries narrow.

Verified: apps/web typecheck clean under verbatimModuleSyntax (proves every re-exported
symbol resolves in the barrel — export parity — and all import sites still resolve);
@sigma/report 230 tests and @sigma/web 1213 tests pass; @sigma/report already declared
in apps/web dependencies; no dangling references to the removed r2-report-object fixture.
…udit

The CI "check" job (osv-scanner) failed: undici 7.28.0 is affected by 5 advisories
(GHSA-4cwx-7wf7-3272 HIGH + four Medium), all fixed in 7.29.0. Raise the undici floor.

This branch carries the security overrides in BOTH pnpm-workspace.yaml and a legacy
package.json `pnpm.overrides` block; the package.json block is the one pnpm actually
applies here (it appears in the lockfile's overrides), so bump it too — as `^7.29.0`
(bounded to the 7.x line, matching #87), not the open `>=7.29.0` selector which pulled
undici 8.x. pnpm-workspace.yaml is set to the same `^7.29.0` + comment, mirroring #87 so
the sibling branches stay in sync for the umbrella merge.

Verified: osv-scanner v2.4.0 `scan source -L pnpm-lock.yaml` → "No issues found" (exit 0);
lockfile diff is undici-only (7.28.0 → 7.29.0); frozen install clean; @sigma/etl 81,
@sigma/web 1213 tests pass.
…t passes

The sync pulled in upstream's coverage ratchet (check-coverage.mjs + coverage-baseline.json),
which measures apps/web against a 91% lines floor via v8 include: ['app/**']. The assistant's
data corpus under app/** (golden replay fixtures, R2 report fixtures — 34 .json files, ~169
lines) was counted at 0%, sinking apps/web to 89.17%. JSON has no executable lines; excluding
it restores the true source coverage (92.58%). Real untested source stays counted.
Resolve conflicts:
- packages/db/src/queries/index.ts: keep both weekly + related-persons exports
- apps/web/workers/app.integration.test.ts: keep both noindex describe blocks (weeks + conflicts)
- apps/web/app/lib/assistant/describe-schema.ts: keep the @sigma/report shim (#167A);
  carry base's annex_total_suspect value_flag additions into packages/report/src/describe-schema.ts
- osv-scanner.toml / pnpm-workspace.yaml: keep the fuller HEAD suppression docs
- package.json: drop the stale pnpm.overrides block so pnpm 10 reads overrides from
  pnpm-workspace.yaml (base's migration); regenerate pnpm-lock.yaml with pnpm 10.33.0
…o 0007

Resolves both blockers from the review of #80.

BLOCKER 1 (accuracy) — synthetic orphan contracts (parent tender procedure_type=
'неизвестна', title='(без предмет)', is_synthetic=1 per 0006) can carry a non-NULL
amount_eur with value_flag='ok', so the eight weekly.ts indicators were over-counting
sums, could surface a '(без предмет)' row as the week's largest, and could make the
volume count non-zero — bypassing the zero-row publish gate. Add `is_synthetic != 1`
to the shared WEEK_FILTER so all eight queries (and any future one) inherit the guard,
matching precompute.sql's sector/authority/company rollups. Correct the top-of-file and
getWeeklyCounts comments; rewrite the reconciliation comment — home_totals.value_eur is
SUM(amount_eur) over ALL contracts and does NOT itself filter is_synthetic, so the week
is a strict subset of it and 'week ≤ home' is a valid loose bound, not an equal basis
(the review's 'home_totals additionally filters is_synthetic' was inaccurate for that
rollup). Add a discriminating test: a synthetic value_flag='ok' row with the week's
highest amount is excluded from total/largest/top, and a synthetic-only week counts 0.

BLOCKER 2 (migration collision) — the base advanced to 0006, so 0004_weekly_digests.sql
collided with 0004_cpv_division_stats.sql. Renumber to 0007_weekly_digests.sql (git mv;
table name unchanged) and fix the ticket's stale reference.

Also updates the ticket's pipeline note to drop persistReport({immutable:true}), matching
the code (the object is overwritten in place on re-issue, so immutable would stale-serve).

Verified: @sigma/db 350 tests, @sigma/etl 81 tests pass; db+etl typecheck clean; prettier clean.
…ase merge

Merging the advanced base (feat/ai-assistant-contracts) into feat/weekly-digest
renumbered the migration chain — is_synthetic moved to 0012 and the base now reaches
0012 (0007 is taken by 0007_amendment_value_suspect). The weekly_digests migration
(previously renumbered 0004→0007 for the review of #80) collided again, so move it to
0013_weekly_digests.sql. Refresh the two now-stale references: weekly.ts's is_synthetic
provenance comment (0006 → 0012) and the ticket's migration path (0007 → 0013).

No duplicate migration prefixes remain. Verified post-merge: @sigma/db 521 tests,
@sigma/etl 89 tests pass; db typecheck clean; osv-scanner exit 0; check:docs ok.
CI 'Coverage ratchet' failed: @sigma/report (new in #167A T1) has a "test" script but
no entry in coverage-baseline.json, and no vitest.config, so it emitted no
coverage-summary.json and check-coverage.mjs fails closed on the missing baseline key.

Add packages/report/vitest.config.ts using the shared sharedCoverage(['src/**']) preset
(mirrors packages/shared) so it emits coverage/coverage-summary.json like every other
workspace, and add its baseline entry (lines 96.3, branches 86.9 — floored from the
measured 96.33/86.93). Verified: full 'pnpm test -- --coverage' + check-coverage.mjs
exits 0, report row 96.33%/86.93%, no workspace below baseline.
@DiyanaDimitrova DiyanaDimitrova changed the title feat(etl): weekly digest producer — @sigma/report extraction + Monday cron (#167A) feat(etl): седмичен дайджест „Седмицата в пари“ — производител + понеделнишки cron (#167A) Aug 19, 2026
@DiyanaDimitrova
DiyanaDimitrova marked this pull request as draft August 19, 2026 12:10
@DiyanaDimitrova DiyanaDimitrova changed the title feat(etl): седмичен дайджест „Седмицата в пари“ — производител + понеделнишки cron (#167A) [DO NOT MERGE] feat(etl): седмичен дайджест „Седмицата в пари“ — производител + понеделнишки cron (#167A) Aug 19, 2026
Drop docs/tickets/167a-weekly-digest-producer.md and 167b-weekly-digest-consumer.md
(the folder held only these two, so docs/tickets/ goes with them) and their two index
entries in docs/README.md. check:docs stays green — no dangling references remain.
Follow-up to 82ad5c3, which deleted the 167a/167b ticket files but (due to a staging
slip) landed without the docs/README.md edit that removes their two index links. Remove
them now so check:docs has no dangling references.
Drop the midt-bg#167 numeric prefix from the implementation-plan filename; update the single
in-repo reference (docs/README.md). git mv preserves history; check:docs stays green.
Remove .github/workflows/preview.yml and preview-reap.yml — the per-PR `sigma-pr-<n>`
preview-env deploy + reaper. They are fork-only infrastructure (gated to same-repo
branches, keyed to a `preview` GitHub Environment), absent from midt-bg:main and from
the #79 branch that targets it. Dropping them so the upstream PR (midt-bg#324) carries
the same shape as #79 instead of proposing the fork's preview setup to the main repo.
check:docs stays green (the dev-environment docs reference them as inline code, not
validated links).

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Дълбок преглед, с фокус върху CI/деплой границата. Кодът на дайджеста е грижлив; една конкретна находка — тестова/preview настройка, изтекла в комитнатия конфиг:

apps/etl/wrangler.toml:5workers_dev = true (на main е false). Конфигът го описва обратно на четири места:

  • wrangler.toml:7-9: „workers_dev = false above … unreachable without a route/workers_dev";
  • src/index.ts:34: DIGEST_TRIGGER_ENABLED „Committed 'false'" — а wrangler.toml:41 комитва "true";
  • src/index.ts:297: „in production this handler is unreachable";
  • src/digest-trigger.ts:3: целият security модел стъпва на „workers_dev=false, no route, so this surface is unreachable in [prod]".

scripts/wrangler-render.mjs не пипа workers_dev (0 срещания), deploy.yml също не — тъй че комитнатите workers_dev=true + DIGEST_TRIGGER_ENABLED="true" тръгват дословно към prod. Ефект: sigma-etl получава публичен *.workers.dev URL и on-demand trigger-ът е ENABLED; остава само DIGEST_TRIGGER_TOKEN (unset → 404). От трите слоя защита (недостижим + флаг off + токен) остава един, а четирите коментара заблуждават бъдещ оператор, че повърхността е недостижима.

Съгласно index.ts:297 („where a preview env opts in") opt-in-ът е замислен per-env, не като комитнат default. Предложение: върни workers_dev = false и DIGEST_TRIGGER_ENABLED = "false" в комита; preview средата да ги вдига през var/render.

Останалото по CI е чисто: ephemeral preview workflow-ите (preview.yml/reap/teardown) НЕ са внесени в upstream; wrangler-render коректно сменя ETL gateway account-а (ред 131-136, SIGMA_AI_GATEWAY_ACCOUNT); двата понеделнишки crons (0 6/0 7) се раутват отделно по event.cron. Дифът е надмножество на #79 — очаквано до сливането му.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

@DiyanaDimitrova — подсещане за находката от прегледа по-горе: apps/etl/wrangler.toml:5 е workers_dev = true (на main е false) плюс DIGEST_TRIGGER_ENABLED = "true". scripts/wrangler-render.mjs не подменя workers_dev, така че комитнатата стойност стига до прод — cron-only ETL worker-ът получава публично достъпен fetch() trigger, точно обратното на това, което описват коментарите на четирите места. Достатъчно е false в конфига + opt-in per-env. Останалото по дайджеста е чисто.

Review of midt-bg#324 caught a dev-testing config leaked into the committed wrangler.toml
(introduced by 5e760b0 'enable workers.dev route for dev digest-trigger testing'):

- workers_dev = true  → false  (main is false)
- DIGEST_TRIGGER_ENABLED = "true"  → "false"

Neither scripts/wrangler-render.mjs nor deploy.yml rewrites these, so the committed
literals went verbatim to prod: sigma-etl would get a public *.workers.dev URL AND the
on-demand HTTP trigger would be ENABLED, collapsing the three-layer defence (unreachable
+ flag off + bearer token) to just the DIGEST_TRIGGER_TOKEN secret. Both values also
contradicted four in-code comments that state the surface is committed-false / unreachable
(wrangler.toml:7-9, src/index.ts:34 & 297, src/digest-trigger.ts:3).

The trigger opt-in is per-env by design (index.ts:297) — a preview env raises these via
var/render, they are not a committed default. @sigma/etl 89 tests pass; toml valid.
@DiyanaDimitrova

Copy link
Copy Markdown
Contributor Author

Точна находка — оправено в 1aa6cbd. Върнати са committed fail-dark стойностите в apps/etl/wrangler.toml, за да съвпадат и с четирите коментара, и с main:

  • workers_dev = truefalse
  • DIGEST_TRIGGER_ENABLED = "true"false

И двете бяха изтекли от 5e760b0 („enable workers.dev route for dev digest-trigger testing"). Потвърдено, че нито wrangler-render.mjs, нито deploy.yml пипат тези ключове (0 срещания), значи committed литералите наистина тръгваха дословно към prod — точно както описа. Сега трите слоя защита (недостижим + флаг off + bearer token) са възстановени, а opt-in-ът остава per-env (preview средата ги вдига през var/render, не като committed default).

Проверено: @sigma/etl 89 теста минават; toml валиден; diff-ът е точно тези два реда.

Благодаря и за потвърждението по останалото (preview workflow-ите не са внесени upstream, ETL gateway account рендерът, раздалечените понеделнишки crons).

@DiyanaDimitrova

Copy link
Copy Markdown
Contributor Author

@lyubomir-bozhinov — оправено, същата находка от прегледа. В 1aa6cbd (вече на head-а на PR-а) и двете са върнати на committed fail-dark стойности:

  • apps/etl/wrangler.toml:5 workers_dev = truefalse
  • DIGEST_TRIGGER_ENABLED = "true"false

Потвърдено, че wrangler-render.mjs/deploy.yml не ги подменят (0 срещания), значи преди това стигаха дословно до прод. Сега съвпадат с main и с коментарите на четирите места; opt-in-ът остава per-env. @sigma/etl 89 теста минават. Виж и отговора под самия преглед.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Рейловете за данни/libel и amount_eur базата в weekly.ts са чисти (WEEK_FILTER навсякъде, euro-annex не важи — чете вече-деривирания amount_eur), digest-trigger е constant-time, noindex на weeks.$iso е на място, а workers_dev/DIGEST_TRIGGER_ENABLED вече са върнати на fail-dark в 1aa6cbd (потвърдено). Още две неща за преди да излезе от draft:

Major (заварено infra, но deploy.yml — който този PR пипа — е мястото) — SIGMA_TURNSTILE_SITE_KEY не се forward-ва. wrangler-render.mjs чете SIGMA_TURNSTILE_SITE_KEY (:98) и стъпва per-account ключа върху committed-ния (:245), но job env-ът в deploy.yml сетва само SIGMA_BUILD_ID/ASSISTANT_ENABLED/ENVIRONMENT/AI_GATEWAY_ACCOUNTSIGMA_TURNSTILE_SITE_KEY липсва (0 срещания). Значи стойността е винаги '', swap-ът е no-op, и committed-ният (прод) Turnstile site key тръгва навсякъде. В мига, в който dev/staging се включи в асистента (SIGMA_ASSISTANT_ENABLED=true), прод ключът (domain-bound) няма да валидира на dev домейна → bot-gate-ът (useTurnstileGate) тихо се чупи. Fix: добави SIGMA_TURNSTILE_SITE_KEY: ${{ vars.SIGMA_TURNSTILE_SITE_KEY }} до другите четири в deploy.yml.

Minor — REFRESH_CRON и PROMPTS_CRON се засичат в понеделник 06:00 UTC. REFRESH_CRON='0 */6 * * *' (00/06/12/18) и PROMPTS_CRON='0 6 * * 1' са отделни entry-та в [triggers].crons, тъй че Workers пуска scheduled() два пъти в понеделник 06:00: refresh-клонът вътрешно вика generateSuggestedPrompts, а самостоятелният prompts-клон — също, конкурентно срещу същото D1. Upsert-ите са per-slot ON CONFLICT DO UPDATE, значи не е корупция — само дублирано compute + last-write-wins на refreshed_at. Коментарът в crons.ts:6 разсъждава само за DIGEST vs PROMPTS, не за REFRESH overlap-а. Или го приеми (idempotent) и документирай, или изместй PROMPTS_CRON от границата (напр. 5 6 * * 1).

…e refresh boundary

Two findings from the review of midt-bg#324.

MAJOR — deploy.yml never forwarded SIGMA_TURNSTILE_SITE_KEY to the render step. wrangler-
render.mjs reads it (:98) and swaps the per-account key over the committed one (:246) only
when non-empty, but the job env set just SIGMA_BUILD_ID/ASSISTANT_ENABLED/ENVIRONMENT/
AI_GATEWAY_ACCOUNT — so the value was always '', the swap a no-op, and the committed prod
(domain-bound) Turnstile SITE key shipped to every environment. The moment a non-prod env
turns on the assistant (SIGMA_ASSISTANT_ENABLED=true), the prod key can't validate on that
domain and the bot-gate (useTurnstileGate) silently breaks. Add
SIGMA_TURNSTILE_SITE_KEY: ${{ vars.SIGMA_TURNSTILE_SITE_KEY }} alongside the other four
(same missing-var class as SIGMA_AI_GATEWAY_ACCOUNT). Verified via render dry-run: set →
dev key renders, unset → committed prod key stays.

MINOR — PROMPTS_CRON '0 6 * * 1' fired at exactly the Monday 06:00 REFRESH_CRON slot, so
scheduled() ran twice and generateSuggestedPrompts raced itself on the same D1 (idempotent
per-slot upserts, so wasted compute + last-write-wins refreshed_at, not corruption). Since
the 6-hourly refresh already regenerates prompts and PROMPTS_CRON is only the coarse weekly
fallback, move it to '5 6 * * 1' — runs just AFTER the Monday refresh (what a fallback
should do) and clears every refresh slot (:00). wrangler.toml [triggers].crons updated in
lockstep (cron-guard test enforces crons.ts == wrangler.toml).

@sigma/etl 89 tests pass (incl. cron-guard); deploy.yml valid; render swap verified.
@DiyanaDimitrova

Copy link
Copy Markdown
Contributor Author

И двете оправени в 68a18a1.

Major — SIGMA_TURNSTILE_SITE_KEY вече се forward-ва. Добавен е SIGMA_TURNSTILE_SITE_KEY: ${{ vars.SIGMA_TURNSTILE_SITE_KEY }} в job env-а на deploy.yml, до другите четири (същият клас пропусната променлива като SIGMA_AI_GATEWAY_ACCOUNT). Точно както описа — преди това wrangler-render.mjs:98 четеше винаги '', swap-ът на ред 246 беше no-op и committed-ният (domain-bound) прод ключ тръгваше навсякъде.

Проверено с render dry-run:

  • със зададена променлива → рендерира се dev ключът;
  • без нея (прод) → committed-ният ключ остава.

Minor — PROMPTS_CRON изместен от границата. '0 6 * * 1''5 6 * * 1'. Тъй като 6-часовият refresh така или иначе регенерира промптовете на всеки run, а PROMPTS_CRON е само грубият седмичен fallback, 06:05 го пуска точно след понеделнишкия refresh (каквото fallback-ът и трябва да прави), вместо конкурентно с него, и е чист от всеки refresh slot (:00). [triggers].crons в wrangler.toml е сменен в синхрон — cron-guard тестът налага crons.ts == wrangler.toml. Обосновката е записана в коментара на crons.ts.

Проверено: @sigma/etl 89 теста минават (вкл. cron-guard); deploy.yml валиден YAML.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants