Skip to content

feat(assistant): наблюдаем RAG fallback — статистика на retrieval-а и логнати деградации - #321

Open
nedda76 wants to merge 45 commits into
midt-bg:mainfrom
nedda76:fix/assistant-rag-observability
Open

nedda76 wants to merge 45 commits into
midt-bg:mainfrom
nedda76:fix/assistant-rag-observability

Conversation

@nedda76

@nedda76 nedda76 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Част от #318 — наблюдаемостта, върху която стъпва предстоящото премерване на флора. Самата рекалибрация на MIN_SCHEMA_SCORE/topK остава за втори PR, след като логовете съберат реални данни (issue-то остава отворено).

Какво

  • retrieveSchemaContext подава RetrievalStats през опционален onStats hook — три брояча, за да са различими двата класа повреди с противоположни поправки:
    • matched — сурови namespace-скоупнати съвпадения (0 = празен/неиндексиран namespace или embed без вектор);
    • aboveFloor — над релевантния флор (matched > aboveFloor = флорът реже);
    • kept — реално стигнали до prompt-а (aboveFloor > kept = счупен metadata.text контракт, бъг в индексирането, НЕ флор).
  • Route-ът логва статистиката като структуриран JSON (дисциплината на workers/request-log.ts — агрегируеми полета, само броячи, никога текста на въпроса) и вече не поглъща тихо грешката при retrieval (console.error само с message — суров error обект може да ехне въпроса).
  • Позиционните topK/minScore станаха opts обект (RetrieveOptions).
  • kept=0 при matched>0 е точно тихият fallback към пълния речник, който досега беше неразличим от работещ RAG.

Гаранции (от ревюто)

  • onStats се вика в try/catch — хвърлящ metrics sink не може да струва на хода неговите чънкове (инвариантът на request-log.ts); закован с тест.
  • И третият деградационен път (!vec — embed без използваем вектор) докладва, с нули — иначе е неразличим от „статистиката не е вързана"; тест.
  • Тестът за броячите деривира скоровете от MIN_SCHEMA_SCORE ± ε (рекалибрацията няма да го прекатури) и затвърждава и върнатите чънкове, не само числата.

Стак

Стъпва върху #320 (→ #319#223). За ревю са последните 2 комита (feat + review fixes). Ред на мърдж: #223#319#320 → този PR.

Проверено

tsc -b чист, пълният пакет на apps/web: 510/510 теста, покритие над baseline (91.24 lines / 83.19 branches), Prettier чист.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Обобщение на ревюто (feat(assistant): наблюдаем RAG fallback)

