Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3414553
fix(assistant): correct data-dictionary drift vs the migration
nedda76 Jul 9, 2026
232a90f
fix(assistant): keep hard data-traps under RAG and floor low-relevanc…
nedda76 Jul 9, 2026
28ad757
fix(assistant): block string-building aggregates in the SQL scalar guard
nedda76 Jul 9, 2026
490b8e0
fix(assistant): harden report emission integrity
nedda76 Jul 9, 2026
d162ba1
refactor(assistant): single-source the data-trap rendering across bot…
nedda76 Jul 9, 2026
8036c95
fix(assistant): treat a scoreless RAG match as below the relevance floor
nedda76 Jul 11, 2026
f2981ba
fix(assistant): match spelled magnitudes by -илион/-илиард suffix (co…
nedda76 Jul 11, 2026
767a3f0
fix(assistant): block string_agg (SQLite ≥3.44 group_concat alias) in…
nedda76 Jul 11, 2026
e21a490
fix(assistant): short-circuit validateEmitShape on over-cap arrays
nedda76 Jul 22, 2026
86a5cc9
ci(deps): document accepted osv-scanner exception for sharp libvips CVEs
nedda76 Jul 22, 2026
a66100e
fix(assistant): stop double-rendering data traps between hardTraps an…
nedda76 Aug 18, 2026
073fab6
ci(deps): unify ignoreUntil timestamp format in osv-scanner.toml
nedda76 Aug 18, 2026
cb97988
fix(assistant): version the schema corpus via native Vectorize namesp…
nedda76 Aug 18, 2026
797cbd6
test(assistant): close the sweep gaps around the corpus-version guard
nedda76 Aug 18, 2026
68b2be8
docs(assistant): уточни бележката за near-collisions при суфиксите на…
nedda76 Aug 19, 2026
b494d81
fix(assistant): флагвай и абревиатурите млрд/млн като стемове в prose…
nedda76 Aug 19, 2026
d344a8a
fix(assistant): затвори quoted-identifier bypass-а на функционалния d…
nedda76 Aug 19, 2026
2a633ae
fix(assistant): премести semantic_search на native Vectorize namespac…
nedda76 Aug 18, 2026
21e14dd
fix(assistant): закали entity namespace прехода по бележките от ревюто
nedda76 Aug 18, 2026
7872393
fix(assistant): релевантен флор за semantic_search — симетричен на сх…
nedda76 Aug 19, 2026
ffa2d29
fix(assistant): scoreless match отпада при всеки флор + README за pre…
nedda76 Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions apps/web/app/lib/assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
| `agent.ts` | Vercel AI SDK glue: BgGPT през AI Gateway + `streamText` | §2/§9.5 | typecheck |
| `routes/assistant.chat.tsx` | Stateless chat ресурс route | §2/§5 | typecheck |

**Проверено:** `pnpm --filter web typecheck` → 0; **150 теста** преминават; `pnpm audit --audit-level=high`
**Проверено:** `pnpm --filter web typecheck` → 0; целият тестов пакет на `apps/web` преминава (бройката
расте с всяко ревю — не я кодираме тук, `pnpm --filter web test` я показва); `pnpm audit --audit-level=high`
чист; Prettier чист. Чистите модули са unit-тествани и deploy-независими; agent loop-ът и route-ът са
typecheck-проверени, но **не са runtime-проверени** (няма `BGGPT_API_KEY` / облачни bindings в тази среда).

Expand All @@ -41,8 +42,9 @@ typecheck-проверени, но **не са runtime-проверени** (н
## RAG — добавка спрямо спецификацията

Спецификацията е **text→SQL агент с инструменти, БЕЗ векторно извличане.** RAG е добавен нарочно на двете
места с най-голяма полза при слаб 27B: (1) **grounding на схемата** — извлича най-релевантните trap-правила
и примерни заявки за конкретния въпрос в системния prompt (retrieval-augmented формата на §9.2); (2)
места с най-голяма полза при слаб 27B: (1) **grounding на схемата** — trap-правилата влизат в системния
prompt безусловно (`hardTraps()`), а RAG извлича най-релевантните таблици и примерни заявки за конкретния
въпрос (retrieval-augmented формата на §9.2; trap-овете не се индексират, за да не се дублират); (2)
**`semantic_search`** — допълва FTS за парафрази/синоними. Пада обратно до статичния `describeSchema()`,
ако се реши, че RAG е извън v1.

Expand All @@ -51,17 +53,31 @@ typecheck-проверени, но **не са runtime-проверени** (н
Това PR добавя bindings към Cloudflare ресурси, които трябва да **съществуват преди deploy** — иначе
`wrangler deploy` се проваля и блокира CD за целия екип (бележка от ревюто на #80). Преди мърдж/deploy на
средата с асистента осигурете: `BGGPT_API_KEY` (secret, `wrangler secret put`), Vectorize индекс
`sigma-assistant`, R2 кофа `sigma-reports`, и еднократно индексиране на схема-корпуса (`indexSchemaCorpus`).
`sigma-assistant`, R2 кофа `sigma-reports`, и индексиране на схема-корпуса (`indexSchemaCorpus`).

```bash
# Веднъж на средата, ПРЕДИ `wrangler deploy` (иначе deploy-ът пада и блокира CD на целия екип):
wrangler vectorize create sigma-assistant --dimensions=1024 --metric=cosine # ТРЯБВА 1024/cosine (bge-m3) — грешни размери чупят RAG
wrangler r2 bucket create sigma-reports
wrangler secret put BGGPT_API_KEY # интерактивно; никога не се комитва
# `AI` (Workers AI) не изисква създаване на ресурс — account capability; включи Workers AI за акаунта.
# След като индексът съществува, еднократно: indexSchemaCorpus(env.AI, env.VECTORIZE) пълни схема-корпуса.
# След като индексът съществува: indexSchemaCorpus(env.AI, env.VECTORIZE) пълни схема-корпуса.
```

**Ре-индексиране:** схема-корпусът е версиониран през `SCHEMA_NS` (`rag.ts`) — namespace-ът И id-тата
на векторите носят версията. Версията се bump-ва при всяка промяна, която маха, размества или
пре-осмисля chunk id-та (виж правилото „WHEN TO BUMP" в `rag.ts`; чисто добавяне или редакция на
текста на съществуващ chunk минава без bump). След bump `indexSchemaCorpus` се пуска отново: пише се
НОВ кохорт вектори, старият остава непокътнат (rollback на Worker-а продължава да работи срещу него),
а среда без ре-индекс просто връща 0 чънка и асистентът пада към пълния статичен речник (безопасно,
но без RAG grounding). Стар кохорт се чисти чак когато rollback прозорецът към неговия release е
затворен — изтриеш ли го по-рано, rollback-ът остава без RAG. Чисти се с
`wrangler vectorize delete-vectors` (иска изричен списък id-та — възстанови ги от git историята на
`buildSchemaChunks`); не е задължително, retrieval-ът игнорира старите кохорти чрез namespace-а.
NB за първите среди: „стар кохорт" включва и ОРИГИНАЛНИЯ pre-namespace кохорт (id-та `schema:query:N`
/ `schema:table:<име>` / `schema:trap:N`, записани в DEFAULT namespace-а с metadata `ns` преди
версионирането) — той също е orphan след прехода и също се чисти по желание, по същия начин.

Докато бекендът не е напълно осигурен, `/assistant/chat` връща контролирано **503**, а грешка по време на
streaming се показва като четим текст — не като счупена връзка или 500 (graceful degradation, §7).

Expand Down Expand Up @@ -98,9 +114,14 @@ embed cap + проверка за брой, без raw D1 грешка към м
- **Фаза 2 — устойчивост:** глобален budget + circuit-breaker / exponential backoff пред BgGPT
(per-IP rate-limit и graceful degradation вече са налице — остава глобалният таван).
- **Фаза 3:** глас (`/assistant/transcribe` → Whisper).
- **`semantic_search` — `ns: 'entity'` е празен** докато не се добави entity indexer (ETL pipeline,
Фаза 2). Инструментът е регистриран и работи, но ще връща 0 попадения за всяко запитване, докато
pipeline-ът не напълни Vectorize с имена на компании/договори/възложители.
- **`semantic_search` — namespace-ът `entity-v1` е празен** докато не се добави entity indexer (ETL
pipeline, Фаза 2). Инструментът е регистриран и работи, но ще връща 0 попадения за всяко запитване,
докато pipeline-ът не напълни Vectorize. Indexer-ът трябва да upsert-ва с `namespace: ENTITY_NS`.
Внимание: правилото „WHEN TO BUMP" от `rag.ts` е за ръчния, append-only схема-корпус и НЕ се
пренася едно към едно — entity корпусът е производен от данните (субекти реално изчезват при
дедуп/карантина), затова indexer-ът трябва да пази списъка на id-тата си и да има собствен
reconciliation/delete път (`wrangler vectorize delete-vectors` иска изричен списък id-та;
entity id-та няма как да се възстановят от git историята).
- **`eop_fetch` връща само БРОЙ редове на ден, не самите данни** (днес): инструментът сваля, капва и
парсва файла, но връща „N реда" и не пуска `QueryResult` в `ctx.results`, така че моделът НЕ може да
обвърже EOP стойност в `emit_report`. Засега е probe за наличие/свежест, не източник на данни (ревю #80).
Expand Down
32 changes: 25 additions & 7 deletions apps/web/app/lib/assistant/describe-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const DATA_TRAPS: string[] = [
'`value_flag`: включи `ok`, `review`, `annex_suspect`, `annex_total_suspect`, `value_low` и ' +
'поправените `value_suspect` редове.',
'`amount_eur IS NULL` означава, че няма използваема EUR стойност (например `value_suspect` без ' +
'прогноза за поправка или чужда валута без FX курс); само тези редове се изключват от парични суми.',
'прогноза за поправка, чужда валута без FX курс, или липсва подписана/текуща стойност); само тези ' +
'редове се изключват от парични суми. `amount_eur IS NULL` НЕ Е синоним на `value_suspect`.',
"Брой „непотвърдени\" = редове с `value_flag = 'value_suspect'` (НЕ редове с NULL `amount_eur`; " +
'готовото число е `home_totals.suspect`).',
'`value_flag` ∈ {ok, review, annex_suspect, annex_total_suspect, value_suspect, value_low} мени ' +
'значението на стойността на реда, но не и каноничната база; `date_flag` ∈ {ok, ' +
'signed_after_publication} е вердикт за датата.',
Expand Down Expand Up @@ -64,11 +67,21 @@ export const TABLES: TableDoc[] = [
grain: 'един възложен договор (на ниво лот)',
columns:
'id, tender_id→tenders, bidder_id→bidders, amount (display, в `currency`), currency, ' +
'amount_eur (КАНОНИЧЕН EUR, SAFE TO SUM; сумирай с amount_eur IS NOT NULL), value_flag, date_flag, ' +
'amount_eur (КАНОНИЧЕН EUR, SAFE TO SUM; сумирай с amount_eur IS NOT NULL — NULL=няма надеждна EUR стойност), value_flag, date_flag, ' +
'fx_converted, fx_rate, signed_at, bids_received, eu_funded',
},
{ name: 'amendments', grain: 'един анекс', columns: 'id, contract_id→contracts, …' },
{ name: 'parties', grain: 'роля по OCDS преписка', columns: 'ocid (≠ УНП!), role, …' },
{
name: 'amendments',
grain: 'един анекс',
columns:
'id, natural_key, unp (=УНП, свързва tenders/contracts), contract_number, ' +
'value_before, value_after, value_delta, currency, published_at',
},
{
name: 'parties',
grain: 'една страна по OCDS преписка',
columns: 'party_key, eik, ocid (≠ УНП!), party_id, name, region_nuts',
},
{
name: 'authority_totals',
grain: 'rollup на възложител',
Expand Down Expand Up @@ -109,7 +122,7 @@ export const TABLES: TableDoc[] = [
},
{
name: 'data_freshness',
grain: 'view — свежест/обхват',
grain: 'таблица — свежест/обхват',
columns: 'source, as_of, refreshed_at',
},
];
Expand Down Expand Up @@ -160,14 +173,19 @@ export const CANONICAL_QUERIES: { intent: string; sql: string }[] = [
},
];

// Render DATA_TRAPS as a numbered list. Shared by describeSchema (full dictionary) and the RAG
// hard-traps block (system-prompt.ts) so both paths render the traps identically and cannot drift.
export function renderTraps(): string {
return DATA_TRAPS.map((t, i) => `${i + 1}. ${t}`).join('\n');
}

/** Build the schema prompt asset the agent reads before writing SQL (returned by the tool). */
export function describeSchema(): string {
const traps = DATA_TRAPS.map((t, i) => `${i + 1}. ${t}`).join('\n');
const tables = TABLES.map((t) => `- ${t.name} — grain: ${t.grain}\n ${t.columns}`).join('\n');
const queries = CANONICAL_QUERIES.map((q) => `-- ${q.intent}\n${q.sql}`).join('\n\n');
return [
'# Речник на данните (чети преди да пишеш SQL)',
'\n## Задължителни правила (капани в данните)\n' + traps,
'\n## Задължителни правила за данните (капани — важат за всеки въпрос)\n' + renderTraps(),
'\n## Таблици\n' + tables,
'\n## Канонични примерни заявки\n' + queries,
].join('\n');
Expand Down
85 changes: 85 additions & 0 deletions apps/web/app/lib/assistant/emit-report-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,91 @@ describe('validateEmitShape', () => {
expect(validateEmitShape(tbl({ kind: 'company' })).ok).toBe(false); // idCol required
});

it('validates the optional column align (whitelist left|right), rejecting anything else', () => {
const tbl = (align: unknown) => ({
title: 't',
question: '',
blocks: [
{
type: 'table',
resultId: 'R1',
columns: [{ key: 'name', header: 'Име', align, format: 'text' }],
},
],
});
expect(validateEmitShape(tbl('right')).ok).toBe(true);
expect(validateEmitShape(tbl(undefined)).ok).toBe(true);
expect(validateEmitShape(tbl('center')).ok).toBe(false);
expect(validateEmitShape(tbl('"><b>')).ok).toBe(false);
});

it('caps oversized model arrays (blocks, items, columns)', () => {
const many = (n: number, make: (i: number) => unknown) =>
Array.from({ length: n }, (_, i) => make(i));
// too many blocks
expect(
validateEmitShape({
title: 't',
question: '',
blocks: many(101, () => ({ type: 'text', md: 'x' })),
}).ok,
).toBe(false);
// too many totals items
expect(
validateEmitShape({
title: 't',
question: '',
blocks: [
{
type: 'totals',
items: many(51, () => ({
label: 'x',
ref: { resultId: 'R1', row: 0, col: 'c' },
format: 'money',
})),
},
],
}).ok,
).toBe(false);
// too many columns
expect(
validateEmitShape({
title: 't',
question: '',
blocks: [
{
type: 'table',
resultId: 'R1',
columns: many(51, (i) => ({ key: `k${i}`, header: 'h', format: 'text' })),
},
],
}).ok,
).toBe(false);
});

it('stops at the cap error instead of scanning the oversized array (review follow-up)', () => {
// Every over-cap block is ALSO individually invalid ({} has no type). Pre-fix, the per-block loop
// still ran and pushed 101 per-block errors; now the cap short-circuits, so exactly the one cap error
// is reported and the oversized structure is never walked.
const out = validateEmitShape({
title: 't',
question: '',
blocks: Array.from({ length: 101 }, () => ({})),
});
expect(out.ok).toBe(false);
if (!out.ok) expect(out.errors).toEqual(['blocks: at most 100']);

// Same for an over-cap items array: the per-item scan is skipped, so only the cap error surfaces
// (each item here is also invalid — missing label/ref/format — but none of them get walked).
const items = validateEmitShape({
title: 't',
question: '',
blocks: [{ type: 'totals', items: Array.from({ length: 51 }, () => ({})) }],
});
expect(items.ok).toBe(false);
if (!items.ok) expect(items.errors.some((e) => /items\[\d+\]/.test(e))).toBe(false);
});

it('rejects a non-integer ref row (review #80)', () => {
const out = validateEmitShape({
title: 't',
Expand Down
Loading