feat(assistant): самопровизиониране на schema-v2 корпуса + GET /assistant/health като доказателство за CD - #347
Open
nedda76 wants to merge 50 commits into
Conversation
The curated dictionary the model treats as hard fact had drifted from packages/db/migrations/0000_init.sql: - amendments: no contract_id column — it links via unp/contract_number - parties: no role column — real cols are party_key, eik, ocid, party_id, name… - value_flag enum was missing value_low - amount_eur IS NULL was described as meaning value_suspect; it actually has several causes (FX-rateless foreign / value_suspect w/o estimate / no signing+current), and the unconfirmed count is value_flag='value_suspect' (home_totals.suspect), not NULL-amount rows - data_freshness is a table, not a view Drift here misleads a weak model into wrong joins or a wrong integrity KPI.
…e retrieval Two grounding gaps that could leave a RAG turn LESS constrained than the no-RAG fallback: - buildSystemPrompt used the retrieved chunks INSTEAD of the dictionary, so a retrieval that missed the money-sum trap dropped the SUM(amount_eur) rule entirely. Inject the short imperative DATA_TRAPS unconditionally; RAG now only selects the extra tables/example-queries for the question. - retrieveSchemaContext had no relevance floor — top-K returned its K least-distant chunks even when all were off-topic. Add MIN_SCHEMA_SCORE; below it we return fewer/zero chunks, and zero falls back to the full dictionary (the safe outcome).
group_concat / json_group_array / json_group_object collapse an entire full-table scan into one huge cell that materialises in Worker memory before capRows can measure it (and capRows keeps the first row whole) — the same memory-amplification class already blocked for printf/format/randomblob, one level up. Add them to the scalar blocklist.
- Prose number-gate missed трилион/билион/квадрилион: '3 трилиона лева' slipped the whole gate (the digit can't reach 'лева' across the Cyrillic word), an unbound order-up figure on a public report — the '12 млрд.' vector one magnitude higher. Add them to the spelled-magnitude stem. - Validate the optional column align against a left|right whitelist, and build resolved table columns explicitly instead of spreading the model object, so no unknown/unvalidated property reaches the renderer. - Cap model-emitted array lengths (blocks, items, columns) in validateEmitShape.
…h prompt paths
renderTraps() now owns the numbered-list rendering that describeSchema (full
dictionary) and the RAG hard-traps block duplicated, so the two paths cannot
drift, and the full-dictionary heading is harmonised to match the RAG block
("Задължителни правила за данните"). No behaviour change — string assembly only.
retrieveSchemaContext relied on `m.score` always being numeric. If an index backend ever returns a match without a `score`, the comparison was falsy and the chunk was dropped — the correct, safe outcome, but only incidentally. Make it explicit with `(m.score ?? 0) >= minScore` and a comment so a future refactor can't strip the guard, and cover it with a test. Addresses the review note on rag.ts robustness (ydimitrof).
…vers квинтилион+) The prose-number gate listed magnitudes explicitly and stopped at квадрилион, so "3 квинтилиона лева" slipped. Match the shared suffixes instead — милион⊃"илион", милиард⊃"илиард" — which covers the whole family (милион…секстилион…, милиард…) and closes the row upward for good rather than chasing an endless list. Addresses the review note on report-schema.ts (ydimitrof).
… the SQL guard string_agg(X, sep) is the official SQLite 3.44 synonym of group_concat and reaches the same code path on D1's modern SQLite, so it bypassed the scalar/aggregate denylist and achieved the same memory amplification (whole scan into one cell before capRows) the guard just closed for group_concat. Add it to the regex and the adversarial test. Addresses the review note on sql-guard.ts (ydimitrof).
An over-cap blocks/items/columns array is exactly the unbounded structure the ceilings guard against, yet validateEmitShape recorded the length error and then walked the whole array anyway — doing the very scan the cap exists to refuse. Return before the per-block scan on oversized blocks, and skip the per-element scan on oversized items/columns. Behaviour is unchanged for valid reports (ok:false either way); this only stops the wasted walk. Test asserts a single cap error with no per-element errors, proving the array is not scanned. Addresses lyubomir-bozhinov's review note on PR midt-bg#223.
…d RAG retrieval DATA_TRAPS are injected into the system prompt unconditionally (hardTraps), so indexing them in the schema corpus let retrieval hand the same rule back as "context" and render it twice. Traps are no longer indexed, and retrieveSchemaContext drops kind:'trap' matches a previously deployed index may still hold. Retrieval's job stays selecting relevant tables/queries. (review note, ydimitrof)
…ace instead of a runtime trap filter Self-review of the previous commit found the client-side kind:'trap' filter ran AFTER Vectorize's server-side topK cut, so legacy trap vectors (12 of ~37 in a pre-change index, and the most money-question-similar text in the corpus) could eat up to all six retrieval slots — leaving the turn with fewer tables/queries than the no-RAG fallback, silently and permanently, since upsert never deletes the stale ids. Replaced with a versioned NATIVE namespace (SCHEMA_NS = 'schema-v2') on both the upserted vectors and the query: native namespaces need no metadata index and exclude every stale cohort at the source, so no topK slot is ever spent on a discarded match and the filter is gone. The version is in the vector ids too, so re-indexing writes a new cohort and a Worker rollback keeps working against the old one. An un-reindexed environment gets zero matches → the documented full-dictionary fallback. Also from the self-review: the stale module header still said trap-rules are embedded; system-prompt tests fed trap strings retrieval can no longer produce; and no test entered through the composed seam — added a retrieveSchemaContext → buildSystemPrompt test seeded with the real corpus asserting every DATA_TRAP renders exactly once (negative-controlled: re-adding traps under a disguised id/kind fails it and the new corpus-length assertion). README provisioning now documents the re-index-on-bump requirement.
Gap-sweep on the namespace fix found the composed exactly-once test was weaker than advertised: it hand-mirrored the write mapping instead of running indexSchemaCorpus, sliced only the first topK chunks (so a trap appended at the corpus tail escaped it), and hard-coded a 0.9 score silently coupled to MIN_SCHEMA_SCORE. It now routes through the real write path into a recording fake, retrieves the WHOLE corpus, and derives its score from the floor — so the write→read metadata contract (text key, ids, namespace) is under test and a trap re-added at any position under any id/kind fails it (negative-controlled again with a tail-appended, table-kind trap). Also: remaining fixtures moved off pre-v2 unversioned ids; the semanticSearch test title no longer claims a namespace it does not use (it pins the entity METADATA filter); the module header no longer claims the bindings satisfy the structural types (the route casts — drift is not tsc-checked); the new-cohort rollback guarantee is now correctly stated as bump-only, with an explicit WHEN TO BUMP rule (in-place upserts, positional query ids, orphan risk); the README no longer suggests purging a cohort inside its rollback window and notes delete-vectors needs an explicit id list; dropped the stale '150 теста' verification claim.
… величините
Единствените near-collisions на суфиксния шаблон са думи на -лион
(напр. „Илион") — приети съзнателно: gate-ът нарочно флагва в повече,
а в регистъра на поръчките такива думи почти не се срещат. Записан е
изходът при евентуални фалшиви отхвърляния: \p{L} lookaround граница
(JS \b е ASCII-only), а не списък с изключения. Изброяването на
-илиард величините е сведено до реалните форми на „милиард"
(бележка от ревюто).
… gate-а „Дванадесет млрд. лева" нямаше нито цифра (за \d…млрд шаблона), нито пълнословен суфикс — изписано числително + абревиатура се промъкваше покрай целия gate. млрд/млн влизат в стем шаблона (флагват и без цифра; негативен контрол: тестът пада без промяната). Остатъкът „хил." без цифра остава приет — хилядите не са defamation-мащабният вектор (бележка от ревюто на midt-bg#320).
…enylist SQLite (D1) резолва "group_concat"(x), [group_concat](x) и `group_concat`(x) до същия built-in, а регексът изискваше голо име непосредствено пред скобата — цитиран идентификатор минаваше L1. Опционален quote клас след името затваря и трите форми (adversarial тестове; негативен контрол: падат без промяната). Идентификатор с padding в кавичките е РАЗЛИЧЕН за SQLite и не резолва built-in — не изисква обработка (бележка от ревюто).
…наваха и двата guard-а Денилистът изброяваше json_group_array/json_group_object, но не и JSONB вариантите им jsonb_group_array/jsonb_group_object (SQLite ≥3.45, в build-а на workerd). Буквалът `json_group_array` не е подниз на `jsonb_group_array`, така че регексът не хващаше, а AST guard-ът гледа само FROM-източници, LIMIT и дублирани колони — не функциите в SELECT-листата. `SELECT jsonb_group_array( name) FROM bidders` минаваше и двата слоя и колабираше цялата таблица в една JSONB клетка ПРЕДИ capRows — точно класът memory-amplification, който денилистът цели (midt-bg#227). Регексът вече е `jsonb?_group_(?:array|object)` — покрива и двете форми, включително цитираните идентификатори през същия quote клас. Тестът добавя голия и цитирания JSONB вариант; негативен контрол: новите случаи падат срещу стария регекс. Поправката живееше само на върха на стека (91d175c в midt-bg#321); пренесена е в основата, където денилистът се въвежда (ревю на midt-bg#223, lyubomir-bozhinov).
…истът пази и на AST ниво
Скенерите stripComments/splitStatements моделираха само '…' литерали. Един `'`
вътре в двойно-кавичен alias (`AS "x'y"`) ги обръщаше в „в низ" до края на
заявката: следващ `/**/` или `--` оцеляваше дословно, `group_concat/**/(x)`
минаваше функционалния regex (който допуска само whitespace преди скобата), а
SQLite чете коментара като whitespace и изпълнява агрегата. AST guard-ът не
гледаше имена на функции, така че и двата слоя пропускаха — включително
printf/randomblob и новите jsonb_group_*. Възпроизведено срещу sqlite3 3.51:
`SELECT 1 AS "x'y", group_concat/**/(subject, '') FROM tenders` се изпълнява.
- sql-guard.ts: и четирите форми на кавички на SQLite ('…', "…", `…`, […])
са непрозрачни спанове и за двата скенера (удвоен затварящ знак = escape,
`]` няма escape); незатворен спан тече до края и AST слоят фейлва CLOSED.
Денилистът е ЕДНА дефиниция (DENIED_FUNCTION_NAME), споделена с AST слоя.
- sql-ast-guard.ts: обхожда парснатото дърво на всяка дълбочина (аргументи,
WHERE, под-заявки, CTE тела) и отхвърля денилистваните имена по
РЕЗОЛВНАТОТО име на извикването — коментари, кавички и регистър вече са
премахнати от парсера, така че лексикален трик не може да скрие име.
Непозната форма на име → fail closed. Формите са снети от реалния
node-sql-parser 5.4 (aggr_func с низ; function с name.name[].value).
Тестове: шестте bypass формулировки падат на L1; L2 отхвърля същите подадени
ДИРЕКТНО (без L1), вкл. вложени в аргумент и в под-заявка; позитивен контрол
за обичайните скаларни/агрегатни функции; идентификатори с `--`, `/* */`,
`;` и удвоена кавичка остават данни. Негативен контрол: и трите нови теста
падат срещу стария код. (независим преглед след ревюто на midt-bg#223)
…спан и непозната форма на име Двата пътя, по които новите скенер и AST проверка фейлват CLOSED, нямаха тест: незатворен кавичен спан (тече до края на входа; `;` вътре не разделя, но keyword блоклистът пак чете текста, а безобиден остатък пада на парсера) и call node с форма на име, която callName не разпознава (никакъв SQL текст не я произвежда от парсера — затова denyDeniedFunction е експортната и се проверява с конструиран възел). Покрива и обхождането на масиви/вложени обекти и резолването до lower-case име.
…инг литералите Регексите на първия слой (ключови думи, pragma_, TVF, каталожни таблици, функционалният денилист) вървяха върху стрипнатия SQL, в който стринг литералите са дословни — така заявка, която само ТЪРСИ текст с име на функция или ключова дума (`WHERE subject LIKE '%group_concat(%'`, `'%DROP TABLE%'`), се отхвърляше фалшиво, и то само от този слой: AST слоят отказва единствено реални извиквания (ревю на midt-bg#321, ydimitrof). Проверките вече четат копие, в което всеки единично-кавичен литерал е сведен до `''` (blankStringLiterals, върху същия quotedSpanEnd скенер). Кавичните ИДЕНТИФИКАТОРИ ("…", `…`, […]) остават видими нарочно — SQLite резолва `"group_concat"(x)` до вградената функция и името трябва да се види. Върнатото изпълнимо изявление е истинското, с непокътнати литерали. Тест: четирите LIKE/= форми минават и двата слоя с непроменен SQL; същото име извън литерал (вкл. до литерал и в кавичена форма) остава отказано. Негативен контрол: новият тест пада срещу стария код. Единственото място, където единично-кавичен токен НЕ е данни, е позицията на таблица: граматиката на SQLite има `nm ::= id | STRING`, така че `FROM 'sqlite_master'` чете реалния каталог, а бланкирането би заслепило каталожния/pragma_/TVF backstop за този правопис. Затова всеки кавичен токен след FROM/JOIN (вкл. schema-квалифициран) се отказва изрично на L1 — AST allowlist-ът го отказва и без това, но не бива да е единственият слой. Имената на функции са само `id` (`'printf'(x)` е синтактична грешка), така че функционалният регекс не губи нищо. Тест за шестте форми + позитивен контрол за литерал, който сам съдържа „from 'x'".
midt-bg#317) Vectorize зачита metadata филтри само върху свойства с провизиран metadata index, а репото не провизира нито един — filter: { ns: 'entity' } на реален индекс греши или под-филтрира, и то тихо, защото извикващите поглъщат грешките. Native namespace-ът (entity-v1, версиониран като SCHEMA_NS) не изисква metadata index и се прилага преди всякакви филтри. Това беше последната употреба на metadata filter в модула. Entity корпус никога не е индексиран, така че няма legacy кохорт — бъдещият indexer трябва да upsert-ва с namespace: ENTITY_NS (README, „Какво остава"). Closes midt-bg#317
- Header-ът вече не твърди, че FTS инструментът search_entities съществува (само спецификация е) — semantic_search днес връща 0 попадения по дизайн, докато entity корпусът не се индексира. - ENTITY_NS коментарът и README вече НЕ пренасят правилото WHEN TO BUMP върху entity корпуса: то предполага ръчен append-only корпус, а entity корпусът е производен от данните — indexer-ът се нуждае от собствен reconciliation/delete път и трябва да пази id-тата си. - metadata.ns е маркиран изрично като форензично поле — НЕ филтруемо (няма metadata index); скоупингът е само през native namespace. - semanticSearch деградира match без score до 0 (същата защита като флора на retrieveSchemaContext) вместо TypeError в tools.ts; тест. - Тестовете за namespace коват и БРОЯ на заявките (toHaveBeenCalledTimes (1)) — иначе filter-базиран retry път би минал зелен.
…ема пътя Без флор, щом 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` на пина.
…рифт-пазач) Речникът, който моделът чете (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/**.
… + дрифт-пазачът да не пропуска цитирана колона - redact вече се строи от userTexts(opts.messages) — всички user ходове. Провайдърска грешка може да цитира която и да е част от prompt-а, а multi-turn prompt носи и по-ранните въпроси; редакция само на ctx.userQuestion ги оставяше нередактирани в tail лога. userTexts е експортнат и тестван (вкл. че игнорира не-user роли и празни текстове; негативен контрол: 'само първия' чупи теста). - Дрифт-пазачът сваля SQL кавички преди да чете идентификатора: цитирана колона на топ ниво („col", [col], ) досега тихо се пропускаше и не се сверяваше срещу реалната схема — guard, който фейлва ОТВОРЕНО. Днес речникът не цитира; така остава затворен, ако някога го направи. - format НЕ става условен: validateEmitShape изисква isFormat(c.format) за всяка колона, а EmitTableColumn го типизира задължителен, така че 'format: undefined' е недостижим — предпоставката на бележката не важи. Записано като коментар на самото място, за да не се пита пак. Бележки от ревюто на @ydimitrof по midt-bg#330.
Класът на идентификатора беше само ASCII (`[A-Za-z_]`), така че колона с кирилско име щеше да се филтрира ПРЕДИ проверката срещу схемата — пазачът да фейлва отворено точно за колоната, която трябва да хване (ревю на midt-bg#330, ydimitrof). SQLite допуска такива идентификатори. Класът вече е `\p{L}`/`\p{N}` с `u` флаг, симетрично и за →референциите. Негативен контрол: кирилска фантомна колона се докладва срещу served схемата; извлеченото от днешния речник не се променя (проверено).
…tant/health Нищо в репото не можеше да напълни `schema-v2` (midt-bg#328): indexSchemaCorpus съществуваше, но без изпълним вход, така че всяка среда беше на full-dictionary fallback, а деплой без индексиран корпус беше безшумен на ниво CD (midt-bg#346). - rag.ts: `ensureSchemaCorpus` — един `getByIds` на очакваните id-та (`schemaVectorId`, една дефиниция за писача и проверката), брои `present` (в SCHEMA_NS, с текста на ТОЗИ build), `stale` (текст на стар build — редакция, която не е преиндексирана) и при всяка липса пуска indexSchemaCorpus, най-много веднъж на изолат (memo по namespace; провалено пускане се забравя веднага, за да не отрови изолата). Отчита какво е било ЧЕТИМО преди записа — Vectorize прилага записите асинхронно, затова доказателството е повторно четене. Резолвнало, но нечетимо пускане (приет, но неприложен запис) се опитва отново след RETRY_INDEXING_AFTER_MS (10 мин.) — една повторна партида на прозорец, не завинаги `upserted: 0`. Лека форма на четене (без namespace/metadata) деградира до присъствие по версионираното id; различен namespace/текст, когато се докладва, брои срещу корпуса; броячът `lean` казва колко са преброени само по id, за да не стане `stale` тих no-op при binding без metadata. `VectorIndex` получава `getByIds` — типизирано структурно, присвояването на VECTORIZE в route-а остава компилационното доказателство (midt-bg#316). - routes/assistant.health.ts: GET, само броячи ({ns, expected, present, stale, upserted}), `Cache-Control: no-store`; 200 само при пълен и актуален корпус, иначе 503 със същите броячи; без bindings — 503 „unprovisioned"; грешка на провайдъра — 503 „unavailable" и errorText в лога, никога съобщението в отговора. Под същия per-IP limiter като чата (assistant-rate-limit.ts), всеки метод. - routes/assistant.chat.tsx: проверката преди retrieval, best-effort — провал се логва (въпросът редактиран) и ходът продължава; броячите се логват само когато нещо липсва или е записано, за да не шуми steady state. Тестове: unit за ensureSchemaCorpus (пълен корпус без запис; липсващ → един upsert, втори опит при забавено четене не преембедва; чужд namespace/стар текст броят срещу корпуса, вкл. чужд namespace ПРЕДИ верния и дубликати; лека форма → по id с `lean`; резолвнало пускане се повтаря след прозореца, не преди; провал → следващото обаждане опитва пак), route-door за health (200 броячи-only, 503 при провизиониране, unprovisioned/unavailable без ехо) и за чата (студен индекс се провизионира на хода, провал на проверката не пречи на хода и е редактиран), limiter за health. Съществуващите фалшиви индекси получават `getByIds`.
deploy.yml: стъпка „Verify assistant schema corpus" след deploy на explorer-а — извиква GET /assistant/health с retry ~2 мин. (Vectorize прилага записите асинхронно) и пада при разминаване. Активира се с Environment променливата SIGMA_WEB_URL и Access service token (CF_ACCESS_CLIENT_ID/SECRET) — двете среди са зад Access; без SIGMA_WEB_URL стъпката се пропуска с notice, за да не зависи deploy-ът от конфигурация, която средата няма. Печата само HTTP статус и до 300 знака от тялото (броячи), никога цяла страница. Последна в job-а нарочно: червена проверка не бива да остави explorer-а и ETL Workflow-а на различни версии или да пропусне LOG_IP_KEY инициализацията. README на асистента: provisioning gate-ът вече не сочи гол TS израз, а самопровизионирането и health route-а; ре-индексирането при bump/редакция става само (stale броячът). docs/deploy.md §3/§5: опционалните променливи и верификацията. wrangler.jsonc: бележката към deploy gate-а.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #328. Реф #346 (Worker-страната на гаранцията; CI-страната остава решение на собственика на акаунта — виж „Какво НЕ прави").
Какво
Worker-ът провизионира схема-корпуса сам и доказва това през
GET /assistant/health; deploy стъпка го проверява след deploy, когато средата има нужната конфигурация.ensureSchemaCorpus(rag.ts) — единgetByIdsна очакваните версионирани id-та (schemaVectorId: една дефиниция за писача и проверката), броиpresent(налични вschema-v2с текста на този build),stale(текст на стар build — редакция, която никога не е преиндексирана) и при всяка липса пускаindexSchemaCorpus— най-много веднъж на изолат (memo по namespace; провалено пускане се забравя веднага; резолвнало, но нечетимо се повтаря след 10 мин., една партида на прозорец).leanброи четенията без записан текст (преброени само по версионираното id), за да не станеstaleтих no-op. Отчита какво е било четимо преди записа: Vectorize прилага записите асинхронно, така че доказателството е повторно четене.GET /assistant/health— само броячи{ns, expected, present, stale, lean, upserted},no-store;200само при пълен и актуален корпус, иначе503със същите броячи (различимо „празен" от „наполовина"); без bindings →503 unprovisioned; провал на провайдъра →503 unavailable+errorTextв лога, никога съобщението в отговора. Под същия per-IP limiter като чата, всеки метод.deploy.yml— стъпка „Verify assistant schema corpus", последна в job-а (червена проверка не оставя explorer и ETL на различни версии): retry ~2 мин., пада при разминаване. Активира се сSIGMA_WEB_URL(Environment променлива) + Access service token (CF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET); безSIGMA_WEB_URLсе пропуска с notice.docs/deploy.md§3/§5, бележката вwrangler.jsonc.Защо така
AIиVECTORIZE; корпусът е 25 детерминистични, идемпотентни upsert-а. CI-страна проверка/индексиране би искала Vectorize + Workers AI права на deploy token-а, които документираните минимални scope-ове нямат и които не можем да променим (assistant: CD гаранция, че schema-v2 корпусът е индексиран — health check след deploy (и кой може да го направи) #346).indexSchemaCorpusпри всяка редакция на текст; сегаstaleго хваща и Worker-ът преиндексира сам.Какво НЕ прави
entity-v1(entity indexer-ът е Фаза 2); health route-ът докладва самоschema-v2.getByIds(namespace/metadataв отговора) е по генериранияworker-configuration.d.ts, не по заснет payload — нямаме Cloudflare credential. Затова проверката деградира безопасно: четене безnamespace/metadataброи по версионираното id (самоindexSchemaCorpusпише такива id-та); ако binding-ът върне различна форма, симптомът е видим веднага на staging (presentв броячите), не тих.Стак
Стъпва върху #330 (→ #321 → #320 → #319 → #223). За ревю са последните 2 комита; мърдж ред: #223 → … → #330 → този.
Проверено
tsc -bчист (вкл. typegen на новия route), пълният пакет наapps/web+ coverage ratchet, Prettier чист; негативни контроли в тестовете: втори опит при забавено четене не преембедва, провалено пускане се забравя,href/непознат ключ и текст на стар build броят срещу корпуса.