Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c3f1ad4
fix(assistant): correct data-dictionary drift vs the migration
nedda76 Jul 9, 2026
4591efa
fix(assistant): keep hard data-traps under RAG and floor low-relevanc…
nedda76 Jul 9, 2026
a3ad3a5
fix(assistant): block string-building aggregates in the SQL scalar guard
nedda76 Jul 9, 2026
10c1a89
fix(assistant): harden report emission integrity
nedda76 Jul 9, 2026
e133eef
refactor(assistant): single-source the data-trap rendering across bot…
nedda76 Jul 9, 2026
05d093e
fix(assistant): treat a scoreless RAG match as below the relevance floor
nedda76 Jul 11, 2026
a5173da
fix(assistant): match spelled magnitudes by -илион/-илиард suffix (co…
nedda76 Jul 11, 2026
d9df899
fix(assistant): block string_agg (SQLite ≥3.44 group_concat alias) in…
nedda76 Jul 11, 2026
ca4a663
fix(assistant): short-circuit validateEmitShape on over-cap arrays
nedda76 Jul 22, 2026
1307ca5
fix(assistant): stop double-rendering data traps between hardTraps an…
nedda76 Aug 18, 2026
4d102e8
fix(assistant): version the schema corpus via native Vectorize namesp…
nedda76 Aug 18, 2026
ae0b55f
test(assistant): close the sweep gaps around the corpus-version guard
nedda76 Aug 18, 2026
24a9308
docs(assistant): уточни бележката за near-collisions при суфиксите на…
nedda76 Aug 19, 2026
790726f
fix(assistant): флагвай и абревиатурите млрд/млн като стемове в prose…
nedda76 Aug 19, 2026
47f5962
fix(assistant): затвори quoted-identifier bypass-а на функционалния d…
nedda76 Aug 19, 2026
e9a0fef
fix(assistant): jsonb_group_* влиза в денилиста — JSONB близнаците ми…
nedda76 Sep 2, 2026
57bd732
fix(assistant): кавичките на идентификаторите са непрозрачни, а денил…
nedda76 Sep 2, 2026
477cbce
test(assistant): закови fail-closed пътищата на guard-а — незатворен …
nedda76 Sep 2, 2026
249ff5f
fix(assistant): лексикалните проверки да не четат съдържанието на стр…
nedda76 Sep 5, 2026
5ef3f95
fix(assistant): премести semantic_search на native Vectorize namespac…
nedda76 Aug 18, 2026
299bee7
fix(assistant): закали entity namespace прехода по бележките от ревюто
nedda76 Aug 18, 2026
6e9368e
fix(assistant): релевантен флор за semantic_search — симетричен на сх…
nedda76 Aug 19, 2026
13bd2f5
fix(assistant): scoreless match отпада при всеки флор + README за pre…
nedda76 Aug 20, 2026
0e251e6
fix(assistant): изравни и схема флора на Number.isFinite (безопасност…
nedda76 Sep 1, 2026
85a0999
fix(assistant): типизирай AI/Vectorize биндингите — без 'as unknown a…
nedda76 Aug 18, 2026
8e5bffa
fix(assistant): закали типизираните биндинги по бележките от ревюто
nedda76 Aug 18, 2026
7e1b662
fix(assistant): адаптерът отхвърля празен data масив вместо да го чет…
nedda76 Aug 19, 2026
541a76a
docs(assistant): embed() назовава контракта на адаптера за празен вход
nedda76 Sep 2, 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
73 changes: 48 additions & 25 deletions apps/web/app/lib/assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,25 @@

## Какво има (имплементирано)

| Файл | Роля | Спец. | Проверка |
| --------------------------- | -------------------------------------------------------------- | ------------ | --------- |
| `report-schema.ts` | Block речник + **сървърно обвързване на стойностите** | §4, §9.1, §7 | unit |
| `sql-guard.ts` | Read-only структурен guard + LIMIT + byte cap | §7, §9.4 | unit |
| `sql-ast-guard.ts` | AST guard: read-only + table allowlist + no-cross-join + LIMIT | §9.4 | unit |
| `describe-schema.ts` | Куриран речник на данните с капаните | §9.2 | unit |
| `rag.ts` | Vectorize + Workers AI RAG (grounding + semantic search) | _добавка_ | unit |
| `system-prompt.ts` | emit-report политика, values-by-reference, data-trust, скелет | §4/§7/§9.10 | unit |
| `tool-results.ts` | D1 редове → хендълнат `QueryResult` | §7 | unit |
| `eop-fetch.ts` | `eop_fetch` — валидация + fixed base (no SSRF) + cap | §9.7 | unit |
| `source-link.ts` | Официални линкове (ЦАИС ЕОП) за цитиране | §3 | unit |
| `emit-report-schema.ts` | Структурна валидация + model-facing JSON Schema | §4 | unit |
| `render-format.ts` | format-by-hint + entity-ref линкове | §4 | unit |
| `tools.ts` | Tool registry (SDK-агностичен) + `finalizeReport` | §2/§3 | unit |
| `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`
| Файл | Роля | Спец. | Проверка |
| --------------------------- | ---------------------------------------------------------------------------------- | ------------ | --------- |
| `report-schema.ts` | Block речник + **сървърно обвързване на стойностите** | §4, §9.1, §7 | unit |
| `sql-guard.ts` | Read-only структурен guard + LIMIT + byte cap | §7, §9.4 | unit |
| `sql-ast-guard.ts` | AST guard: read-only + table allowlist + no-cross-join + function denylist + LIMIT | §9.4 | unit |
| `describe-schema.ts` | Куриран речник на данните с капаните | §9.2 | unit |
| `rag.ts` | Vectorize + Workers AI RAG (grounding + semantic search) | _добавка_ | unit |
| `system-prompt.ts` | emit-report политика, values-by-reference, data-trust, скелет | §4/§7/§9.10 | unit |
| `tool-results.ts` | D1 редове → хендълнат `QueryResult` | §7 | unit |
| `eop-fetch.ts` | `eop_fetch` — валидация + fixed base (no SSRF) + cap | §9.7 | unit |
| `source-link.ts` | Официални линкове (ЦАИС ЕОП) за цитиране | §3 | unit |
| `emit-report-schema.ts` | Структурна валидация + model-facing JSON Schema | §4 | unit |
| `render-format.ts` | format-by-hint + entity-ref линкове | §4 | unit |
| `tools.ts` | Tool registry (SDK-агностичен) + `finalizeReport` | §2/§3 | unit |
| `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; целият тестов пакет на `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})`);
},
};
}
Loading