name: enrich-open-calls description: Read an ИСУН procedure's own document and extract its budget, aid rate, grant range and eligibility into open_calls — through a deterministic quote-in-source gate and a human sign-off. Use when the user asks to enrich / fill in the money on open calls (отворени процедури, бюджет на процедура, допустими кандидати), or after update-open-calls reports new GUIDs. NOT part of the daily refresh: it spends tokens per document and must never publish a figure unreviewed. The crawl+load half is update-open-calls. allowed-tools:
- Read
- Bash
- Edit
- Write
Enrich open calls skill
update-open-calls gives every procedure a title, a deadline and a link. It cannot give it a
budget, an aid rate, a grant range or who may apply — ИСУН publishes none of
those in its listing. They exist only inside each procedure's own PDF/DOCX.
This skill reads that document and proposes those values. It is deliberately separate from the crawl (plan §8.3.7): crawl+load is mechanical and safe on a cron, while reading a 52-page „Условия за кандидатстване" costs tokens per document and must not publish a figure without human sign-off. Keeping them apart means the daily refresh can never quietly ship an unreviewed number.
Read this first — what you will actually get
Measured 2026-08-09, six live ИСУН procedures:
| what happened | n | note |
|---|---|---|
| readable text (DOCX or PDF) | 4 | |
| stated a EURO amount | 0 | |
| stated a LEV amount | 3 | e.g. 91 072 240 лв. |
| stated no amount at all | 1 | |
| ZIP archive — skipped with a reason | 1 | „Насоки за кандидатстване" |
| legacy .doc (OLE2) — skipped with a reason | 1 | „Покана" |
So: on today's corpus the money yield is approximately zero, and that is the gate working, not failing. Bulgaria adopted the euro on 2026-01-01 and ИСУН's documents have not been re-tabled; the currency rule below correctly rejects every lev budget offered as euro. What you will get is eligibility text — the verbatim „кой може да кандидатства" sentence, which is the question readers actually ask — and that is worth the run on its own.
Do not respond to a rejected budget by converting the lev figure yourself. That is a new number you invented, not a number the document states. Omit it.
The pipeline
1. See what needs work
npm run opencalls:enrich -- --list
Lists every ИСУН row at enrichment='none' with at least one document, in deadline order, and
names the document it would pick. [NO USABLE DOC] means the procedure publishes only annexes
— skip it, there is nothing to read.
Selection is a preference order, not a single label: Обява › Покана › Условия за кандидатстване › Насоки за кандидатстване › Заповед. The plan named only „Обява"; measured
over the 55 live procedures it exists on 7, while „Условия за кандидатстване" is on 26, so a
single-label rule would leave four fifths of the corpus untouched.
2. Fetch one procedure's document
npm run opencalls:enrich -- --key <source_key>
Downloads it, extracts the text, and writes a worksheet to
scratch/opencalls/<key>.md (gitignored). Three things are handled for you and none of them
were obvious — see scripts/opencalls/enrich_fetch.ts for the probe that established them:
- the
InfoDownload?fileKey=URL is a 302 to a signed, short-lived blob URL; Content-Typeis alwaysapplication/octet-stream, so the format comes from the magic bytes;- an archive or a legacy
.docis reported as a skip-with-reason, never a crash. If you get one, move to the next procedure.
3. Read the worksheet and extract
Read the file. Its „## Document text" section is the exact string the gate will check your
quotes against — search within it. Write your answer as JSON to
scratch/opencalls/<key>.json, using the skeleton the worksheet prints.
Five rules, all enforced mechanically in the next step:
- Every field needs a verbatim quote — a span you can find by searching the worksheet, not a summary of one. A field you cannot quote is omitted, not guessed. Omitting is the normal outcome for most fields on most documents.
- The quote must STATE the value. This is a separate check from the one above and it is the
one that catches a figure recalled rather than read: a real sentence with a fabricated number
attached, or the same digits at the wrong scale (
0.6cited from „…60 %…"). The gate parses the numbers out of your quote and compares. Forbeneficiaries, the extracted phrase must appear in its own quote. - Quote the sentence, not the number. Under 12 characters is rejected outright.
- Every
_eurfield needs a quote that is in EURO. See the table above: this will usually mean omitting the budget. A quote naming no currency is accepted but flagged — it is where a lev figure most easily slips through on this corpus. audienceis derived from the eligibility text, so it is dropped ifbeneficiariesis. The worksheet prints the allowed values; they come fromAUDIENCESinscripts/opencalls/types.ts.
4. Gate and store
npm run opencalls:enrich-review # gate every proposal, print the queue
npm run opencalls:enrich-review -- --apply # … and store the survivors at enrichment='auto'
scripts/opencalls/enrich_gate.ts re-checks both halves: the quote is a plain
whitespace-normalised substring of the document text, and the quote states the value. A field
that fails either is dropped and reported — this is the mechanical half of
project_ai_chat_grounding_gate, and the guarantee comes from the check, not from trusting the
model. What neither half can do is judge whether the quoted sentence is the right one: a
sub-component's „максимален размер" cited against the whole procedure's budget is a real number,
correctly attributed, and still wrong. That is what step 5 is for.
A proposal where nothing survives is left at enrichment='none' — it stays in the queue. The
queue is 'none', so writing 'auto' on an empty extraction would retire the procedure for
ever with no value stored and no path back. Two proposals are also refused before the gate: one
whose JSON does not parse (reported, the rest of the queue still runs), and one whose
source_key disagrees with its filename — which would otherwise apply this document's figures to
a different procedure, with every quote still grounded.
--apply still publishes no number. Per invariant 8, enrichment='auto' may write the
verbatim eligibility text and the provenance blob ({model, extracted_at, doc_url, quotes, values, rejected}) and nothing else. The four money columns stay NULL, enforced by 142's
open_calls_money_needs_provenance CHECK. audience is also held back — ИСУН rows already
carry one derived from the title at crawl time, and overwriting a source-derived facet value
with an unreviewed inference would be a downgrade disguised as an enrichment.
5. Human sign-off
npm run opencalls:enrich-review -- --promote <source_key>
Prints the proposed values with their quotes, then promotes that ONE row to
enrichment='reviewed', which is what lets a figure into budget_eur & co. — and therefore
into sorting, range filters and the tile's „€X общ бюджет".
A person must read the quotes before running this. There is deliberately no
--promote-all: a flag that signs off fifty-five rows at once is not a human gate, and the
whole point of Stage 7 is that a figure reaches the site because someone read its evidence.
Promotion re-runs the value check against the quote stored beside each figure, because the
meta is not a trusted store — it can be hand-edited and it can predate a change to the gate. A
value with no stored quote is refused here even though it survived at --apply time.
Promoting a row whose only surviving field is the eligibility text is fine and common — it records „I read it, the document states no figures" and stops the row cycling through the queue for ever. The command says so rather than claiming columns went live.
To put a row back in the queue (a re-issued document, a bad extraction), reset it — there is no command for this because it should be rare and deliberate:
UPDATE open_calls SET enrichment = 'none', enrichment_meta = '{}'::jsonb
WHERE source = 'isun' AND source_key = '<key>';
Note this does not clear beneficiaries_raw; the loader treats that column as
fill-never-blank, so clear it in the same statement if the old text was wrong.
6. Publish
Nothing here reaches prod on its own:
npm run db:load:open-calls:pg:cloud
But note the loader ships data/opencalls/*.json, and enrichment lives only in Postgres —
it is not in those snapshots. So enrichment done locally does not travel. Enrich against the
database you intend to serve, or re-run the skill against Cloud SQL:
DATABASE_URL=postgres://postgres@127.0.0.1:5434/electionsbg npm run opencalls:enrich -- --list
The one rule that must not bend
Never relax a check to make an extraction pass. If a real quote fails, the normaliser is
wrong and the normaliser is what changes — not the threshold, and never the direction of the
comparison. scripts/opencalls/enrich_gate.test.ts is adversarial on purpose and every check in
it has been mutation-tested: deliberately breaking any one of the six guarantees turns it red.
What this skill does NOT do
- It does not crawl. New procedures arrive via
update-open-calls. Run that first. - It does not convert currencies. A lev figure is not a euro figure, and the conversion would be a number the document does not state.
- It does not touch
sp2023(ДФЗ) rows. Their indicative windows carry no per-procedure document to read. - It does not run unattended. If you find yourself wanting to loop it over all 55 procedures and promote the results, re-read the table at the top: the gate would reject nearly every budget anyway, and the ones it accepted would be the ones nobody checked.
Prompt Engineering
Data & AI
Prompt engineering best practices and templates to maximize AI outputs.
Data Visualization
Data & AI
Generates data visualizations and charts tailored to your data.
RAG Architecture Setup
Data & AI
Setup guide for RAG (Retrieval-Augmented Generation) architectures.