Много добре изпипано и изчерпателно тествано PR. Промяната добавя наблюдаемост на retrieval-а (RetrievalStats/onStats, логнати деградации в route-а — #318), маха последния as unknown as каст чрез боундъри-адаптер (bindings.ts#316), сменя метаданни-filter с нативни версионирани namespace-и (schema-v2/entity-v1#317) и въвежда безусловно инжектиране на hard-trap-овете плюс relevance floor за RAG. Придружено е с целенасочени тестове за всеки нов клон.

Фаза 0 — security scan: ЧИСТО

  • Няма закодирани тайни — BGGPT_API_KEY минава през wrangler secret put, изрично никога не се комитва.
  • Няма нови/променени външни URL-и извън одобрените.
  • Няма backdoor/обфускация/инжекции. Точно обратното — денилистът в sql-guard.ts се разширява (group_concat/string_agg/json_group_array/json_group_object), а report-schema.ts затваря дупка за неограничени числа в публичен отчет.
  • Промените в зависимостите са само в osv-scanner.toml (игнориране на sharp libvips CVE-та) — обосновано като транзитивна, само-dev зависимост на miniflare, извън деплойнатия Worker, с дата за преразглеждане.

Силни страни

  • Сигурност/устойчивост: логовете носят само броячи и error.message, никога текста на въпроса или payload-а (последователно в bindings.ts, rag.ts, assistant.chat.tsx) — правилна защита срещу изтичане на потребителски вход в логовете.
  • Коректност: align е whitelist-нат, spread-ът на колони е заменен с експлицитно копиране (не пропуска непознати model-подадени полета към рендера), array-length таваните късат сканирането при препълване, score ?? 0 защитава срещу TypeError надолу по веригата.
  • Без дублиране: renderTraps() е единствен източник за trap-овете; тестът „exactly-once" покрива регресията с двойно рендиране през реалния write→read seam.
  • Тестове: смислени, а не тривиални — derived-from-floor фикстури, за да не се чупят при рекалибрация на MIN_SCHEMA_SCORE; проверки на call-count, за да не се промъкне filter-базиран fallback.

Съображения (не блокиращи)

  1. MIN_SCHEMA_SCORE = 0.35 е некалибриран за v2 корпуса (изрично отбелязано, #318). Рискът е контролиран: под флора → [] → безопасен fallback към пълния речник, а fallback-процентът вече е наблюдаем. Препоръка: рекалибрирайте с реални въпроси, преди да разчитате на RAG grounding в прод.
  2. Почистване на стар кохорт изисква ръчно възстановяване на списъка с id-та от git историята на buildSchemaChunks — операционно чупливо, но документирано и незадължително (namespace-ът изолира старите кохорти).
  3. Денилистът за SQL функции по същество е догонваща игра — авторите сами отбелязват, че позитивен allowlist е трайното решение (проследено отделно).

Препоръка: COMMENT

Кодът е готов за мърдж и не чупи main. Оставям COMMENT (вместо APPROVE) единствено заради висящата рекалибрация на relevance floor-а (#318) — тя е предпоставка да се вярва на RAG grounding в новия режим, макар че текущото поведение при провал е безопасно.

Comment thread apps/web/app/lib/assistant/sql-guard.ts Outdated
Comment thread apps/web/app/lib/assistant/report-schema.ts Outdated
Comment thread apps/web/app/routes/assistant.chat.tsx
Comment thread apps/web/app/lib/assistant/emit-report-schema.ts
@nedda76
nedda76 force-pushed the fix/assistant-rag-observability branch from 8a8156f to 8410e8d Compare August 19, 2026 17:10

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Обобщение на ревюто — feat(assistant): наблюдаем RAG fallback

Обща оценка: 9.3/10 — APPROVE. PR-ът е с висока чистота, добре тестван и защитен на всяка граница. Промените са атомарни и в един ясен концерн (наблюдаемост на RAG + версионирани namespace-и + няколко review follow-up хардънинга).

Сигурност (Фаза 0 — автоматично сканиране)

  • Няма hardcoded тайни. wrangler secret put BGGPT_API_KEY е интерактивен; README изрично казва „никога не се комитва".
  • Няма нови/променени външни URL-и. Единственият модел-литерал е @cf/baai/bge-m3 (Workers AI capability), не мрежов endpoint.
  • Няма зловредни шаблони / обфускация / backdoors.
  • Зависимости: osv-scanner.toml добавя ignore само за транзитивна dev-only уязвимост (sharp<0.35.0 през miniflare), с обосновка и ignoreUntil дата. Приемливо — не е в деплойнатия Worker.
  • Резултат: CLEAN.

Силни страни

  1. emit-report-schema.ts — тавани на масивите (MAX_BLOCKS/ITEMS/COLUMNS) със short-circuit преди per-element сканирането; тестовете доказват, че се връща точно кап-грешката, а не 101 паразитни грешки. Затваря реален DoS/amplification вектор.
  2. isAlign whitelist + експлицитно изграждане на колоните в bindReport (без {...c} spread) — спира пренасяне на непроверени model-подадени полета към рендера. Отлична defense-in-depth срещу атрибутна инжекция.
  3. sql-guard.ts — блокиране на string-building агрегати (group_concat/string_agg/json_group_*) — правилно затваря същия memory-amplification клас като printf едно ниво нагоре, вкл. string_agg alias за SQLite ≥3.44 (D1).
  4. system-prompt.tshardTraps() безусловно — коригира реалната регресия „RAG turn с по-малко ограничения от no-RAG fallback". Композиционният тест през реалния write→read seam (indexSchemaCorpus → retrieve → buildSystemPrompt) проверява „точно веднъж" за всеки trap — силен тест.
  5. bindings.ts — премахване на as unknown as (issue #316) с реален compiler-checked bridge и keys-only диагностика, която не логва потребителски текст.
  6. Наблюдаемост (#318): RetrievalStats с три отделни брояча (matched/aboveFloor/kept) различава двата класа деградация; best-effort try/catch около sink-а; логовете носят само броячи, никога текста на въпроса.

Незадължителни watch-items (не блокират мърдж)

  • MIN_SCHEMA_SCORE и MIN_ENTITY_SCORE = 0.35 (rag.ts). Кодът сам отбелязва „RECALIBRATION PENDING (#318)": прагът е калибриран срещу pre-v2 корпуса (12 къси trap chunk-а), а v2 е 25 по-дълги query/table chunk-а, които скорират различно под bge-m3. Рискът е тих fallback към пълния речник за много въпроси — но е безопасният изход и вече е наблюдаем чрез assistant.rag лога. Препоръка: измерете реалните разпределения на скора преди да фиксирате прага; обмислете отделни стойности за схема vs entity.
  • entity-v1 namespace е празен by design (няма entity indexer). Инструментът връща 0 попадения — коректно документирано в README/rag.ts, включително предупреждението, че „WHEN TO BUMP" правилото НЕ се пренася към data-derived entity корпуса. Добра проактивна бележка за бъдещия indexer.
  • Denylist подходът в sql-guard.ts е inherently catch-up игра срещу нови aliases — PR-ът сам признава, че positive allowlist е трайният фикс (tracked separately). ОК за сега.

CLAUDE.md съответствие

Няма partial implementation, TODO-simplification, дублиране (renderTraps() дедуплицира trap рендера между describeSchema и hardTraps), мъртъв код или resource leaks. Тестовете са смислени (не cheater — проверяват граници, floor drop-ове, метадата bug сигнали), не тривиални. Именуването е консистентно.

Препоръка: APPROVE. Watch-items-ите са наблюдателни follow-up-и, не дефекти.

Comment thread apps/web/app/lib/assistant/system-prompt.ts
Comment thread apps/web/app/lib/assistant/sql-guard.ts
Comment thread apps/web/app/lib/assistant/rag.ts

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ревю на PR: наблюдаем RAG fallback — статистика на retrieval-а и логнати деградации

Фаза 0 — Сигурност (блокираща): ЧИСТО ✅

  • Няма твърдо кодирани тайни. BGGPT_API_KEY минава през wrangler secret put и изрично се коментира „никога не се комитва". Няма API ключове/пароли/токени в дифа.
  • Няма нови външни URL-и. EMBED_MODEL = '@cf/baai/bge-m3' е Workers AI идентификатор на модел, не мрежов адрес.
  • Няма зловредни шаблони (backdoor/инжекция/обфускация).
  • Зависимости: единствената промяна е добавяне на два IgnoredVulns записа в osv-scanner.toml за sharp<0.35.0 (libvips CVE-та). Обосновката е коректна — транзитивна, само-dev зависимост през miniflare, не влиза в деплойнатия Worker, няма in-range upstream fix; има ignoreUntil дата за ре-триаж. Приемливо.

Общо качество: много силен PR

Промяната е атомарна и добре обоснована — цялата се върти около една тема (наблюдаемост на RAG fallback + втвърдяване на границите). Забележителни силни страни:

  • Сигурност на данните към логовете: навсякъде се логват само броячи/съобщения, никога текстът на въпроса или суровият error обект (bindings.ts, assistant.chat.tsx, rag.ts). Това е последователно спазено и изрично документирано.
  • bindings.ts (issue #316): премахването на as unknown as в полза на типизиран адаптер е реално подобрение — грешка на границата вече се хваща от tsc, а не в продукция. Адаптерът разграничава „липсващ ключ" от „празен data масив за непразен вход", което пази долната embed() count-проверка от подвеждащо съобщение.
  • rag.ts: преходът от metadata filter към нативен namespace е правилен (metadata филтър изисква provisioned metadata index, който репото няма — #317). Версионирането на namespace + id (schema-v2) и правилото „WHEN TO BUMP" коректно пазят rollback прозореца. Релевантностният праг (MIN_SCHEMA_SCORE) решава реален проблем — иначе top-K връща K-те най-близки чънка дори при изцяло off-topic заявка и прави grounding-а по-слаб от no-RAG fallback-а.
  • system-prompt.ts: hardTraps() инжектира императивните капани безусловно — това затваря реалната дупка, при която RAG turn можеше да остане с по-малко ограничения от fallback-а. Тестът renders every hard trap exactly once минава през реалния write→read seam и пази срещу двойно рендиране.
  • sql-guard.ts: разширяването с string-building агрегати (group_concat/string_agg/json_group_*) и покриването на quoted-identifier bypass ("group_concat"(x), [group_concat](x)) е добре обмислено; авторът честно отбелязва, че denylist е catch-up игра и позитивен allowlist е трайният fix.
  • Покритие с тестове: отлично. Всеки нов клон има целеви тест (праг, scoreless match, onStats broene, throwing sink, cap short-circuit, quoted-name bypass, spelled magnitudes до секстилион). Тестовете са проектирани да ловят регресии, не да минават тривиално.

CLAUDE.md съответствие

Няма частична имплементация, дублиране (renderTraps() дедупlicира rendering-а на капаните), мъртъв код или смесени концерни. Ресурсите се почистват (best-effort try/catch около onStats).

Дребни, неблокиращи бележки

  1. semanticSearch не получи onStats наблюдаемостта, която retrieveSchemaContext придоби. Днес е безвредно (entity корпусът е празен по дизайн), но когато entity indexer-ът от Фаза 2 се появи, ще е полезна същата симетрия (виж инлайн).
  2. MIN_SCHEMA_SCORE е изрично отбелязан за рекалибрация (#318) спрямо v2 корпуса — това е следеният, очакван компромис, а не скрит TODO; просто да не се забрави преди да се разчита на прага в новия режим.

Вердикт

Няма блокиращи или critical находки. Сигурност: чисто. Тестове, наблюдаемост и документация са налице и последователни. Препоръка: APPROVE.

Comment thread apps/web/app/lib/assistant/rag.ts Outdated
Comment thread apps/web/app/lib/assistant/rag.ts
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 20, 2026
…isFinite и по схема пътя

- semanticSearch подава SemanticSearchStats (matched/kept) през същия
  best-effort reportStats hook; tools.ts ги логва структурирано
  ({evt:'assistant.semantic'}) — след напълването на entity-v1 (Фаза 2)
  'флорът отряза всичко' и 'празен namespace' са различими и там.
- Схема флорът също мина на Number.isFinite (симетрия с entity ръба при
  изричен minScore = 0 в RetrieveOptions).
- Error логът на semantic_search подава само message — провайдърски
  error envelope може да ехне текста на заявката (същата дисциплина
  като route-а). (бележки от ревюто на midt-bg#321)
@nedda76
nedda76 force-pushed the fix/assistant-rag-observability branch from 4ab2b80 to f5ef473 Compare August 20, 2026 07:01
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Малка непоследователност в leak-safety патерна, който този PR въвежда: assistant.chat.tsx:128-132 редактира до .message (защото Workers AI/Vectorize грешка може да ехне embedded-ната заявка — обосновката на :129-131), но третият catch в същия файл, :145-148, още логва суровия error обект:

} catch (error) {
  console.error('[assistant] turn failed to start', error);

Заварено (непроменено спрямо main), но след като серията установява и тества „само .message" два пъти точно в този файл за точно този клас изтичане, третото място си струва да е огледално:

const message = error instanceof Error ? error.message : String(error);
console.error('[assistant] turn failed to start:', message);

Иначе #319#320#321 е издържана серия: namespace-ът е приложен и на insert, и на query (rag.ts:125,159 — без cross-namespace leak), DATA_TRAPS grounding-ът остава непокътнат на fallback пътя (hardTraps() безусловно), stats-овете са само броячи (без PII), а quoted-identifier bypass fix-ът в sql-guard.ts вече покрива и "…"/[…]/backtick. Merge ред: 319 → 320 → 321 (стекнати).

nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 21, 2026
…isFinite и по схема пътя

- semanticSearch подава SemanticSearchStats (matched/kept) през същия
  best-effort reportStats hook; tools.ts ги логва структурирано
  ({evt:'assistant.semantic'}) — след напълването на entity-v1 (Фаза 2)
  'флорът отряза всичко' и 'празен namespace' са различими и там.
- Схема флорът също мина на Number.isFinite (симетрия с entity ръба при
  изричен minScore = 0 в RetrieveOptions).
- Error логът на semantic_search подава само message — провайдърски
  error envelope може да ехне текста на заявката (същата дисциплина
  като route-а). (бележки от ревюто на midt-bg#321)
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 21, 2026
…а модула

Третият catch в route-а още логваше суровия error обект, докато същият
файл вече два пъти пази 'само message' заради ехо на въпроса в логовете.
Същият клас имаше и в run_sql (D1 грешка носи SQL-а, построен от
въпроса) и в stream onError (BgGPT грешка носи prompt-а).

Вместо трети copy-paste на тернара — errorText() в log-safety.ts,
използван на ВСИЧКИТЕ пет catch/лог места: само message (никога stack,
никога cause веригата, никога обекта), с таван от 300 знака срещу
гигантско тяло от провайдър. Тестът закова точно тези инварианти
(негативен контрол: връщане на stack/cause го чупи).

Бележка от ревюто на @lyubomir-bozhinov по midt-bg#321.
@nedda76
nedda76 force-pushed the fix/assistant-rag-observability branch from f5ef473 to c7ec503 Compare August 21, 2026 10:54
@nedda76

nedda76 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Прав си — и не само третият catch. Проверих всички лог места в модула и същият клас беше още на две: tools.ts run_sql (D1 грешката носи SQL-а, който моделът е построил от въпроса) и agent.ts stream onError (BgGPT грешката носи prompt-а).

Затова вместо трети copy-paste на тернара — errorText() в новия log-safety.ts, използван на ВСИЧКИТЕ пет catch/лог места: само message, никога stack, никога cause веригата, никога обекта, плюс таван от 300 знака срещу гигантско тяло от провайдър. Тестът заковава точно тези инварианти (негативен контрол: връщане на stack/cause го чупи). c7ec503.

Благодаря и за прегледа на серията — merge редът е потвърден: 319 → 320 → 321.

@nedda76

nedda76 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Допълнение към горното — само-ревюто на първия ми комит намери, че той пазеше грешната ос, затова го поправих в cecdf2e:

  1. Помощникът не беше тотален. String(Object.create(null)) хвърля (проверено), Proxy или хостилен message getter — също. Функция, която живее в пет catch блока, не може да хвърля: изключението щеше да избяга от catch-а, да убие graceful fallback-а и да върне 500 — при това с целия обект в лога на framework-а, т.е. точно обратното на целта. Вече е тотален (фиксиран таг при неконвертируема стойност).
  2. Махането на stack-а не адресира ехото. Stack frames по конструкция не носят потребителски текст — ехото от провайдъра е в message. Затова сайтовете, които знаят входа си, вече го подават за редакция: въпросът (route-ът и stream onError през ctx.userQuestion) и заявката (semantic_search). Това е частта, която реално затваря „грешката ехна въпроса"; тапата и без-stack-а само ограничават щетата. Където входът не е известен на catch-а (D1 грешка с model-построен SQL) message-ът се логва съзнателно — иначе губим единствената корелация към проблемната заявка; записано е изрично в log-safety.ts.
  3. Тестът твърдеше нещо невярно („обект се свежда до непрозрачен таг") — вярно е само за обект с наследен toString. Сега тестът документира реалното поведение.

Плюс: collapse на нови редове (многоредово съобщение чупеше prefix-базиран grep — продълженията нямат [assistant] префикс), рязане по кодови точки, и връщане на stack frames при setup грешката в route-а, където дефектът е конфигурационен и рамката е диагностиката.

Негативни контроли: махането на try/catch чупи теста за тоталност, махането на редакцията — теста за ехото. 524 теста зелени, покритието над baseline.

Остава като съзнателен пропуск: корелация на run_sql провалите (fingerprint на заявката) — по-скоро observability, отколкото leak-safety; ако смяташ, че си струва, отварям отделен issue.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Патчовете c7ec503a/cecdf2e2 затварят находката от предишния ревю — и трите catch-а вече минават през errorText, stackHead дава само рамки. Проверих при HEAD cecdf2e2. ✅

Един остатъчен ръб в errorText (log-safety.ts), който тестовете не покриват:

let out = raw.replace(/\s+/g, ' ').trim();
for (const needle of redact) {
  if (needle && needle.length >= MIN_REDACT_CHARS) out = out.split(needle).join(REDACTED);
}

out е whitespace-collapse-нат, needle-ът (question) — не. latestUserText() прави само join(' ').trim(), без да свива вътрешния whitespace, затова многоредов въпрос (shift-enter в composer-а) или таб/двоен интервал остават в needle-а. Ако provider-ът ехне такъв въпрос в съобщението на грешка, collapse-натият out вече не съвпада с needle-а → split не намира нищо → въпросът влиза дословно (само с интервали вместо \n) в лога. Точно гаранцията „no verbatim copy of that input", която модулът декларира.

Repro:

const question = 'колко плати\nобщина Пловдив на фирма Х';
errorText(new Error(`400 invalid input: ${question}`), [question]);
// → "400 invalid input: колко плати община Пловдив на фирма Х"  (нередактирано)

Фикс — свий needle-а по същия начин преди split:

const n = needle.replace(/\s+/g, ' ').trim();
if (n.length >= MIN_REDACT_CHARS) out = out.split(n).join(REDACTED);

Minor (само server логове, иска ехо от provider-а), но пада точно в основния control за ехнат вход — и е един ред тест: needle с \n.

@nedda76

nedda76 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Приложено в f8af0b6 — needle-ът за редакция минава през същото collapseWhitespace като съобщението, прагът MIN_REDACT_CHARS се преценява по свитата форма (whitespace не може да издуе къс needle над него), а твоето repro (\n във въпроса) е тест с негативен контрол: пада срещу старата имплементация.

Покрай това минах клона още веднъж отгоре до долу — гаранцията „въпросът не влиза в лога" беше пробита на още три места, всички затворени:

  • streamText има собствен default onError = console.error(error) — суровият APICallError, чиито собствени полета носят requestBodyValues (system prompt + съобщенията) и responseBody. Hook-ът беше подаден само на toUIMessageStreamResponse, така че SDK-то продължаваше да пише суровия обект при всяка провайдърска грешка. Сега логът е една редактирана линия с клас/HTTP статус (401 vs 429 vs 5xx остават различими, само идентификатори), дедупната по идентичност на грешката (13ab613).
  • Catch-ът на RAG retrieval в route-а викаше errorText(error) без [question] — точно мястото, което embed-ва въпроса и е най-вероятното ехо (13ab613).
  • stackHead приемаше едноредово съобщение, а V8 печата ЦЯЛОТО многоредово съобщение преди първата рамка — продълженията излизаха нередактирани точно до редактирания errorText на същия ред. Вече се котви на първия at ред и без разпознаваема рамка връща '' (fail closed). Редакцията междувременно лови и JSON-escaped (\", literal \n) и отрязано ехо през прозоречно съвпадение, с bounded pre-cap срещу многомегабайтово тяло (4a9f5de).
  • Бонус в същата класа: bindings.ts правеше 'data' in out без обектен guard — при не-обект V8 слага операнда в съобщението на TypeError-а, т.е. тяло от gateway влизаше в лога през „keys-only" пътя; вече се именува само типът (13ab613).

Всичко това беше минало под радара, защото нито един тест не влизаше през вратата на потребителя — добавени са route-door тестове (POST през action() с фалшиви AI/VECTORIZE: stats линията е само броячи, RAG-провалът с ехо е редактиран, setup-провалът дава 503 с рамки-only линия) и stream-error тест през реалния streamText с MockLanguageModelV3. И двата са негативни контроли за горните находки (13ab613).

Останалото от само-ревюто (b54a86b56a24a2): „павилион(и)" вече не флагва като число (суфиксите са котвени към числителните представки — \p{L} lookaround не различава „пав-илион" от „секст-илион"); голо млн./млрд. в мерно заглавие („Стойност (млн. €)" — стилът на собствените ни колони) също не, флагва само след изписано числително; jsonb_group_* влезе в денилиста (SQLite ≥3.45 в build-а на workerd, минаваше и двата слоя); sub на facts се проверява по тип (число минаваше shape-а и хвърляше TypeError в bindReport — непрозрачна tool грешка вместо retryable съобщение); align/link: null се приемат като „не е зададено"; link се пресъздава от двете си известни полета (href отвътре не стига до замразения отчет); async stats sink вече не тече като unhandled rejection; returnMetadata: 'all' е закован в тестовете (махането му оставаше зелено срещу фалшивия индекс, а в прод дава text-less съвпадения → kept=0 → тих fallback на всеки ход); записът за amendments в речника носи value_suspect/валутния/join капаните от новите миграции (#305/#307); и мъртвото osv изключение за sharp е махнато — main вече override-ва до 0.35.3 от #226, записът идваше от стар комит, пребазиран отгоре.

549 теста зелени, tsc чист, Prettier чист. Отворени като follow-up, не в този PR: изпълним вход за indexSchemaCorpus (нищо в репото не може да напълни schema-v2 — всяка среда е на full-dictionary fallback до тогава) и дрифт-тест, който заковава колоните от TABLES срещу реалните миграции през pragma_table_info.

nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 24, 2026
…isFinite и по схема пътя

- semanticSearch подава SemanticSearchStats (matched/kept) през същия
  best-effort reportStats hook; tools.ts ги логва структурирано
  ({evt:'assistant.semantic'}) — след напълването на entity-v1 (Фаза 2)
  'флорът отряза всичко' и 'празен namespace' са различими и там.
- Схема флорът също мина на Number.isFinite (симетрия с entity ръба при
  изричен minScore = 0 в RetrieveOptions).
- Error логът на semantic_search подава само message — провайдърски
  error envelope може да ехне текста на заявката (същата дисциплина
  като route-а). (бележки от ревюто на midt-bg#321)
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 24, 2026
…а модула

Третият catch в route-а още логваше суровия error обект, докато същият
файл вече два пъти пази 'само message' заради ехо на въпроса в логовете.
Същият клас имаше и в run_sql (D1 грешка носи SQL-а, построен от
въпроса) и в stream onError (BgGPT грешка носи prompt-а).

Вместо трети copy-paste на тернара — errorText() в log-safety.ts,
използван на ВСИЧКИТЕ пет catch/лог места: само message (никога stack,
никога cause веригата, никога обекта), с таван от 300 знака срещу
гигантско тяло от провайдър. Тестът закова точно тези инварианти
(негативен контрол: връщане на stack/cause го чупи).

Бележка от ревюто на @lyubomir-bozhinov по midt-bg#321.
@nedda76
nedda76 force-pushed the fix/assistant-rag-observability branch from 56a24a2 to ea27b25 Compare August 24, 2026 12:48
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 24, 2026
…рифт-пазач)

Речникът, който моделът чете (describe-schema.ts), дрифтна тихо веднъж:
amendments.contract_id никога не е съществувала, а parties.role — също, и
двете се откриха само на око при ревюто на midt-bg#321. Всяка фантомна колона там
влиза във всеки prompt, а полученият SQL пада в runtime с грешка, която
моделът „поправя" с ново налучкване.

Тестът прилага ВСИЧКИ packages/db/migrations/*.sql поред върху временна
sqlite3 база (same harness като packages/db/src/migrations.test.ts) и
проверява:
- всяка таблица от речника съществува;
- всеки водещ идентификатор от всеки columns низ е реална колона
  (pragma_table_info; скобените бележки със запетаи/кавички се игнорират);
- всяка →таблица референция е реална таблица;
- всяка канонична заявка КОМПИЛИРА срещу схемата (EXPLAIN) — тях моделът
  копира дословно като отправна точка;
- негативни контроли: историческият дрифт ('id, contract_id→contracts, …')
  се хваща, а parser-ът не се лъже от бележки в скоби.

Проверено срещу main-версията на речника: пазачът докладва точно
amendments.contract_id и parties.role. Понеже поправката им живее в midt-bg#321,
клонът е стакнат върху него (мърдж ред: midt-bg#321 → този).

Тестът живее в apps/web/test/ и е включен в tsconfig.node.json (Node типове
за child_process), за да не се наливат Node globals в Workers кода на
приложението; vitest include покрива test/**.
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 25, 2026
…isFinite и по схема пътя

- semanticSearch подава SemanticSearchStats (matched/kept) през същия
  best-effort reportStats hook; tools.ts ги логва структурирано
  ({evt:'assistant.semantic'}) — след напълването на entity-v1 (Фаза 2)
  'флорът отряза всичко' и 'празен namespace' са различими и там.
- Схема флорът също мина на Number.isFinite (симетрия с entity ръба при
  изричен minScore = 0 в RetrieveOptions).
- Error логът на semantic_search подава само message — провайдърски
  error envelope може да ехне текста на заявката (същата дисциплина
  като route-а). (бележки от ревюто на midt-bg#321)
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 25, 2026
…а модула

Третият catch в route-а още логваше суровия error обект, докато същият
файл вече два пъти пази 'само message' заради ехо на въпроса в логовете.
Същият клас имаше и в run_sql (D1 грешка носи SQL-а, построен от
въпроса) и в stream onError (BgGPT грешка носи prompt-а).

Вместо трети copy-paste на тернара — errorText() в log-safety.ts,
използван на ВСИЧКИТЕ пет catch/лог места: само message (никога stack,
никога cause веригата, никога обекта), с таван от 300 знака срещу
гигантско тяло от провайдър. Тестът закова точно тези инварианти
(негативен контрол: връщане на stack/cause го чупи).

Бележка от ревюто на @lyubomir-bozhinov по midt-bg#321.
@nedda76
nedda76 force-pushed the fix/assistant-rag-observability branch from ea27b25 to cf78482 Compare August 25, 2026 18:29
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 25, 2026
…рифт-пазач)

Речникът, който моделът чете (describe-schema.ts), дрифтна тихо веднъж:
amendments.contract_id никога не е съществувала, а parties.role — също, и
двете се откриха само на око при ревюто на midt-bg#321. Всяка фантомна колона там
влиза във всеки prompt, а полученият SQL пада в runtime с грешка, която
моделът „поправя" с ново налучкване.

Тестът прилага ВСИЧКИ packages/db/migrations/*.sql поред върху временна
sqlite3 база (same harness като packages/db/src/migrations.test.ts) и
проверява:
- всяка таблица от речника съществува;
- всеки водещ идентификатор от всеки columns низ е реална колона
  (pragma_table_info; скобените бележки със запетаи/кавички се игнорират);
- всяка →таблица референция е реална таблица;
- всяка канонична заявка КОМПИЛИРА срещу схемата (EXPLAIN) — тях моделът
  копира дословно като отправна точка;
- негативни контроли: историческият дрифт ('id, contract_id→contracts, …')
  се хваща, а parser-ът не се лъже от бележки в скоби.

Проверено срещу main-версията на речника: пазачът докладва точно
amendments.contract_id и parties.role. Понеже поправката им живее в midt-bg#321,
клонът е стакнат върху него (мърдж ред: midt-bg#321 → този).

Тестът живее в apps/web/test/ и е включен в tsconfig.node.json (Node типове
за child_process), за да не се наливат Node globals в Workers кода на
приложението; vitest include покрива test/**.
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 26, 2026
…isFinite и по схема пътя

- semanticSearch подава SemanticSearchStats (matched/kept) през същия
  best-effort reportStats hook; tools.ts ги логва структурирано
  ({evt:'assistant.semantic'}) — след напълването на entity-v1 (Фаза 2)
  'флорът отряза всичко' и 'празен namespace' са различими и там.
- Схема флорът също мина на Number.isFinite (симетрия с entity ръба при
  изричен minScore = 0 в RetrieveOptions).
- Error логът на semantic_search подава само message — провайдърски
  error envelope може да ехне текста на заявката (същата дисциплина
  като route-а). (бележки от ревюто на midt-bg#321)
nedda76 added a commit to nedda76/sigma that referenced this pull request Aug 26, 2026
…а модула

Третият catch в route-а още логваше суровия error обект, докато същият
файл вече два пъти пази 'само message' заради ехо на въпроса в логовете.
Същият клас имаше и в run_sql (D1 грешка носи SQL-а, построен от
въпроса) и в stream onError (BgGPT грешка носи prompt-а).

Вместо трети copy-paste на тернара — errorText() в log-safety.ts,
използван на ВСИЧКИТЕ пет catch/лог места: само message (никога stack,
никога cause веригата, никога обекта), с таван от 300 знака срещу
гигантско тяло от провайдър. Тестът закова точно тези инварианти
(негативен контрол: връщане на stack/cause го чупи).

Бележка от ревюто на @lyubomir-bozhinov по midt-bg#321.
…ема пътя

Без флор, щом entity корпусът се напълни, top-K връща K-те най-близки
съседа ДОРИ когато всички са off-topic, и те стигат до модела като
реални hits. MIN_ENTITY_SCORE (симетричен на MIN_SCHEMA_SCORE) реже под
прага; match без score се чете като под флора и отпада — същото
защитно правило като схема пътя. Тестовете деривират скоровете от
флора ± ε (бележка от ревюто на midt-bg#319).
…-namespace кохорта

- Number.isFinite вместо ?? 0 във флор филтъра на semanticSearch:
  (undefined ?? 0) >= 0 промъкваше match без score като 'hit' при
  изричен minScore = 0, а истински score 0 при флор 0 е легитимен —
  двата случая вече са разграничени (+ тест). След филтъра score е
  гарантирано число и DTO-то няма нужда от fallback.
- README: 'стар кохорт' изрично включва и оригиналния pre-namespace
  кохорт (id-та в DEFAULT namespace отпреди версионирането) — за
  първите среди той също е orphan за чистене (бележки от ревюто).
…та да не зависи от стойността на флора)

Този PR въвежда entity флора с Number.isFinite точно за да не пропусне
scoreless match при minScore = 0 — но остави схема пътя на (m.score ?? 0),
т.е. асиметрия, въведена в същия PR. При подразбиращия се 0.35 двата се
държат еднакво, но извикване с minScore = 0 би пропуснало match без score
като „контекст". Изравнено; тест точно за minScore = 0 (негативен контрол:
връщането на ?? 0 го чупи). Бележка от ревюто на @ydimitrof.
…s' на route границата (midt-bg#316)

env.VECTORIZE вече се присвоява на VectorIndex БЕЗ каст: metadata на
VectorRecord е стеснен до стойностите, които Vectorize приема, а
неизползваемият metadata filter отпадна от интерфейса — така tsc доказва
присвоимостта и дрейф между rag.ts и worker-configuration.d.ts чупи
typecheck-а, не продукцията (негативен контрол: върнат filter член →
TS2322 на самото присвояване).

env.AI не може да удовлетвори EmbeddingRunner структурно (run() връща
per-model union), затова route-ът минава през типизиран адаптер, който
вика реалния @cf/baai/bge-m3 overload — също проверен от компилатора —
и подава само embeddings члена; embed() и без това fail-fast-ва при
малформен data.

Кастовете към AgentEnv остават: BGGPT_API_KEY е secret и не присъства
в генерирания Env — отделен въпрос от AI/Vectorize биндингите.

Closes midt-bg#316
- EmbeddingRunner.run вече взима model: typeof EMBED_MODEL (литерала), а
  адаптерът го препраща в реалния Ai.run overload — втори, различен
  модел би бил компилационна грешка, не тихо embed-ване с грешния модел.
- Адаптерът е изнесен в bindings.ts (embeddingRunnerFor) — единственият
  модул, който познава и двете страни на границата; unit тестван,
  включително [] случаят, който инлайн версията оставяше непокрит.
- При неочаквана форма на отговора адаптерът хвърля именувана грешка
  (само ключовете, без payload — error envelope може да ехне въпроса)
  вместо да връща [], което се четеше като 'провайдърът не embed-на нищо'.
- Header коментарът на rag.ts е разделен по интерфейс: VectorIndex е
  присвоим от VectorizeIndex (без каст), EmbeddingRunner нарочно НЕ е;
  поправено и погрешното твърдение, че Vectorize няма filter поле.
- README provisioning gate-ът вика indexSchemaCorpus през
  embeddingRunnerFor — голият env.AI вече не typecheck-ва там.
…е като успех

[] е truthy — проверка само за присъствие връщаше { data: [] } за
непразен вход и embed() после обвиняваше '0 embeddings' вместо реалната
причина: провайдър, отговорил с празен batch. Празният масив вече е
именуван отделен случай в грешката на адаптера (+ тест; бележка от
ревюто на midt-bg#320).
Ранното връщане при `texts.length === 0` е това, което прави вярно
„адаптерът никога не се вика с празен вход" за ВСЕКИ извикващ — не само за
днешните три. Досега контрактът беше негласен (споменат само в bindings.ts);
сега е записан на самото място, което го гарантира, с указание да не се мести
под run(). Тестът в rag.test.ts вече закова, че моделът не се вика за [].
(бележка от ревюто на midt-bg#320, ydimitrof)
…rieval-а + логнати деградации

retrieveSchemaContext подава RetrievalStats (matched срещу kept) през
опционален onStats hook; route-ът ги логва и вече не поглъща тихо
грешката при retrieval (console.error + fallback, в стила на другите
деградационни пътища на файла). kept=0 при matched>0 е точно тихият
fallback към пълния речник, който досега беше неразличим от работещ RAG.

Позиционните topK/minScore станаха opts обект — така сигнатурата носи
и hook-а без опашка от undefined аргументи.

Флорът MIN_SCHEMA_SCORE остава непипнат: коментарът му вече казва изрично,
че е калибриран срещу пре-v2 корпуса и чака емпирично премерване — това е
втората половина на midt-bg#318, която ще стъпи върху тези логове.

Част от midt-bg#318
…вюто

- onStats се вика в try/catch (reportStats) — хвърлящ metrics sink не
  може да струва на хода неговите чънкове (инвариантът на request-log.ts);
  закован с тест.
- Третият деградационен път (!vec — embed без използваем вектор) вече
  също докладва, с нули — иначе е неразличим от 'статистиката не е вързана'.
- kept се раздели от aboveFloor: aboveFloor > kept сигнализира счупен
  metadata.text контракт (бъг в индексирането), не флор — двата класа
  повреди имат противоположни поправки.
- Логът на route-а е структуриран JSON (стилът на request-log.ts), а
  error логът подава само message — суров error обект може да ехне
  въпроса на потребителя в логовете.
- Тестът за статистика деривира скоровете от MIN_SCHEMA_SCORE ± ε и
  затвърждава и върнатите чънкове, не само броячите.

Част от midt-bg#318
…isFinite и по схема пътя

- semanticSearch подава SemanticSearchStats (matched/kept) през същия
  best-effort reportStats hook; tools.ts ги логва структурирано
  ({evt:'assistant.semantic'}) — след напълването на entity-v1 (Фаза 2)
  'флорът отряза всичко' и 'празен namespace' са различими и там.
- Схема флорът също мина на Number.isFinite (симетрия с entity ръба при
  изричен minScore = 0 в RetrieveOptions).
- Error логът на semantic_search подава само message — провайдърски
  error envelope може да ехне текста на заявката (същата дисциплина
  като route-а). (бележки от ревюто на midt-bg#321)
…а модула

Третият catch в route-а още логваше суровия error обект, докато същият
файл вече два пъти пази 'само message' заради ехо на въпроса в логовете.
Същият клас имаше и в run_sql (D1 грешка носи SQL-а, построен от
въпроса) и в stream onError (BgGPT грешка носи prompt-а).

Вместо трети copy-paste на тернара — errorText() в log-safety.ts,
използван на ВСИЧКИТЕ пет catch/лог места: само message (никога stack,
никога cause веригата, никога обекта), с таван от 300 знака срещу
гигантско тяло от провайдър. Тестът закова точно тези инварианти
(негативен контрол: връщане на stack/cause го чупи).

Бележка от ревюто на @lyubomir-bozhinov по midt-bg#321.
…вариант пазеше грешната ос

Само-ревюто на предишния комит намери три дефекта в него:

1. НЕ беше тотален: String(Object.create(null)) хвърля (проверено), а
   Proxy/хостилен message getter — също. Помощник, който живее в пет
   catch блока, не може да хвърля: изключението щеше да избяга от
   catch-а, да убие graceful fallback-а (пълния речник / стрийм линията)
   и да върне 500, при това с целия обект в лога на framework-а.
2. Филтрираше по грешната ос: махаше stack-а (който по конструкция НЕ
   носи потребителски текст) и пазеше message-а (където точно се появява
   ехото от провайдъра). Затова сега сайтовете, които знаят входа си,
   го подават за РЕДАКЦИЯ: въпросът (route + stream през ctx.userQuestion)
   и заявката (semantic_search). Това е частта, която реално затваря
   ехото; тапата и без-stack-а само ограничават щетата.
3. Тестът твърдеше 'обект се свежда до непрозрачен таг' — невярно за
   обект със собствен toString (проверено). Тестът вече документира
   реалното поведение, а не желаното.

Освен това: collapse на нови редове (многоредово съобщение чупеше
prefix-базиран grep — продълженията нямат [assistant] префикс), рязане
по кодови точки (не режем емоджи на самотен surrogate), и връщане на
stack frames там, където те са диагностиката — setup грешката в route-а
е конфигурационна, не провайдърска.

Негативни контроли: махането на try/catch чупи теста за тоталност,
махането на редакцията чупи теста за ехото.
…о на съобщението

errorText свиваше съобщението до един ред преди split, но подаваният needle
(въпросът) оставаше суров — latestUserText прави само join(' ').trim(). Въпрос с
shift-enter, таб или двоен интервал, ехнат от провайдъра, вече не съвпадаше и
влизаше дословно в лога — точно гаранцията „no verbatim copy", която модулът
декларира.

Сега needle-ът минава през същото collapseWhitespace, а прагът MIN_REDACT_CHARS
се преценява по свитата форма (whitespace не може да издуе къс needle над него).
typeof guard пази тоталността срещу не-низ през unknown границата.

Тестове: repro на ревюто (needle с \n/\t/двоен интервал → «редактирано»),
floor по свитата форма, тоталност при не-низ. Негативен контрол: repro тестът
пада срещу старата имплементация.
…d/отрязано ехо

Две дупки в log-safety, намерени при ревю на клона:

1. stackHead приемаше, че съобщението заема само ред 0 на stack-а. V8 печата
   ЦЯЛОТО (многоредово) съобщение преди първата рамка, така че
   `.slice(1, 1 + frames)` връщаше продълженията на съобщението — нередактирани
   и без таван — точно до редактирания errorText на същия ред в route-а. Сега се
   котви на първия `    at ` ред и пази само рамки; без разпознаваема рамка (друг
   runtime, hostile getter) връща '' — fail closed.

2. errorText редактираше само дословно (whitespace-свито) копие на needle-а.
   Провайдър, който връща JSON тялото като message, ехва входа escaped (`"` →
   `\"`, нов ред → двата знака `\n`); embed пътят праща отрязан вход; и двете
   минаваха покрай split-а. Сега се търсят и JSON-escaped формата на суровия
   needle, и прозорци от 24 знака за дълъг needle (отрязано ехо се бланкира като
   един run). Plus: суровото съобщение се отрязва до 19 200 UTF-16 единици
   (surrogate-safe) ПРЕДИ O(n) паса — многомегабайтово тяло вече не струва
   стотици ms в catch блок; capCodePoints пропуска Array.from при къс вход.

Тестове: многоредово съобщение → нито ред от него в stackHead; stack без рамки
→ ''; JSON-escaped ехо и отрязано ехо → «редактирано»; дълъг needle при пре-cap
→ един run; несвързано съобщение непокътнато; surrogate на границата на
пре-cap-а. Негативен контрол: 4 от новите тестове падат срещу старата
имплементация.
… тестове през вратата на потребителя

Ревю на клона намери, че „въпросът не влиза в лога" не е вярно на три места,
въпреки errorText:

1. streamText има СОБСТВЕН default onError = console.error(error) — суровият
   APICallError с requestBodyValues (system prompt + съобщенията на потребителя)
   и responseBody. agent.ts подаваше onError само на toUIMessageStreamResponse,
   така че SDK-то продължаваше да пише суровия обект при всяка провайдърска
   грешка. Сега hook-ът е и на streamText (една редактирана линия на грешка,
   дедуп по идентичност), а UI hook-ът само решава какво вижда клиентът.
   Бонус: линията носи класа и HTTP статуса (и през RetryError) — 401 vs 429
   vs 5xx пак са различими, само идентификатори, никога тяло.

2. Route-ът: catch-ът на RAG retrieval викаше errorText(error) БЕЗ needle, а
   точно той embed-ва въпроса — най-вероятното място за ехо. Вече подава
   [question] като останалите три места.

3. bindings.ts: `'data' in out` хвърля при не-обект, а V8 слага операнда в
   съобщението на TypeError-а — gateway, върнал 200 с текст/примитив, вкарваше
   тялото в лога през „keys-only" пътя. Сега се именува само типът.

Тестове през вратата на потребителя (CLAUDE.md: „at least one test must enter
through the same door as the user"): apps/web/app/routes/assistant.chat.test.ts
кара POST-а през action() с фалшиви AI/VECTORIZE — stats линията е само
броячи, RAG-провалът с ехо е редактиран, setup-провалът дава 503 с редактирана
и само-рамки линия. agent.stream-error.test.ts кара runAssistant през реалния
streamText с MockLanguageModelV3 — точно една низова линия, никога суровият
обект. Негативни контроли: без needle-а route тестът пада; срещу старото
agent.ts console.error се вика два пъти (втория път с обекта).
Три дупки от ревюто на клона по gate-овете на отчета:

- Прозата: голият суфикс -илион хващаше „павилион(и)" — рутинен предмет на
  поръчка — и отхвърляше легитимно заглавие като несвързано число, което
  моделът не може да пренапише. Суфиксите вече са котвени към числителните
  представки (м/б/тр/квадр/квинт/секст/септ/окт/нон/дец) — затворено нагоре до
  10^33, „Илион" и „билярд" вече не флагват, „милионер" още (безопасната посока).
- emit-report-schema: `sub` на facts не се проверяваше по тип — число минаваше
  shape-а и хвърляше TypeError в bindReport (непрозрачна tool грешка към модела
  вместо retryable съобщение, и несканирана цифра). `align: null`/`link: null`
  („не е зададено" на модела) вече се приемат като отсъстващи, вместо да
  обръщат иначе валиден отчет в retry. Трикратният copy-paste на cap guard-а
  е един cappedArray helper със същите съобщения (диференциално проверени).
- bindReport: `link` се пресъздава от двете си известни полета, а не се копира
  по референция — допълнителен ключ вътре (href) иначе влизаше в замразения
  отчет въпреки „само известни полета стигат до рендера".

Тестове за всяко; негативен контрол: новите тестове падат срещу старите файлове.
JSONB близнаците в SQL денилиста (jsonb_group_*), първоначално част от този
комит, са пренесени в основата на стека (midt-bg#223), където денилистът се въвежда.
…агва само след числително

Две находки от втория преглед на клона:

- describe-schema: новият запис за `amendments` изброяваше value_before/after/
  delta като обикновени колони, без да каже, че са в `currency` (не в EUR — не
  се сумират между валути), че `value_suspect = 1` редовете носят удвоена,
  ненадеждна стойност, която самият сайт бланкира (миграция 0007), и че
  връзката към contracts е по ДВЕ колони (t.source_id = am.unp AND
  c.contract_number = am.contract_number) — само unp дава декартово
  преброяване. Точно класата SUM(amount) капан, заради която речникът
  съществува, отворена върху таблицата, която PR-ът току-що направи
  привлекателна. Записът вече носи и value_suspect, и value_restated.

- report-schema: голите стемове `млрд|млн` (добавени за „дванадесет млрд.")
  флагваха и чисто мерни заглавия като „Стойност (млн. €)" — стилът на
  собствените колони на сайта, без никакво число — и обръщаха валиден отчет
  в retry, докато „(хил. €)" минаваше. Абревиатурата флагва само след
  изписано числително (затворен клас: 1–19, десетици, стотици + няколко/
  десетки/стотици); „Сто-йност" не е „сто" (word-edge lookaround).

Тестове: двете посоки за млн/млрд (числително → флаг; само единица → не),
bindReport с header „Похарчено (млн. €)" минава.
…ции вместо позиционни за semantic_search

- reportStats ловеше само синхронен throw; `(stats) => void` приема и async
  sink, чийто reject избягваше като unhandled rejection (runtime-ът го пише
  като грешка — обратното на best-effort). Върнатият promise вече се обезврежда,
  извикването остава синхронно (старият тест за хвърлящ sink пак минава).
- Нито един тест не заковаваше `returnMetadata: 'all'`: фалшивият индекс
  връщаше metadata.text без да е поискан, така че махането на опцията оставаше
  зелено, докато реалният Vectorize (default 'none') дава text-less съвпадения,
  kept=0 и тих fallback към пълния речник на всеки ход. Двата namespace теста
  го pin-ват, а фалшивият индекс в system-prompt.test дава metadata само при
  'all' — seam-ът вече доказва и четящата страна.
- semanticSearch взимаше (topK, minScore, onStats) позиционно, докато
  сестринската retrieveSchemaContext — опции обект; единственият извикващ
  пишеше `undefined, undefined, cb`. Вече SemanticSearchOptions, огледално.
- Фикстурите на два floor теста бяха твърди 0.6/0.1 — рекалибрация (midt-bg#318) над
  0.6 щеше да ги събори за несъществуваща регресия; изведени от константата.
- tools.test: semantic_search през runTool — рендер на hit-овете, stats
  линията само броячи, needle-ът на tool-а редактира ехо на заявката.
- README/rag.ts: „редакция на текст минава без bump" подвеждаше — retrieval-ът
  връща metadata.text от индексирането, така че ВСЯКА промяна в корпуса иска
  повторно indexSchemaCorpus; bump-ът решава само нов кохорт vs. презапис.
  Таблицата на модулите вече изброява bindings.ts и log-safety.ts.

Негативни контроли: старият reportStats → 1 unhandled rejection уловен;
без returnMetadata → 3 теста падат.
…tream грешки

- „три трлн лева" се промъкваше през целия prose gate: няма цифра (за
  \d…-шаблона), няма пълнословен -илион стем и няма млн/млрд. Добавено
  към същия numeral+абревиатура клон. Само реално използваните в
  български финансов текст съкращения — измислено „квдрлн" би бил
  шаблон, който никой не пише, а пълната дума вече се лови от стема.
- Дедупът на stream грешките ползваше WeakSet, т.е. работеше само за
  обектни грешки; примитив (низ/число), видян и от двата onError hook-а,
  се логваше двойно. Примитивите вече се дедупват по вече-редактирания
  текст, а обектите остават по идентичност (две различни грешки с еднакъв
  текст заслужават по ред). Логиката е изнесена в makeStreamErrorLogger,
  за да е тествана без реален стрийм — 4 теста, вкл. редакцията.

Бележки от ревюто на @ydimitrof по midt-bg#321.
…еният списък числителни течеше

Клонът „числително + абревиатура" пазеше със ЗАТВОРЕН списък кардинални
числителни. Обикновена българска финансова проза минаваше покрай него и
замразяваше необвързана сума в публичния отчет: „два и половина млрд. лева"
(думата пред единицата е „половина"), „двайсет млн.", „трийсет млрд.",
„стотина млн.", „десетина млн.", „четвърт млрд.", „дузина млн.",
„няколкостотин млн." — всички връщаха ok:true през bindReport. Точно
„12 млрд."-векторът, за който gate-ът съществува, с изписано число.

Правилото вече е обърнато: абревиатурата флагва след ВСЯКА дума, освен след
предлог за мерна единица (в/във/на/по/от/до/за/към/при/с/със/и/или —
„Стойност в млн. лв.", „изразени във млрд."). Гол остава само след
пунктуация/начало на ред — „Стойност (млн. €)", „Похарчено, млрд. лв." —
стилът на собствените ни колони. „Стойност млн. €" (съществително директно
пред единицата) вече флагва: без пълен речник на числителните е неразличимо
от „стотина млн.", а безопасната посока е over-flag (моделът слага единицата
в скоби).

„трлн" влиза и в цифровия клон — „12 трлн. лева" / „1,5 трлн" (формата, която
моделът най-често пише) минаваше целия gate, макар „три трлн лева" да се
хващаше (ревю на ydimitrof по абревиатурния клон).

Тестове: десетте формулировки + bindReport с текстов блок; предлозните и
скобените форми остават чисти; негативен контрол: и трите променени/нови
теста падат срещу стария regex. (независим преглед след ревюто на midt-bg#321)
…з pre-cap-а

Фикстурата беше 18 105 UTF-16 единици при праг MAX_RAW_CHARS = 19 200, така
че preCap() връщаше суровото съобщение непроменено и тестът „Pre-cap
regression guard" никога не влизаше в пътя, който коментарът му твърди, че
пази — регресия в реда pre-cap/collapse/redact щеше да остане зелена.

Фикстурата вече е ~21 100 единици, MAX_RAW_CHARS е експортнат и тестът
заковава `question.length > MAX_RAW_CHARS`, за да не може да се смъкне тихо
под прага отново. Очакваният изход остава същият: една редактирана линия
„err: «редактирано»". (независим преглед след ревюто на midt-bg#321)
…i cap-а, ok пътя на emit_report и отказите на route-а

Клонове от кода в този PR, които суитът не докосваше след пребазирането
върху новия coverage baseline на main (midt-bg#254):
- errorTag: RetryError, който обвива НЕ-APICallError — тагът е само причината,
  без статус;
- log-safety: needle между MIN_REDACT_CHARS и REDACT_WINDOW се редактира по
  точен match, а съобщение с повече UTF-16 единици от cap-а, но по-малко кодови
  точки, не се реже;
- agent wiring: валиден emit_report минава по ok клона и връща вързаната справка;
- route door: и петте отказа на входа (обявен/реален над-cap body, невалиден
  JSON, нула ходове след филтъра на ролите, един гигантски ход) връщат
  4xx, без да стигат до модела. Content-Length се подава през незащитен
  Headers обект, защото истинският Request го изпуска.
…ната към типа

Изричното изграждане на колоната в bindReport (вместо `{ ...c }`) копира
фиксиран списък полета. Ако EmitTableColumn някога получи ново незадължително
поле, старият код би го изпуснал тихо — без TS грешка (ревю на midt-bg#330,
ydimitrof). REBUILT_COLUMN_KEYS/REBUILT_LINK_KEYS са `Record<keyof …, true>`
обекти: липсващо или излишно свойство е компилационна грешка, така че
списъкът не може да изостане от типа. Тестът закова и runtime страната —
напълно зададена колона излиза с точно тези ключове, а непознат ключ (вкл.
`href` вътре в `link`) не оцелява. Негативен контрол: `width?: number`,
добавено към интерфейса, чупи `tsc -b` на пина.
nedda76 added a commit to nedda76/sigma that referenced this pull request Sep 5, 2026
…рифт-пазач)

Речникът, който моделът чете (describe-schema.ts), дрифтна тихо веднъж:
amendments.contract_id никога не е съществувала, а parties.role — също, и
двете се откриха само на око при ревюто на midt-bg#321. Всяка фантомна колона там
влиза във всеки prompt, а полученият SQL пада в runtime с грешка, която
моделът „поправя" с ново налучкване.

Тестът прилага ВСИЧКИ packages/db/migrations/*.sql поред върху временна
sqlite3 база (same harness като packages/db/src/migrations.test.ts) и
проверява:
- всяка таблица от речника съществува;
- всеки водещ идентификатор от всеки columns низ е реална колона
  (pragma_table_info; скобените бележки със запетаи/кавички се игнорират);
- всяка →таблица референция е реална таблица;
- всяка канонична заявка КОМПИЛИРА срещу схемата (EXPLAIN) — тях моделът
  копира дословно като отправна точка;
- негативни контроли: историческият дрифт ('id, contract_id→contracts, …')
  се хваща, а parser-ът не се лъже от бележки в скоби.

Проверено срещу main-версията на речника: пазачът докладва точно
amendments.contract_id и parties.role. Понеже поправката им живее в midt-bg#321,
клонът е стакнат върху него (мърдж ред: midt-bg#321 → този).

Тестът живее в apps/web/test/ и е включен в tsconfig.node.json (Node типове
за child_process), за да не се наливат Node globals в Workers кода на
приложението; vitest include покрива test/**.
@nedda76
nedda76 force-pushed the fix/assistant-rag-observability branch from 42e1362 to d9e75f2 Compare September 5, 2026 08:46
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.

3 participants