The KAGAMI mark КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03 · NOT VERIFIED IN FULL
IDENTITY
module
GX10-04-190 · Procurement-procedure review assistant (decision support)
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Advanced
duration
about 3.5 h
prerequisites
A GB10-class machine with DGX OS (Ubuntu 24.04), Docker, a running Ollama, a text file of the Public Procurement Act taken from an official source, basic RAG knowledge (see 04-84)
trust_label
UPDATED 2026-10-03 · not verified in full: no threshold, procedure type, deadline or article number of the Act was verified, so none is given; only the parts without external services (article splitting, step selection from a user file, output check, endpoint with a stubbed function) ran once on a generic Linux machine (Python 3.10) · NOT TESTED on a GB10-class machine · retrieval and model quality not measured
versions
pgvector/pgvector:pg18 (amd64, arm64) · bge-m3 on Ollama (1024 dimensions, up to 8192 tokens per model card) · llama3.1 on Ollama · psycopg 3.3.6 · httpx 0.28.1 · FastAPI 0.142.2 (as of 2026-10-03)
language
human view: en · bulgarian edition: /academy/gx10/ (same file name)
previous / next
04-181_Legal_Research_Assistant.html / 04-193_ZCHOD_Labor_Compliance.html
PURPOSE

Build a decision-support helper for preparing a public-procurement procedure under the Bulgarian Public Procurement Act: split the law by article, store bge-m3 vectors in Postgres with pgvector, compare the estimated value with thresholds that the USER fills in from the official text, retrieve related articles, have a local model return review points with article citations, and validate the output in code. It is not legal advice and does not replace a lawyer or a procurement specialist; a human decides and signs.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-181 legal RAG with Qdrant · 04-139 legal assistant with citations · 04-179 deadlines to .ics · series index: kagami.bg/en/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10ragpgvectorollamabge-m3public-procurementdecision-supporthuman-in-the-loop
UPDATED · 03.10.2026

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.

⏱ 3.5 h Advanced GX10 Ollama · pgvector · bge-m3 · FastAPI
Ollama (the models)🔒 local Postgres + pgvector🔒 local Python · FastAPI🔒 local
⚖️
It does not replace a lawyer
This is an assistant for preparation and review, not legal advice. It does not decide the type of procedure, does not make decisions and does not replace a lawyer or a public-procurement specialist. Every decision and every article of the law is checked in the official text.
🔄
UPDATED · 03.10.2026 — what changed
The lesson was rebuilt and narrowed to what we can prove. The old version contained threshold numbers in two different versions (30,000 EUR in the code and other values in a table) and claims about deadlines for projects with European funding. We could not check them against an official text of the Act on 03.10.2026, so we removed all thresholds, article numbers and deadlines. Instead, the thresholds are in a file that you fill in from the official text, and the code claims nothing about the law. We removed: the example with a specific contracting authority, the names of internal files, the password and "token" written into the code, the JWT with a hard-coded secret, a server open on all network interfaces, the reference to a Guardrails configuration that was not supplied, and an n8n scheme we have not checked. We added: Postgres with pgvector (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.
⚠️
What we have not run or checked
We had no GB10-class machine — which is why there is no "TESTED" label. On an ordinary Linux computer (Python 3.10) we ran only the parts without external services: the splitting by article, the step selection from a thresholds file, the output check and the endpoint (with a stubbed review function). Not run: Postgres with pgvector, Ollama with 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

02Before you start

WhatAs of 03.10.2026What it is for
pgvector/pgvector:pg18image for amd64 and arm64 (Docker Hub)Postgres 18 with the vector extension
bge-m3Ollama · 1024 dimensions · up to 8192 tokens (model card)Vectors for Bulgarian and other languages
llama3.1Ollama · 8B and 70BThe model for the review points (an example)
psycopg · httpx · fastapi3.3.6 · 0.28.1 · 0.142.2 (PyPI)Database, requests to Ollama, endpoint

03Steps

  1. The scheme

    PartWhat it doesWho decides
    IndexingThe law is split by article, each becomes a vector in Postgrescode
    RulesThe estimated value is compared with the steps in thresholds.jsoncode, from your file
    RetrievalThe articles closest to the question are foundvectors
    Review pointsThe model returns a list of points by a schemamodel
    Output checkThe cited article must be among the retrieved ones; no advice wordingcode
    DecisionThe review and the sign-offa 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.

  2. Database and environment

    The password is generated on the spot (.env has permissions 600), and the database is reachable only from the machine.

    bash · ~/zop-check
    mkdir -p ~/zop-check && cd ~/zop-check
    cat > .env <<EOF
    POSTGRES_USER=zop
    POSTGRES_PASSWORD=$(openssl rand -hex 24)
    POSTGRES_DB=zop
    EOF
    chmod 600 .env
    yaml · ~/zop-check/compose.yaml
    name: 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.sql
    CREATE 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);
    bash
    docker 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 uvicorn

    The 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.

  3. 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 variable
    bash
    export 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.

  4. The rules: steps from your own file

    The thresholds.json file 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):

    python
    from 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
  5. 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 with citation_found: false is 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 guarantee
    A 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.
  6. 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 8002
    bash
    export 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_by stays 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
    }
  7. 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.

  8. 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_by field is filled in by them.
    • Data on real procedures is not uploaded to outside services; in exercises use invented cases.

04Check

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

  1. State Gazette — the official source for the text of the Act and its amendments.
  2. Public Procurement Agency — information and guidance on the Act.
  3. Ollama: API — /api/embed and /api/generate with a JSON schema in format 🔒 local · bge-m3 · llama3.1 🔒 local.
  4. BAAI/bge-m3 — 1024 dimensions, up to 8192 tokens.
  5. Llama 3.1: model card — officially supported languages (Bulgarian is not among them).
  6. pgvector · pgvector image on Docker Hub — the <=> operator, the HNSW index, tags and architectures.
  7. FastAPI · psycopg (PyPI) — versions as of 03.10.2026.