A Public Procurement Check with a Local AI
We build a preparation assistant for a team that prepares a procurement under the Bulgarian Public Procurement Act (ЗОП). It compares the estimated value with thresholds that you fill in from the official text, finds the related articles in the law and returns a list of review points with citations. A human decides — a procurement specialist or a lawyer.
pgvector/pgvector:pg18 has an arm64 image), vectors with bge-m3 through Ollama (/api/embed), structured model output, a deterministic output check (the cited article must be among the retrieved ones; no advice wording), the database address through an environment variable and a server on 127.0.0.1 only.
bge-m3 and llama3.1, the vector search and the whole chain with a real law. Not checked: thresholds, procedure types, deadlines and article numbers of the Act. According to the llama3.1 model card, Bulgarian is not among its officially supported languages — the model is only an example; swap it and measure. We have not measured how well the chain finds problems.01What you'll learn
- How to split a law by article so that each piece can be cited precisely.
- How to keep vectors in Postgres with pgvector and search by meaning.
- How to separate the deterministic part (thresholds from your own file) from the model's part.
- How to make a model return review points by a schema, and how code checks its citations.
- Why the end of the chain is always a human.
02Before you start
- A machine of the NVIDIA GB10 class with DGX OS (Ubuntu 24.04), Docker and a working Ollama at
http://localhost:11434(see the n8n lesson; there Ollama has no published port — for this lesson publish it only to127.0.0.1). ⚠️ We have not run this variant. - The law as a text file that you take from an official source: the State Gazette or the Public Procurement Agency. The lesson does not hand it out and does not tell you which numbers to enter.
- Familiarity with the lesson Local RAG on GX10 — here we use it in a narrower form.
| What | As of 03.10.2026 | What it is for |
|---|---|---|
pgvector/pgvector:pg18 | image for amd64 and arm64 (Docker Hub) | Postgres 18 with the vector extension |
bge-m3 | Ollama · 1024 dimensions · up to 8192 tokens (model card) | Vectors for Bulgarian and other languages |
llama3.1 | Ollama · 8B and 70B | The model for the review points (an example) |
psycopg · httpx · fastapi | 3.3.6 · 0.28.1 · 0.142.2 (PyPI) | Database, requests to Ollama, endpoint |
03Steps
-
The scheme
Part What it does Who decides Indexing The law is split by article, each becomes a vector in Postgres code Rules The estimated value is compared with the steps in thresholds.jsoncode, from your file Retrieval The articles closest to the question are found vectors Review points The model returns a list of points by a schema model Output check The cited article must be among the retrieved ones; no advice wording code Decision The review and the sign-off a human Why this way? The numbers and the procedure type are not a job for a model: an error here is costly, and a model makes it convincingly. So the thresholds are a plain file that you can read and check.
-
Database and environment
The password is generated on the spot (
.envhas permissions 600), and the database is reachable only from the machine.bash · ~/zop-checkmkdir -p ~/zop-check && cd ~/zop-check cat > .env <<EOF POSTGRES_USER=zop POSTGRES_PASSWORD=$(openssl rand -hex 24) POSTGRES_DB=zop EOF chmod 600 .envyaml · ~/zop-check/compose.yamlname: zop-check services: db: image: pgvector/pgvector:pg18 restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} PGDATA: /var/lib/postgresql/data volumes: - db_data:/var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" volumes: db_data:sql · schema.sqlCREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE law_chunks ( id bigserial PRIMARY KEY, law text NOT NULL, article text NOT NULL, content text NOT NULL, embedding vector(1024) NOT NULL ); CREATE INDEX ON law_chunks USING hnsw (embedding vector_cosine_ops);bashdocker compose up -d docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < schema.sql ollama pull bge-m3 ollama pull llama3.1 python3 -m venv .venv && . .venv/bin/activate pip install httpx "psycopg[binary]" fastapi uvicornThe size 1024 in the table follows the BGE-M3 model card. If you change the embedding model, check the size and change the table. ⚠️ We have not run the Docker and Ollama commands on GB10.
-
The law in the database
A piece is a whole article, and its number stays as metadata — so a citation is precise. Save the law as a UTF-8 file from the official source, and put the version in its name so you know which date the index is for.
python · ~/zop-check/ingest.py# ingest.py: a law (a text file from an official source) -> one chunk per article -> vectors -> Postgres import os import re import sys import httpx import psycopg OLLAMA = "http://localhost:11434" EMBED_MODEL = "bge-m3" ARTICLE = re.compile(r"^\s*Чл\.\s*(\d+[а-я]?)\.") # matches the Bulgarian "Чл. 18." or "Чл. 20а." heading def split_by_article(text: str, law: str) -> list: """One chunk per article; the article number stays as metadata for citing.""" chunks, art, buf = [], None, [] for line in text.splitlines(): m = ARTICLE.match(line) if m: if art: chunks.append({"law": law, "article": art, "content": "\n".join(buf).strip()}) art, buf = m.group(1), [line] elif art: buf.append(line) if art: chunks.append({"law": law, "article": art, "content": "\n".join(buf).strip()}) return chunks def embed(texts: list) -> list: r = httpx.post(f"{OLLAMA}/api/embed", json={"model": EMBED_MODEL, "input": texts}, timeout=300) r.raise_for_status() return r.json()["embeddings"] def to_vec(v: list) -> str: return "[" + ",".join(str(x) for x in v) + "]" def ingest(path: str, law: str, dsn: str) -> None: chunks = split_by_article(open(path, encoding="utf-8").read(), law) vectors = embed([c["content"] for c in chunks]) with psycopg.connect(dsn) as conn: for c, v in zip(chunks, vectors): conn.execute( "INSERT INTO law_chunks (law, article, content, embedding) VALUES (%s, %s, %s, %s::vector)", (c["law"], c["article"], c["content"], to_vec(v))) print(f"{law}: {len(chunks)} articles") if __name__ == "__main__": ingest(sys.argv[1], sys.argv[2], os.environ["DATABASE_URL"]) # file, name and version of the law; the database address is in a variablebashexport DATABASE_URL='postgresql://<user>:<password>@127.0.0.1:5432/<database>' python ingest.py zop.txt "<law name and version>"We ran the splitting by article on an invented text:
Чл. 20а.is recognised as a separate article. Laws change — re-index after every amendment. -
The rules: steps from your own file
The
thresholds.jsonfile is empty by default. You add rows in rising order of limit, copying them from the official text; the last row has"max_eur": null(no upper limit). If there are no steps, the code says "not set" instead of guessing.python · ~/zop-check/rules.py# rules.py: the deterministic part. YOU fill in the thresholds from the official text. import json from dataclasses import dataclass @dataclass class Decision: procedure: str publication: str note: str def load_steps(path: str) -> dict: """thresholds.json: {"category": [{"max_eur": number|null, "procedure": "...", "publication": "..."}, ...]} The steps are ordered by rising limit; the last one has max_eur = null (no upper limit).""" return json.load(open(path, encoding="utf-8")) def decide(value_eur: float, category: str, steps: dict) -> Decision: rows = steps.get(category) if not rows: return Decision("not set", "", f"No steps for category {category}: fill in thresholds.json.") for row in rows: limit = row.get("max_eur") if limit is None or value_eur < limit: if not row.get("procedure"): break return Decision(row["procedure"], row.get("publication", ""), "The value was compared with your file, not with the law. Check the official text.") return Decision("not set", "", "The value falls in no step: fill in thresholds.json.")json · thresholds.json (empty template){ "supplies_services": [ {"max_eur": null, "procedure": "", "publication": ""} ], "works": [ {"max_eur": null, "procedure": "", "publication": ""} ] }This is how the logic is checked with demonstration numbers (run on an ordinary computer):
pythonfrom rules import decide # The numbers are for DEMONSTRATION. They are NOT thresholds from the law. steps = {"demo": [ {"max_eur": 100, "procedure": "step A", "publication": "x"}, {"max_eur": 1000, "procedure": "step B", "publication": "y"}, {"max_eur": None, "procedure": "step C", "publication": "z"}, ]} for v in (50, 100, 999, 5000): print(v, decide(v, "demo", steps).procedure) # 50 step A | 100 step B | 999 step B | 5000 step C -
The agent: retrieval, model, check
The agent looks for the closest articles, gives them to the model and asks for points only from them. Then the code checks two things: whether the cited article is among the retrieved ones (
citation_found) and whether there is advice wording (advice_wording). A point withcitation_found: falseis not shown as fact.python · ~/zop-check/agent.py# agent.py: retrieve articles + local model + output check import json import re import httpx import psycopg from ingest import embed, to_vec from rules import decide OLLAMA = "http://localhost:11434" MODEL = "llama3.1" SCHEMA = { "type": "object", "properties": { "flags": {"type": "array", "items": { "type": "object", "properties": { "issue": {"type": "string"}, "article": {"type": "string"}, # an article number taken from the supplied excerpts "for_human": {"type": "string"}, # what the human has to check }, "required": ["issue", "article", "for_human"]}}, }, "required": ["flags"], } SYSTEM = """You help a public-procurement specialist not to miss a point to check. You are not a lawyer and you do not give legal advice. Work ONLY from the supplied excerpts of the law. Return a list of points for review. Each one cites an article number from the excerpts. If the excerpts give no basis, do not invent an article.""" ADVICE = re.compile(r"\b(I advise|I recommend|you should (award|choose|sign)|I guarantee)\b", re.I) def retrieve(question: str, dsn: str, k: int = 6) -> list: qv = to_vec(embed([question])[0]) with psycopg.connect(dsn) as conn: rows = conn.execute( "SELECT law, article, content FROM law_chunks ORDER BY embedding <=> %s::vector LIMIT %s", (qv, k)).fetchall() return [{"law": r[0], "article": r[1], "content": r[2]} for r in rows] def check_output(result: dict, retrieved: list) -> dict: """Deterministic guard: the citation must be among the retrieved articles, no advice wording.""" known = {a["article"] for a in retrieved} for f in result.get("flags", []): f["citation_found"] = f.get("article") in known f["advice_wording"] = bool(ADVICE.search(f.get("issue", "") + " " + f.get("for_human", ""))) result["disclaimer"] = "Points for review. Not legal advice. A procurement specialist or a lawyer decides." return result def review(case: dict, steps: dict, dsn: str) -> dict: d = decide(case["value_eur"], case["category"], steps) articles = retrieve(f"{d.procedure} deadlines documents publication {case['category']}", dsn) context = "\n\n".join(f"[Art. {a['article']}]\n{a['content'][:700]}" for a in articles) prompt = (f"Description of the procedure: {json.dumps(case, ensure_ascii=False)}\n" f"Expected type per your thresholds file: {d.procedure}\n\nExcerpts:\n{context}") r = httpx.post(f"{OLLAMA}/api/generate", timeout=300, json={ "model": MODEL, "system": SYSTEM, "prompt": prompt, "format": SCHEMA, "stream": False, "options": {"temperature": 0}}) r.raise_for_status() result = check_output(json.loads(r.json()["response"]), articles) if case.get("procedure_chosen") != d.procedure: result["flags"].insert(0, {"issue": f"Chosen type '{case.get('procedure_chosen')}', but your thresholds file expects '{d.procedure}'.", "article": "", "for_human": "Compare with the official text.", "citation_found": False, "advice_wording": False}) result["expected_by_thresholds"] = d.__dict__ return result⚠️The output check is a net, not a guaranteeA model can cite an article number correctly and still interpret it wrongly. Comparing the citation with the retrieved articles catches invented numbers, not wrong interpretation — the human catches that. -
An endpoint
One endpoint, on your computer only. The database address comes from an environment variable. If you open it to the network, add authentication and HTTPS first.
python · ~/zop-check/api.py# api.py: one endpoint, on this computer only import os from fastapi import FastAPI from pydantic import BaseModel, Field from agent import review from rules import load_steps app = FastAPI(title="Procurement check") STEPS = load_steps("thresholds.json") DSN = os.environ["DATABASE_URL"] # keep it in .env or the environment, not in code class Case(BaseModel): value_eur: float = Field(gt=0) category: str procedure_chosen: str = "" @app.post("/check") def check(case: Case): result = review(case.model_dump(), STEPS, DSN) result["reviewed_by"] = None # waits for a specialist's sign-off return result # uvicorn api:app --host 127.0.0.1 --port 8002bashexport DATABASE_URL='postgresql://<user>:<password>@127.0.0.1:5432/<database>' uvicorn api:app --host 127.0.0.1 --port 8002 # in another terminal: curl -X POST http://127.0.0.1:8002/check -H "Content-Type: application/json" \ -d '{"value_eur": 1000, "category": "supplies_services", "procedure_chosen": "<type>"}'The answer has the shape below;
reviewed_bystays empty until a specialist signs it.json · shape of the answer{ "flags": [ {"issue": "...", "article": "18", "for_human": "...", "citation_found": true, "advice_wording": false} ], "disclaimer": "Points for review. Not legal advice. ...", "expected_by_thresholds": {"procedure": "...", "publication": "...", "note": "..."}, "reviewed_by": null } -
Deadlines of the procedure
The assistant does not calculate deadlines. The dates of a specific procedure (the offer deadline, the appeal period, deadlines under a funding programme, if there is one) are taken by the specialist from the official text and from the documentation. If you want reminders, turn them into a calendar with the technique from the lesson Deadlines from Documents into an .ics Calendar.
-
The human decides
- Every point is checked against the official text of the law, not against the retrieved excerpt.
- The decision and the signature belong to a procurement specialist or a lawyer; the
reviewed_byfield is filled in by them. - Data on real procedures is not uploaded to outside services; in exercises use invented cases.
04Check
- The
law_chunkstable has one row per article, and the article number matches the text. thresholds.jsonis filled in from the official text; the demonstration numbers are not in it.- The endpoint returns review points, and each has
citation_found. - Points with
citation_found: falseor with advice wording are rejected by a human. - The database and the endpoint listen only on
127.0.0.1.
Quiz
1. Why are the thresholds in a file that you fill in, and not in the code?
2. What does check_output check?
3. Who makes the decision on the type of procedure?
4. How many dimensions does the vector column have for bge-m3 according to the model card?
05What's next
All lessons in the series are in the index GX10: all lessons.
06Sources
- State Gazette — the official source for the text of the Act and its amendments.
- Public Procurement Agency — information and guidance on the Act.
- Ollama: API —
/api/embedand/api/generatewith a JSON schema informat🔒 local · bge-m3 · llama3.1 🔒 local. - BAAI/bge-m3 — 1024 dimensions, up to 8192 tokens.
- Llama 3.1: model card — officially supported languages (Bulgarian is not among them).
- pgvector · pgvector image on Docker Hub — the
<=>operator, the HNSW index, tags and architectures. - FastAPI · psycopg (PyPI) — versions as of 03.10.2026.