The KAGAMI mark КАГАМИ
kagami.bg/academy · lesson · machine-readable viewVERIFIED 2026-10-01 · UPDATED 2026-10-01
IDENTITY
module
GX10-04-142 · GDPR preparation assistant: local RAG over the official Bulgarian text, DPA draft, breach deadline tracker
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Advanced
duration
about 2 h
prerequisites
Python 3.10+, PostgreSQL with the pgvector extension, Ollama running locally, the official Bulgarian text of Regulation (EU) 2016/679 saved as an HTML file from EUR-Lex, a fictional organisation for all trials (no real incident data)
trust_label
VERIFIED 2026-10-01 (quoted articles checked against the official Bulgarian and English texts, OJ L 119, 4.5.2016, p. 1; package versions, pgvector operators and Ollama embed endpoint checked online) · UPDATED 2026-10-01 · NOT TESTED on a GB10-class machine (the loader, the DPA draft builder and the tracker were run once on an ordinary computer; the Postgres and Ollama parts were not run) · Bulgarian Personal Data Protection Act Art. 6(1), 15(2) and 17(1) checked against the lex.bg consolidated text (unofficial; last amendment there State Gazette No. 69 of 31.07.2026) · UNVERIFIED: the current filing channel of the Bulgarian supervisory authority (only its home page opened on 2026-10-01)
versions
psycopg 3.3.6 (LGPL-3.0) · httpx 0.28.1 · nomic-embed-text (274 MB, 2K context window per the Ollama library page) · llama3.1:70b (43 GB) · pgvector extension (cosine distance operator <=>) · Regulation text: 99 articles confirmed
language
human view: en · bulgarian edition: /academy/gx10/ (same file name)
previous / next
04-140 contract drafting aid / 04-193 private-security and labour law assistant (legal chain 04-135 → 04-136 → 04-137 → 04-138 → 04-140 → 04-142 → 04-193; related: 04-136 legal document RAG, 04-137 notary deed OCR)
PURPOSE

Build a local preparation aid for data protection work: (1) split the official Bulgarian text of Regulation (EU) 2016/679 into articles and chunks and index them with an embedding model in PostgreSQL/pgvector; (2) answer questions by returning the verbatim article text with a similarity score, refuse below a threshold, and let a local model explain only from the retrieved passages while every quote it returns is verified against those passages; (3) assemble a processing-agreement draft whose mandatory clauses are copied verbatim from Article 28(3), not generated; (4) track the 72-hour window of Article 33(1) with human-entered awareness time and human risk assessment, and prepare a draft of the Article 33(3) content. The output is preparation for a lawyer, not legal advice. The code never files anything with an authority.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-193 private-security and labour law assistant (next in the legal chain) · related: 04-135 contract analysis, 04-136 legal document RAG, 04-137 notary deed OCR · GX10 series index · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10gdprragpgvectorollamadata-breachdpalocal-ai
VERIFIED · 01.10.2026 UPDATED · 01.10.2026

GDPR Assistant: Searching the Regulation, a Processing Agreement and a Breach Tracker

In an organisation you have to answer GDPR questions, prepare agreements with processors and, if an incident happens, watch the notification deadline. We build a preparation aid on your own machine: it searches the official text and returns verbatim quotes, assembles an agreement draft from the Regulation's exact clauses, and keeps count of the hours to the Article 33 deadline. Decisions stay with people and the lawyer.

