Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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
23 changes: 18 additions & 5 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,28 @@ 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-а.

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

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
36 changes: 32 additions & 4 deletions apps/web/app/lib/assistant/emit-report-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const BLOCK_TYPES = new Set([

const ENTITY_KINDS = new Set(['company', 'authority', 'contract']);

// Upper bounds on model-emitted array sizes. bindReport sanitises/scans every block, item and column,
// and result rows are byte-capped upstream — but nothing bounded the array LENGTHS, so a very long (or
// non-LLM) emission would scan an unbounded structure. These ceilings are far above any real report
// (review follow-up).
const MAX_BLOCKS = 100;
const MAX_ITEMS = 50;
const MAX_COLUMNS = 50;

const isStr = (v: unknown): v is string => typeof v === 'string';
const isNonEmptyStr = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0;
// row indices are 0-based, non-negative INTEGERS. A non-integer (1.5) slips bindReport's `row < length`
Expand All @@ -31,6 +39,10 @@ const isIndex = (v: unknown): v is number => typeof v === 'number' && Number.isI
const isObj = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v);
const isFormat = (v: unknown): v is CellFormat => isStr(v) && FORMATS.has(v as CellFormat);
// A table column's optional horizontal alignment. Whitelisted here so an out-of-enum value the type
// claims impossible ('left'|'right') cannot reach a renderer that interpolates it into an attribute
// or style (review follow-up).
Comment thread
nedda76 marked this conversation as resolved.
const isAlign = (v: unknown): boolean => v === undefined || v === 'left' || v === 'right';
// A table column's optional entity link. `kind` must be a known EntityKind (it reaches entityHref,
// where an unknown kind silently builds a wrong-entity `/contracts/…` citation — review #80).
const isLink = (v: unknown): boolean =>
Expand All @@ -53,6 +65,13 @@ export function validateEmitShape(input: unknown): ShapeResult {
errors.push('blocks must be an array');
return { ok: false, errors };
}
// Return before the per-block scan: an over-cap array is exactly the unbounded structure the ceiling
// guards against, so validating it any further would do the scanning we mean to refuse (as for the
// `!Array.isArray` guard above — review follow-up).
if (input.blocks.length > MAX_BLOCKS) {
errors.push(`blocks: at most ${MAX_BLOCKS}`);
return { ok: false, errors };
}

input.blocks.forEach((b, i) => {
const at = `block[${i}]`;
Expand All @@ -73,7 +92,10 @@ export function validateEmitShape(input: unknown): ShapeResult {
break;
case 'totals':
need(Array.isArray(b.items), 'items must be an array');
if (Array.isArray(b.items))
need(!Array.isArray(b.items) || b.items.length <= MAX_ITEMS, `at most ${MAX_ITEMS} items`);
// Skip the per-item scan when over-cap — the length error is already recorded and scanning the
// oversized array is the work the ceiling exists to refuse (review follow-up).
if (Array.isArray(b.items) && b.items.length <= MAX_ITEMS)
b.items.forEach((it, j) =>
need(
isObj(it) && isStr(it.label) && isCellRef(it.ref) && isFormat(it.format),
Expand All @@ -83,23 +105,29 @@ export function validateEmitShape(input: unknown): ShapeResult {
break;
case 'facts':
need(Array.isArray(b.items), 'items must be an array');
if (Array.isArray(b.items))
need(!Array.isArray(b.items) || b.items.length <= MAX_ITEMS, `at most ${MAX_ITEMS} items`);
if (Array.isArray(b.items) && b.items.length <= MAX_ITEMS)
b.items.forEach((it, j) =>
need(isObj(it) && isStr(it.term) && isCellRef(it.ref), `items[${j}] needs {term, ref}`),
);
break;
case 'table':
need(isNonEmptyStr(b.resultId), 'resultId required');
need(Array.isArray(b.columns) && b.columns.length > 0, 'columns must be a non-empty array');
if (Array.isArray(b.columns))
need(
!Array.isArray(b.columns) || b.columns.length <= MAX_COLUMNS,
`at most ${MAX_COLUMNS} columns`,
);
if (Array.isArray(b.columns) && b.columns.length <= MAX_COLUMNS)
b.columns.forEach((c, j) =>
need(
isObj(c) &&
isNonEmptyStr(c.key) &&
isStr(c.header) &&
isAlign(c.align) &&
isFormat(c.format) &&
isLink(c.link),
`columns[${j}] needs {key, header, format, link?:{kind:company|authority|contract, idCol}}`,
`columns[${j}] needs {key, header, align?:left|right, format, link?:{kind:company|authority|contract, idCol}}`,
),
);
break;
Expand Down
Loading