Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
f8b3d07
fix(assistant): типизирай AI/Vectorize биндингите — без 'as unknown a…
nedda76 Aug 18, 2026
674f173
fix(assistant): закали типизираните биндинги по бележките от ревюто
nedda76 Aug 18, 2026
1e05ef9
fix(assistant): адаптерът отхвърля празен data масив вместо да го чет…
nedda76 Aug 19, 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
39 changes: 31 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,33 @@ 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(embeddingRunnerFor(env.AI), env.VECTORIZE)
# пълни схема-корпуса (embeddingRunnerFor е от lib/assistant/bindings.ts — env.AI не е директно
# EmbeddingRunner и каст с `as unknown as` е точно това, което #316 премахна).
```

**Ре-индексиране:** схема-корпусът е версиониран през `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 +116,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
46 changes: 46 additions & 0 deletions apps/web/app/lib/assistant/bindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { embeddingRunnerFor } from './bindings';
import { EMBED_MODEL } from './rag';

// The adapter is the ONLY hand-written logic between the Worker's Ai binding and embed(); a fake
// binding pins its behaviour (a blind cast had none to pin — review note on #316). The stub is
// cast because tests fake the boundary; production code never casts (that is the point of #316).
function fakeBinding(out: Record<string, unknown>) {
const run = vi.fn(async () => out);
return { ai: { run } as unknown as Ai, run };
}

describe('embeddingRunnerFor', () => {
it('forwards the model literal and the texts into the real binding call', async () => {
const { ai, run } = fakeBinding({ data: [[0.1], [0.2]] });
const out = await embeddingRunnerFor(ai).run(EMBED_MODEL, { text: ['а', 'б'] });
expect(out).toEqual({ data: [[0.1], [0.2]] });
expect(run).toHaveBeenCalledWith(EMBED_MODEL, { text: ['а', 'б'] });
});

it('throws a named, keys-only error on a non-embedding response shape', async () => {
// bge-m3 can answer with query-scoring or async envelopes; the adapter must not silently
// return [] (that reads as "provider embedded nothing") and must not log payload content.
const { ai } = fakeBinding({ response: [{ id: 0, score: 0.5 }] });
await expect(embeddingRunnerFor(ai).run(EMBED_MODEL, { text: ['а'] })).rejects.toThrow(
/неочаквана форма.*ключове: response/,
);
});

it('throws with "няма" when the response has no keys at all', async () => {
const { ai } = fakeBinding({});
await expect(embeddingRunnerFor(ai).run(EMBED_MODEL, { text: ['а'] })).rejects.toThrow(
/ключове: няма/,
);
});

it('rejects an EMPTY data array for a non-empty input instead of reading [] as success', async () => {
// `[]` is truthy: a presence-only check would return { data: [] } and embed()'s count error
// would then blame "0 embeddings" instead of the real cause — a provider answering with an
// empty batch. The adapter names that case explicitly (review f/u, ydimitrof).
const { ai } = fakeBinding({ data: [] });
await expect(embeddingRunnerFor(ai).run(EMBED_MODEL, { text: ['а'] })).rejects.toThrow(
/празен data масив/,
);
});
});
38 changes: 38 additions & 0 deletions apps/web/app/lib/assistant/bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Boundary adapters between the Worker's generated binding types (worker-configuration.d.ts) and
// the assistant's narrowed structural types (rag.ts). This is the ONE module allowed to know both
// sides — everything else depends on the structural types only (issue #316).
//
// VECTORIZE needs no adapter: VectorizeIndex is structurally assignable to VectorIndex, and the
// route's plain assignment is the compile-time proof. Only AI needs bridging, because Ai.run() is
// typed per-model (generic overloads) and returns an output UNION that cannot satisfy
// EmbeddingRunner directly.

import { EMBED_MODEL, type EmbeddingRunner } from './rag';

/**
* Wrap the Workers AI binding as the assistant's EmbeddingRunner. The call goes through the real
* `@cf/baai/bge-m3` overload (the `model` parameter is typed as that literal end-to-end), so the
* request shape stays compiler-checked — no `as unknown as`, ever.
*/
export function embeddingRunnerFor(ai: Ai): EmbeddingRunner {
return {
run: async (model, inputs) => {
const out = await ai.run(model, { text: inputs.text });
// `data.length > 0` too, not just presence: an empty `data: []` is truthy and would read as
// "success" here for a NON-empty input (embed() never calls the adapter with empty texts) —
// the inverse failure of the missing-key case, named separately for the operator (review
// f/u, ydimitrof). embed()'s count check would still throw, but with a message that blames
// "0 embeddings" instead of the real cause: a provider that answered with an empty batch.
if ('data' in out && Array.isArray(out.data) && out.data.length > 0) {
return { data: out.data };
}
// Preserve the diagnostic a blind cast used to lose: name the unexpected shape. KEYS ONLY —
// an error envelope could echo the embedded input, and user text must not land in logs.
const shape =
'data' in out && Array.isArray(out.data)
? 'празен data масив за непразен вход'
: `ключове: ${Object.keys(out).join(', ') || 'няма'}`;
throw new Error(`embeddings: неочаквана форма на отговора от ${EMBED_MODEL} (${shape})`);
},
};
}
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
Loading