⏱ ~2 h Advanced GX10 RAG · pgvector · Ollama Data never leaves the machine
PostgreSQL + pgvector (the index)🔒 local Ollama · nomic-embed-text · Llama 3.1 (the models)🔒 local Python · SQLite (the draft and the tracker)🔒 local EUR-Lex (the official text, saved by hand)🌐 global
⚖️
IMPORTANT: this is not legal advice
This lesson is preparation for an opinion, not an opinion. The quotes come from the official text, but how they apply to a concrete case — when you "became aware" of a breach, whether a risk is likely, whether an agreement is sufficient — is decided by people: the data controller and its lawyer or data protection officer. Every legal conclusion here awaits a lawyer's confirmation. The examples are fictional ("Alpha Example" Ltd) — do not put real incident data into trials.
🔄
UPDATED · 01.10.2026 — what changed
The lesson was rebuilt entirely against the official text. We removed: the claim that the 72-hour period also applies to the processor (under Art. 33(2) it notifies the controller "without undue delay" — there is no figure), a vector of size 1536 for a model that does not give that size (the size is now measured), a similarity threshold of 0.75 with no basis, the claim that "async is dangerous", the automatic e-mail to the supervisory authority, the "12 criteria" list and the table of deadlines (articles whose text we had not checked), an agreement and a notification "written" by the model, and the promise of "full GDPR compliance". We also fixed the wrong letter references to Art. 28(3). We added: the full text of the cited articles from the official text, loading and verifying all 99 articles, splitting into passages by paragraph, refusal on insufficient similarity, verification of every quote the model returns, an agreement draft whose clauses are copied verbatim from Art. 28(3), a tracker in which "when we became aware" and the risk assessment are human decisions with a reason and an action log, the full text of Art. 6(1) and Art. 15(2) of the Bulgarian Personal Data Protection Act, and an honest note about what we could not check (the current way of filing with the Bulgarian supervisory authority).
⚠️
What we have not run ourselves
We had no GB10-class machine: there is no "TESTED" label. The code that loads the Regulation, builds the agreement draft and runs the tracker was executed once on an ordinary computer; the Postgres and Ollama parts were not run. We measured neither speed nor search accuracy. The provisions of the Bulgarian Personal Data Protection Act (ZZLD) on the supervisory authority (Art. 6(1) and 15(2)) were checked against the consolidated text on lex.bg on 01.10.2026. ⚠️ We did not check the current form/portal for notifying the Bulgarian supervisory authority (KZLD) — on 01.10.2026 only the home page of its website opened. So the lesson gives no portal address (see step 7).

01What you will learn

02Before you start

What we use. Checked as of 01.10.2026

PartVersion / sizeNote
psycopg3.3.6LGPL-3.0 licence. Installed as psycopg[binary]
httpx0.28.1Requests to Ollama
nomic-embed-text 🔒274 MB, 2K contextEmbedding model. The vector size is not stated on its page — so the code measures it
llama3.1:70b 🔒43 GBFor explanation only; its official language list does not include Bulgarian — accuracy on Bulgarian is unverified ⚠️
pgvectorPostgres extensionOperator <=> = cosine distance (pgvector README); similarity = 1 - distance

03Steps

  1. What we build and why this way

    Four small files. The rule of the lesson: legal words are taken from the official text and quoted verbatim, not "written" by the model. A model can paraphrase, drop a clause or invent one. So search returns the text, the agreement is assembled from copied clauses, and the deadline is computed by a formula.

    FileDoes
    gdpr_loader.pyreads gdpr_bg.html, splits it into articles and passages, checks the count (99)
    gdpr_rag.pypgvector index, search with a threshold, explanation with quote verification
    dpa_draft.pyagreement draft with verbatim clauses under Art. 28(3)
    breach_tracker.pytracker of the Art. 33 window, action log, draft of the notification content
  2. The official text and what we use of it

    These provisions are cited in the lesson. The text is from the official English version of the Regulation (OJ L 119, 4.5.2016, p. 1), checked against the official Bulgarian text on 01.10.2026. ⚠️ We did not check whether later corrections of the text (corrigenda) affect these articles — see the list of versions on EUR-Lex.

    📜
    Art. 4(12) — "personal data breach"
    ‘personal data breach’ means a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorised disclosure of, or access to, personal data transmitted, stored or otherwise processed;
    📜
    Art. 28 — Processor (paragraphs 2, 3, 4 and 9)
    2. The processor shall not engage another processor without prior specific or general written authorisation of the controller. In the case of general written authorisation, the processor shall inform the controller of any intended changes concerning the addition or replacement of other processors, thereby giving the controller the opportunity to object to such changes.

    3. Processing by a processor shall be governed by a contract or other legal act under Union or Member State law, that is binding on the processor with regard to the controller and that sets out the subject-matter and duration of the processing, the nature and purpose of the processing, the type of personal data and categories of data subjects and the obligations and rights of the controller. That contract or other legal act shall stipulate, in particular, that the processor:
    (a) processes the personal data only on documented instructions from the controller, including with regard to transfers of personal data to a third country or an international organisation, unless required to do so by Union or Member State law to which the processor is subject; in such a case, the processor shall inform the controller of that legal requirement before processing, unless that law prohibits such information on important grounds of public interest;
    (b) ensures that persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality;
    (c) takes all measures required pursuant to Article 32;
    (d) respects the conditions referred to in paragraphs 2 and 4 for engaging another processor;
    (e) taking into account the nature of the processing, assists the controller by appropriate technical and organisational measures, insofar as this is possible, for the fulfilment of the controller's obligation to respond to requests for exercising the data subject's rights laid down in Chapter III;
    (f) assists the controller in ensuring compliance with the obligations pursuant to Articles 32 to 36 taking into account the nature of processing and the information available to the processor;
    (g) at the choice of the controller, deletes or returns all the personal data to the controller after the end of the provision of services relating to processing, and deletes existing copies unless Union or Member State law requires storage of the personal data;
    (h) makes available to the controller all information necessary to demonstrate compliance with the obligations laid down in this Article and allow for and contribute to audits, including inspections, conducted by the controller or another auditor mandated by the controller.
    With regard to point (h) of the first subparagraph, the processor shall immediately inform the controller if, in its opinion, an instruction infringes this Regulation or other Union or Member State data protection provisions.

    4. Where a processor engages another processor for carrying out specific processing activities on behalf of the controller, the same data protection obligations as set out in the contract or other legal act between the controller and the processor as referred to in paragraph 3 shall be imposed on that other processor by way of a contract or other legal act under Union or Member State law, in particular providing sufficient guarantees to implement appropriate technical and organisational measures in such a manner that the processing will meet the requirements of this Regulation. Where that other processor fails to fulfil its data protection obligations, the initial processor shall remain fully liable to the controller for the performance of that other processor's obligations.

    9. The contract or the other legal act referred to in paragraphs 3 and 4 shall be in writing, including in electronic form.
    📜
    Art. 33 — Notification of a personal data breach to the supervisory authority
    1. In the case of a personal data breach, the controller shall without undue delay and, where feasible, not later than 72 hours after having become aware of it, notify the personal data breach to the supervisory authority competent in accordance with Article 55, unless the personal data breach is unlikely to result in a risk to the rights and freedoms of natural persons. Where the notification to the supervisory authority is not made within 72 hours, it shall be accompanied by reasons for the delay.
    2. The processor shall notify the controller without undue delay after becoming aware of a personal data breach.
    3. The notification referred to in paragraph 1 shall at least:
    (a) describe the nature of the personal data breach including where possible, the categories and approximate number of data subjects concerned and the categories and approximate number of personal data records concerned;
    (b) communicate the name and contact details of the data protection officer or other contact point where more information can be obtained;
    (c) describe the likely consequences of the personal data breach;
    (d) describe the measures taken or proposed to be taken by the controller to address the personal data breach, including, where appropriate, measures to mitigate its possible adverse effects.
    4. Where, and in so far as, it is not possible to provide the information at the same time, the information may be provided in phases without undue further delay.
    5. The controller shall document any personal data breaches, comprising the facts relating to the personal data breach, its effects and the remedial action taken. That documentation shall enable the supervisory authority to verify compliance with this Article.
    📜
    Art. 34(1) — Communication of a personal data breach to the data subject
    1. When the personal data breach is likely to result in a high risk to the rights and freedoms of natural persons, the controller shall communicate the personal data breach to the data subject without undue delay.

    Three things stand out in the text. First: the 72 hours are the controller's and run from the moment it "has become aware" — that is not a technical date but a legal judgement, so the tracker asks a person for it. Second: for the processor there is no figure of 72 — "without undue delay" (paragraph 2). Third: paragraph 5 requires every breach to be documented, including when no notification is made — that is why the tracker keeps a log.

  3. Loading the Regulation

    Save the official text as gdpr_bg.html next to the scripts. The code turns it into text, finds the "Член N" headings (Bulgarian for "Article N"), takes each article's title and body, and stops with an error if there are not exactly 99 articles. The body is cut before chapter or section headings, which would otherwise stick to the end of the previous article. The paragraph function returns a paragraph verbatim — we need it for the agreement.

    python · gdpr_loader.py
    import re
    from html.parser import HTMLParser
    
    BLOCK = {"p", "div", "tr", "li", "h1", "h2", "h3", "br", "table"}
    
    
    class _Text(HTMLParser):
        def __init__(self):
            super().__init__()
            self.out = []
    
        def handle_starttag(self, tag, attrs):
            if tag in BLOCK:
                self.out.append("\n")
    
        def handle_endtag(self, tag):
            if tag in BLOCK:
                self.out.append("\n")
    
        def handle_data(self, data):
            self.out.append(data)
    
    
    def html_to_text(path: str) -> str:
        p = _Text()
        with open(path, encoding="utf-8") as f:
            p.feed(f.read())
        t = "".join(p.out).replace("\xa0", " ")
        t = re.sub(r"[ \t]+", " ", t)
        return re.sub(r"\n\s*\n+", "\n", t)
    
    
    def load_articles(path: str) -> dict:
        """{number: {"title": ..., "body": ...}} from the official Bulgarian text (headings start with "Член")."""
        t = html_to_text(path)
        heads = list(re.finditer(r"^\s*Член (\d+)\s*$", t, flags=re.M))
        articles = {}
        for i, m in enumerate(heads):
            end = heads[i + 1].start() if i + 1 < len(heads) else len(t)
            lines = [l.strip() for l in t[m.end():end].split("\n") if l.strip()]
            title, body = lines[0], []
            for l in lines[1:]:
                if re.match(r"^(ГЛАВА [IVX]+|Раздел \d+)$", l) or l.startswith("Настоящият регламент е задължителен"):
                    break
                body.append(l)
            articles[int(m.group(1))] = {"title": title, "body": "\n".join(body)}
        if len(articles) != 99:
            raise ValueError(f"Expected 99 articles, found {len(articles)} - check the file.")
        return articles
    
    
    def chunks(body: str, limit: int = 1200):
        """Split the body at paragraph/point boundaries so each chunk fits the embedding model context."""
        cur = ""
        for line in body.split("\n"):
            if cur and len(cur) + len(line) > limit and re.match(r"^(\d+[.)]|[a-zа-я]\))", line):
                yield cur
                cur = line
            else:
                cur = (cur + "\n" + line) if cur else line
        if cur:
            yield cur
    
    
    def paragraph(body: str, n: int) -> str:
        """Paragraph n of an article - verbatim, with its lettered points."""
        m = re.search(rf"^{n}\. .*?(?=^{n + 1}\. |\Z)", body, flags=re.M | re.S)
        return m.group(0).strip() if m else ""

    The passages are about 1750 characters at most. That matters: nomic-embed-text has a 2K context, and Ollama by default truncates a long input silently — so below we call /api/embed with "truncate": false and get an error if something does not fit.

  4. Index and search: a quote or a refusal

    First the passages are embedded (Ollama's /api/embed accepts a list). We measure the vector size with one test call and create the column with it — we do not guess. In search we sort by <=> (cosine distance); similarity is 1 - distance. Below the MIN_SIM threshold the system refuses. The value 0.5 is only a starting point with no proven basis: change it after trying 20 questions of your own.

    The model does not answer "from its head". It receives only the retrieved passages and returns an explanation plus quotes. Every quote is searched for in the passages; one that is not found is marked found_in_text: false and is not shown as a quote.

    python · gdpr_rag.py
    import json
    import re
    
    import httpx
    import psycopg
    
    from gdpr_loader import load_articles, chunks
    
    OLLAMA = "http://localhost:11434"
    EMBED_MODEL = "nomic-embed-text"
    CHAT_MODEL = "llama3.1:70b"
    DSN = "dbname=gdpr_lab"
    MIN_SIM = 0.5   # STARTING value with no proven basis: calibrate it with your own questions
    
    
    def embed(texts: list[str]) -> list[list[float]]:
        r = httpx.post(f"{OLLAMA}/api/embed", timeout=300,
                       json={"model": EMBED_MODEL, "input": texts, "truncate": False})
        r.raise_for_status()
        return r.json()["embeddings"]
    
    
    def vec(v: list[float]) -> str:
        return "[" + ",".join(map(str, v)) + "]"
    
    
    def build_index(path: str) -> None:
        arts = load_articles(path)
        dim = len(embed(["test"])[0])           # measure the dimension, do not guess it
        with psycopg.connect(DSN) as conn:
            conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
            conn.execute(f"""CREATE TABLE IF NOT EXISTS gdpr_chunks(
                id bigserial PRIMARY KEY, article int NOT NULL, title text NOT NULL,
                body text NOT NULL, embedding vector({dim}) NOT NULL)""")
            if conn.execute("SELECT count(*) FROM gdpr_chunks").fetchone()[0]:
                return                            # already indexed
            rows = [(n, a["title"], c) for n, a in arts.items() for c in chunks(a["body"])]
            for i in range(0, len(rows), 16):
                batch = rows[i:i + 16]
                embs = embed([f"Член {n}. {t}\n{c}" for n, t, c in batch])
                for (n, t, c), e in zip(batch, embs):
                    conn.execute("INSERT INTO gdpr_chunks(article, title, body, embedding) VALUES (%s,%s,%s,%s::vector)",
                                 (n, t, c, vec(e)))
    
    
    def search(question: str, k: int = 4) -> list[dict]:
        q = vec(embed([question])[0])
        with psycopg.connect(DSN) as conn:
            rows = conn.execute(
                "SELECT article, title, body, 1 - (embedding <=> %s::vector) AS sim "
                "FROM gdpr_chunks ORDER BY embedding <=> %s::vector LIMIT %s", (q, q, k)).fetchall()
        return [{"article": r[0], "title": r[1], "body": r[2], "sim": round(float(r[3]), 3)}
                for r in rows if r[3] >= MIN_SIM]
    
    
    def _norm(s: str) -> str:
        return re.sub(r"\s+", " ", s).strip().lower()
    
    
    def explain(question: str, hits: list[dict]) -> dict:
        """A short explanation ONLY from the retrieved passages. Every quote is checked against them."""
        if not hits:
            return {"answer": "The index holds no sufficiently close text. No answer.", "quotes": []}
        context = "\n\n".join(f"[Член {h['article']}]\n{h['body']}" for h in hits)
        system = ("You answer in English ONLY from the given passages of Regulation (EU) 2016/679 (Bulgarian text). "
                  "You use no other knowledge. You give no legal advice. If the passages do not answer the question, "
                  'say so. Return JSON only: {"answer": "...", "quotes": [{"article": 33, "quote": "verbatim quote in Bulgarian"}]}')
        r = httpx.post(f"{OLLAMA}/api/chat", timeout=600, json={
            "model": CHAT_MODEL, "stream": False, "format": "json",
            "options": {"temperature": 0.1, "num_ctx": 16384},
            "messages": [{"role": "system", "content": system},
                         {"role": "user", "content": f"Question: {question}\n\nPassages:\n{context}"}]})
        r.raise_for_status()
        result = json.loads(r.json()["message"]["content"])
        pool = _norm(context)
        for q in result.get("quotes", []):
            q["found_in_text"] = _norm(q.get("quote", "")) in pool
        return result
    
    
    if __name__ == "__main__":
        import sys
        if sys.argv[1] == "index":
            build_index(sys.argv[2])
        else:
            question = " ".join(sys.argv[2:])
            hits = search(question)
            for h in hits:
                print(f"Art. {h['article']} ({h['title']}) · similarity {h['sim']}\n{h['body']}\n")
            print(json.dumps(explain(question, hits), ensure_ascii=False, indent=2))
    bash
    pip install "psycopg[binary]" httpx
    ollama pull nomic-embed-text
    ollama pull llama3.1:70b
    createdb gdpr_lab
    python gdpr_rag.py index gdpr_bg.html
    python gdpr_rag.py ask "What must a notification of a personal data breach contain?"

    You expect to see Art. 33 among the retrieved passages. ⚠️ We have not run it — if it does not appear, that is a reason to change the passage size or the threshold, not to "fix" the answer by hand. Note that the index holds the Bulgarian text, so a question in Bulgarian will match best; test an English question on your own setup.

    💡
    Model language
    Llama 3.1's official language list does not include Bulgarian: explanations may be weak. So the real result is the passage; the explanation is a helper and is always checked.
  5. A processing agreement draft — without the model

    Art. 28(3) says what the agreement must stipulate. So the clauses are not generated: the code extracts paragraph 3 verbatim from the loaded text (with points (a) to (h) and the final subparagraph) and inserts it into the draft. Next to it come five fields that a person fills in (subject matter and duration, nature and purpose, type of data, categories of data subjects, the controller's rights and obligations), and placeholders for sub-processors (paragraphs 2 and 4) and form (paragraph 9). Anything missing is printed as [TO FILL IN]. This is a v0.1 draft for the lawyer, not a contract. Note that the clause block is in Bulgarian, as in the source text it comes from.

    python · dpa_draft.py
    from gdpr_loader import load_articles, paragraph
    
    FIELDS = ["subject matter and duration of the processing", "nature and purpose of the processing",
              "type of personal data", "categories of data subjects", "rights and obligations of the controller"]
    
    
    def build_dpa(controller: str, processor: str, values: dict, articles: dict) -> str:
        """Draft processing agreement. The mandatory clauses are VERBATIM from Art. 28(3) (no model involved)."""
        missing = [f for f in FIELDS if not values.get(f)]
        art28 = articles[28]["body"]
        out = [f"# DRAFT - data processing agreement (v0.1, not reviewed by a lawyer)",
               f"Controller: {controller}", f"Processor: {processor}", ""]
        for i, f in enumerate(FIELDS, 1):
            out.append(f"{i}. {f.capitalize()}: {values.get(f) or '[TO FILL IN]'}")
        out += ["", "## Clauses under Art. 28(3) of Regulation (EU) 2016/679 (verbatim, official Bulgarian text)",
                paragraph(art28, 3), "",
                "## Sub-processors - Art. 28(2) and (4): [TO BE SETTLED BY THE LAWYER]",
                "## Form - Art. 28(9): in writing, including in electronic form."]
        if missing:
            out.append(f"\nUNFILLED FIELDS: {', '.join(missing)}")
        return "\n".join(out)
    
    
    if __name__ == "__main__":
        a = load_articles("gdpr_bg.html")
        print(build_dpa('"Alpha Example" Ltd', '"Beta Example" Ltd', {"type of personal data": "names, e-mails"}, a))
  6. The breach tracker: the deadline is a formula, the decisions are human

    The tracker records a breach with the moment aware_at, entered by a person (when the controller became aware), and computes deadline = aware_at + 72 hours — by the letter of Art. 33(1), which speaks of hours; we see no weekend exception in the article itself, but how to count in your case is for the lawyer to say. The risk assessment ("risk unlikely", "risk likely", "high risk likely" — this ties to Art. 33(1) and Art. 34(1)) is also human and is stored with a reason. Every action goes into a log — that matches the documentation duty of Art. 33(5).

    The notification draft follows the content of Art. 33(3), points (a) to (d), and shows what is still missing. If the deadline has passed, it adds a line for the reasons for the delay (paragraph 1, last sentence). Information that is not yet available may be provided in phases (paragraph 4). The code sends nothing — filing is a human act and is recorded with mark_notified. The database file is owner-only, because it holds descriptions of incidents.

    python · breach_tracker.py
    import json
    import os
    import sqlite3
    import uuid
    from datetime import datetime, timedelta, timezone
    
    DB = "breaches.db"
    RISK = {"unlikely": "risk unlikely", "risk": "risk likely", "high": "high risk likely"}
    
    
    def _conn():
        new = not os.path.exists(DB)
        c = sqlite3.connect(DB)
        c.row_factory = sqlite3.Row
        if new:
            os.chmod(DB, 0o600)          # owner-only access
        c.executescript("""
        CREATE TABLE IF NOT EXISTS breach(
          id TEXT PRIMARY KEY, aware_at TEXT NOT NULL, deadline TEXT NOT NULL,
          description TEXT NOT NULL, categories TEXT, approx_subjects TEXT, approx_records TEXT,
          consequences TEXT, measures TEXT, contact TEXT,
          risk TEXT, risk_reason TEXT, notified_at TEXT, delay_reason TEXT);
        CREATE TABLE IF NOT EXISTS action(
          id INTEGER PRIMARY KEY AUTOINCREMENT, breach_id TEXT NOT NULL, at TEXT NOT NULL, text TEXT NOT NULL);
        """)
        return c
    
    
    def now() -> datetime:
        return datetime.now(timezone.utc)
    
    
    def log(c, breach_id: str, text: str) -> None:
        c.execute("INSERT INTO action(breach_id, at, text) VALUES (?,?,?)", (breach_id, now().isoformat(), text))
        c.commit()
    
    
    def register(description: str, aware_at: datetime | None = None, **fields) -> str:
        """Record a breach. aware_at is entered by a HUMAN: when the controller became aware of it."""
        aware_at = aware_at or now()
        bid = uuid.uuid4().hex[:8]
        c = _conn()
        c.execute(
            "INSERT INTO breach(id, aware_at, deadline, description, categories, approx_subjects, approx_records,"
            " consequences, measures, contact) VALUES (?,?,?,?,?,?,?,?,?,?)",
            (bid, aware_at.isoformat(), (aware_at + timedelta(hours=72)).isoformat(), description,
             fields.get("categories"), fields.get("approx_subjects"), fields.get("approx_records"),
             fields.get("consequences"), fields.get("measures"), fields.get("contact")))
        c.commit()
        log(c, bid, "Breach recorded")
        return bid
    
    
    def decide(bid: str, risk: str, reason: str) -> None:
        """The risk assessment is made by a HUMAN (with the lawyer / DPO) and stored with a reason. The system does not decide."""
        if risk not in RISK:
            raise ValueError(f"risk must be one of {list(RISK)}")
        c = _conn()
        c.execute("UPDATE breach SET risk=?, risk_reason=? WHERE id=?", (risk, reason, bid))
        c.commit()
        log(c, bid, f"Assessment: {RISK[risk]}. Reason: {reason}")
    
    
    def status(bid: str) -> dict:
        c = _conn()
        b = dict(c.execute("SELECT * FROM breach WHERE id=?", (bid,)).fetchone())
        left = (datetime.fromisoformat(b["deadline"]) - now()).total_seconds() / 3600
        b["hours_left"] = round(left, 1)
        b["overdue"] = left < 0 and not b["notified_at"]
        return b
    
    
    def draft_notification(bid: str) -> dict:
        """Draft following the content of Art. 33(3), points (a) to (d). The code never sends it."""
        b = status(bid)
        d = {
            "a_nature_categories_numbers": f"{b['description']} | categories: {b['categories'] or '[TO FILL IN]'}"
                                        f" | approximate number of data subjects: {b['approx_subjects'] or '[TO FILL IN]'}"
                                        f" | approximate number of records: {b['approx_records'] or '[TO FILL IN]'}",
            "b_contact": b["contact"] or "[TO FILL IN: DPO or other contact point]",
            "c_consequences": b["consequences"] or "[TO FILL IN]",
            "d_measures": b["measures"] or "[TO FILL IN]",
        }
        if b["overdue"]:
            d["reasons_for_delay"] = "[TO FILL IN: the 72-hour period has passed]"
        return d
    
    
    def mark_notified(bid: str, delay_reason: str | None = None) -> None:
        c = _conn()
        c.execute("UPDATE breach SET notified_at=?, delay_reason=? WHERE id=?", (now().isoformat(), delay_reason, bid))
        c.commit()
        log(c, bid, "Notification filed by a person" + (f"; reasons for delay: {delay_reason}" if delay_reason else ""))
    
    
    if __name__ == "__main__":
        bid = register("FICTIONAL EXAMPLE: a lost unencrypted portable drive holding a customer list",
                       categories="names, e-mails", approx_subjects="~200", approx_records="~200")
        decide(bid, "risk", "data not encrypted; the LAWYER confirms the assessment")
        print(json.dumps(status(bid), ensure_ascii=False, indent=2))
        print(json.dumps(draft_notification(bid), ensure_ascii=False, indent=2))
    ⚠️
    If you are a processor, not a controller
    Art. 33(2) obliges you to notify the controller "without undue delay" after becoming aware. The tracker above is for the controller; as a processor, record in the log when you became aware and when you notified the controller.
  7. The ZZLD and the KZLD: what we know and what we could not check

    Art. 33 speaks of "the supervisory authority competent in accordance with Article 55". In Bulgaria this is the Commission for Personal Data Protection (KZLD, in Bulgarian КЗЛД). Here are the provisions of the Personal Data Protection Act (ZZLD, ЗЗЛД) we rely on — verbatim from the consolidated text on lex.bg (an unofficial source; last amendment there: State Gazette No. 69 of 31.07.2026), checked on 01.10.2026. Only the Bulgarian text has legal force; the English rendering is ours.

    📜
    ZZLD, Art. 6(1) — who the supervisory authority is
    „(1) (Изм. - ДВ, бр. 17 от 2019 г.) Комисията за защита на личните данни, наричана по-нататък "комисията", е постоянно действащ независим надзорен орган, който осъществява защитата на лицата при обработването на техните лични данни и при осъществяването на достъпа до тези данни, както и контрола по спазването на Регламент (ЕС) 2016/679 и на този закон.“
    Unofficial English rendering: "(1) (Amended, State Gazette No. 17 of 2019) The Commission for Personal Data Protection, hereinafter 'the Commission', is a permanent independent supervisory authority that protects individuals in the processing of their personal data and in access to such data, and supervises compliance with Regulation (EU) 2016/679 and with this Act."
    📜
    ZZLD, Art. 15(2) — the KZLD's non-public registers
    „(2) Комисията води следните регистри, които не са публични:
    1. регистър на нарушенията на Регламент (ЕС) 2016/679 и на този закон, както и на предприетите мерки в съответствие с упражняването на правомощията по чл. 58, параграф 2 от Регламент (ЕС) 2016/679;
    2. регистър на уведомленията за нарушения на сигурността на личните данни по чл. 33 от Регламент (ЕС) 2016/679 и по чл. 67.“
    Unofficial English rendering: "(2) The Commission keeps the following registers, which are not public: 1. a register of infringements of Regulation (EU) 2016/679 and of this Act, and of the measures taken in exercising the powers under Art. 58(2) of Regulation (EU) 2016/679; 2. a register of personal data breach notifications under Art. 33 of Regulation (EU) 2016/679 and under Art. 67."

    So: a notification under Art. 33 goes to the KZLD and is entered in its non-public register. Exception: where data are processed by a court acting in its judicial capacity, or by the prosecution and investigating bodies for criminal-justice purposes, supervision lies with the Inspectorate to the Supreme Judicial Council (ZZLD, Art. 17(1)) — if you work in such an organisation, ask your lawyer ⚠️. We did not check the current way of filing a breach notification — a form, a portal or e-mail. On 01.10.2026 only the home page of the KZLD website opened, not a page with the procedure, so we do not give you an address or a form "from memory" ⚠️.

    • Open the KZLD website 🌐 and find the current way to notify a breach.
    • Check in the official State Gazette whether the ZZLD has been amended after 31.07.2026.
    • Fill in the draft from step 6 so that its content matches Art. 33(3), and have yourself or your lawyer file it through the current channel.

    This is guidance, not legal advice. It is confirmed by the lawyer or data protection officer in your organisation.

  8. A trial with a fictional case

    1. Index the Regulation. Ask five questions — for example about the content of the notification, the deadline, the agreement with a processor, communicating to data subjects. Note: does the right article come up? Is there a question the system should refuse, and does it?
    2. Run python dpa_draft.py and check that clauses (a) to (h) match the quote above (in Bulgarian).
    3. Run python breach_tracker.py — the fictional example of a lost drive. Look at the deadline, the log and the draft with its blanks.
    4. Read everything together with a lawyer and write down what they changed. That is your quality assessment — not figures we did not measure.
    ✅
    Practical minimum
    Nothing goes to an external service. Incident records have restricted access and do not go into application logs. A real incident is handled under the organisation's own procedure, not through a trial of this lesson.

04Check

Quiz

1. What time limit does a processor have under Art. 33(2) to notify the controller of a breach?

2. Who decides whether a breach is likely to result in a risk?

3. Why are the Art. 28(3) clauses in the draft copied verbatim and not written by a model?

4. What does the system do if the closest passage is below the similarity threshold?

05What next

06Sources

  1. Regulation (EU) 2016/679 on EUR-Lex (official English text, OJ L 119, 4.5.2016, p. 1) · official Bulgarian text 🌐 global — Art. 4(12), 28, 33, 34 (full text quoted in step 2).
  2. pgvector 🔒 local — CREATE EXTENSION vector, operator <=> (cosine distance).
  3. Ollama: API (/api/embed, truncate) · nomic-embed-text · llama3.1 🔒 local — sizes and context.
  4. psycopg · httpx — versions and licences.
  5. Personal Data Protection Act (ZZLD) on lex.bg — Art. 6(1), 15(2) and 17(1); consolidated text (unofficial; last amendment there State Gazette No. 69 of 31.07.2026), checked on 01.10.2026. The official source is the State Gazette.
  6. Commission for Personal Data Protection (KZLD) 🌐 global — current way of notifying (⚠️ not checked by us: on 01.10.2026 only the home page opened).