Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03
IDENTITY
module
GX10-04-202 · Audit-log narrative with a local AI model
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Intermediate
duration
2–3 h
prerequisites
A GB10-class machine with DGX OS (Arm64), Docker, Python 3.10+, Ollama installed per its official docs, basic SQL
trust_label
UPDATED 2026-10-03 (checked against official PostgreSQL, Docker Hub, Ollama and WeasyPrint documentation) · NOT TESTED: no command or script in this lesson was run on a GB10 machine
versions
PostgreSQL 18 (18.6 current, supported until 2030-11-14 per postgresql.org) · image pgvector/pgvector:0.8.7-pg18 (multi-arch amd64+arm64) · Ollama model bge-m3 (567M parameters, 1.2 GB, MIT, up to 8192 tokens) · WeasyPrint 70.0 (Python 3.10+, Pango 1.44+)
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
GX10 series index / 04-203_NemoClaw_Local_Agent.html
PURPOSE

Turn an audit log into a readable report on a local machine: SQL rules find anomalies, a local language model only describes the facts it is given, a PDF carries a SHA-256 fingerprint. No data leaves the machine. Not legal advice; a hash proves integrity only, not time or author.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-203 · NemoClaw: local model and Telegram (04-203_NemoClaw_Local_Agent.html) · series index: kagami.bg/academy/gx10/ · related: 04-10 vLLM, 04-217 local LLM on GB10

SOURCES
TAGS
gx10nvidia-gb10audit-logpostgresqlpgvectorollamaweasyprintsha-256gdpr
ОБНОВЕНО · 03.10.2026

Одитен дневник на GX10: разказ с локален AI

Превръщаме суров одитен дневник в четим разказ: правила в SQL намират странното, локален модел го описва на човешки език, а готовият PDF носи отпечатък, за да се вижда дали е пипан. Данните не напускат машината.

⏱ 2–3 ч Средно GX10 NVIDIA GB10 · 128 GB обща памет PostgreSQL 18 · pgvector · Ollama
Docker · PostgreSQL 18 + pgvector🔒 локално Ollama (вектори и езиков модел)🔒 локално WeasyPrint (PDF)🔒 локално
🔄
ОБНОВЕНО · 03.10.2026 — какво
Урокът е преработен по текущите документи. Махнахме: твърденията за „оттеглен“ модел и за конкретен модел с име (не можем да ги докажем — сега пишем <име-на-модела>), PostgreSQL 16 (сега е 18), правилото CREATE RULE … DO INSTEAD NOTHING (то мълчаливо игнорира промените — заменено с тригер, който вдига грешка), публикуването на порта към цялата мрежа (сега само 127.0.0.1), паролата в командата (сега е във .env), разказа за клиент и вътрешните адреси. Поправихме: хешът на PDF не може да стои вътре в същия PDF — пазим хеш на съдържанието в текста и хеш на файла в отделен .sha256. Добавихме: проверка, че моделът не цитира несъществуващи събития, груба сметка за паметта, GDPR и бележка какво доказва и какво не доказва един хеш. Сверка 03.10.2026: сроковете на чл. 50 след „Digital Omnibus“ са проверени по AI Act Service Desk и ЕК — прилага се от 2.08.2026.
⚠️
Какво не сме пускали сами
Нямахме машина от класа GB10 и не пуснахме нито една команда или скрипт от урока — затова няма етикет „ТЕСТВАНО“. Не сме мерили скорост или качество на български текст. Размерът на вектора на BGE-M3 (1024) е по описанието на модела — скриптът го проверява. Бележката за електронен времеви печат (eIDAS) е обща и не е правно становище.

01Какво ще научиш

02Преди да започнеш

💡
Груба сметка за паметта
Паметта на машината е обща — 128 GB за процесора и видеокартата. Теглата на модела заемат приблизително параметри × битове ÷ 8; отгоре идват кешът за контекста и самата система. Пример: 8 милиарда параметъра при 4 бита са около 4 GB; 70 милиарда при 4 бита — около 35 GB; 70 милиарда при 16 бита — около 140 GB, което не се събира. Това е сметка, не измерване — реалният размер на файла на модела е на страницата му в Ollama. Модел за разказа избери по такава сметка и го провери с Ollama.
⚠️
Лични данни и правна рамка (не е правен съвет)
Одитният дневник обикновено съдържа лични данни (потребители, IP адреси). Обработката по Регламент (ЕС) 2016/679 (GDPR) изисква основание, ограничаване на достъпа и срок за съхранение; сигурността на обработката е предмет на неговите принципи за цялост и поверителност и на изискванията за сигурност. Локалната обработка помага, но не заменя решение за срок, достъп и права на субектите. За договор, спор или проверка се консултирай с юрист и длъжностното лице по защита на данните, ако имаш такова.

03Стъпки

  1. Какво строим и защо така

    Четири части, всяка с една задача. Защо да не пуснем целия дневник в модела? Модел, който сам „открива“ аномалии, може да пропусне или да измисли. Затова търсенето е детерминирано (SQL), а моделът само обяснява вече намерените факти.

    ЧастЗадачаКъде върви
    PostgreSQL 18 + pgvectorДневникът, само за добавяне; вектори за търсене по смисълDocker, достъпен само от машината
    SQL правилаНощен достъп, повтарящи се неуспешни опити, много адресиВ базата
    OllamaВектори (bge-m3) и езиков модел за текстаЛокално
    WeasyPrintPDF с отпечатък на съдържаниетоЛокално
  2. База с pgvector (порт само към 127.0.0.1)

    Паролата се генерира на място и стои във .env, не в командата. Образът е pgvector/pgvector:0.8.7-pg18 (към 03.10.2026 е публикуван за amd64 и arm64). Данните стоят в именуван том. Редът PGDATA пази старото място на данните при PostgreSQL 18, както в урок 04-01.

    bash
    mkdir -p ~/audit-narrative && cd ~/audit-narrative
    echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" > .env
    chmod 600 .env
    
    docker run -d --name audit-db --restart unless-stopped \
      --env-file .env -e POSTGRES_USER=audit -e POSTGRES_DB=audit \
      -e PGDATA=/var/lib/postgresql/data \
      -p 127.0.0.1:5432:5432 \
      -v audit_db:/var/lib/postgresql/data \
      pgvector/pgvector:0.8.7-pg18

    PostgreSQL 18 е текущата поддържана версия (18.6, поддръжка до 14.11.2030 по postgresql.org). Командата не е пускана от нас.

  3. Таблиците: дневник само за добавяне

    Ето защо тригер, а не правило: правилото DO INSTEAD NOTHING тихо игнорира UPDATE и DELETE — „успява“, без да направи нищо. Тригерът вдига грешка и записът се вижда. Отделен тригер хваща и TRUNCATE.

    sql · schema.sql
    -- schema.sql
    CREATE EXTENSION IF NOT EXISTS vector;
    
    CREATE TABLE audit_log (
      id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      ts        timestamptz NOT NULL,
      actor     text        NOT NULL,
      action    text        NOT NULL,
      resource  text,
      outcome   text        NOT NULL CHECK (outcome IN ('ok','fail')),
      source_ip inet,
      details   text
    );
    CREATE INDEX ON audit_log (ts);
    CREATE INDEX ON audit_log (actor, ts);
    
    CREATE FUNCTION audit_log_readonly() RETURNS trigger
    LANGUAGE plpgsql AS $$
    BEGIN
      RAISE EXCEPTION 'audit_log е само за добавяне (append-only)';
    END $$;
    
    CREATE TRIGGER audit_log_no_change
      BEFORE UPDATE OR DELETE ON audit_log
      FOR EACH ROW EXECUTE FUNCTION audit_log_readonly();
    CREATE TRIGGER audit_log_no_truncate
      BEFORE TRUNCATE ON audit_log
      FOR EACH STATEMENT EXECUTE FUNCTION audit_log_readonly();
    
    CREATE TABLE audit_embeddings (
      log_id    bigint PRIMARY KEY REFERENCES audit_log(id),
      embedding vector(1024) NOT NULL
    );
    CREATE INDEX ON audit_embeddings USING hnsw (embedding vector_cosine_ops);

    Зареди го и провери, че промяната е забранена:

    bash
    docker exec -i audit-db psql -U audit -d audit < schema.sql
    docker exec -i audit-db psql -U audit -d audit -c "DELETE FROM audit_log;" 
    ⚠️
    Тригерът не е крепост
    Собственикът на таблицата и суперпотребителят могат да махнат тригера. В реална среда приложението се свързва с роля само с права за INSERT и SELECT, а копия се пазят на друго място. Тригерът е предпазител срещу грешки, не срещу злонамерен администратор.
  4. Измислени данни за проба

    Адресите 198.51.100.x са блок, запазен за документация. Данните имат три „странни“ неща: четири неуспешни входа за няколко минути, нощен достъп и един потребител с повече от два адреса.

    sql · seed.sql
    -- измислени данни: 198.51.100.x е адресен блок за документация
    INSERT INTO audit_log (ts, actor, action, resource, outcome, source_ip, details)
    SELECT timestamptz '2026-09-30 09:00+03' + g * interval '17 minutes',
           'user_a', 'view', 'report-' || g, 'ok', ('198.51.100.' || 1)::inet, 'преглед на справка'
    FROM generate_series(1, 6) g;
    
    INSERT INTO audit_log (ts, actor, action, resource, outcome, source_ip, details)
    SELECT timestamptz '2026-09-30 23:10+03' + g * interval '2 minutes',
           'user_b', 'login', 'portal', 'fail', ('198.51.100.' || (10 + g))::inet, 'неуспешен вход'
    FROM generate_series(1, 4) g;
    
    INSERT INTO audit_log (ts, actor, action, resource, outcome, source_ip, details)
    VALUES ('2026-10-01 02:30+03', 'user_b', 'download', 'export-archive', 'ok', ('198.51.100.' || 99)::inet, 'нощно изтегляне');
    bash
    docker exec -i audit-db psql -U audit -d audit < seed.sql
  5. Вектори с Ollama и търсене по смисъл

    Теглим модела за вектори (bge-m3: 567 милиона параметъра, 1,2 GB, лиценз MIT, до 8192 токена — по страницата му в Ollama). Скриптът проверява, че векторът има размера на колоната — ако смениш модела, размерът ще е друг.

    bash
    curl -s http://127.0.0.1:11434/api/version
    ollama pull bge-m3
    pip install httpx "psycopg[binary]" weasyprint
    set -a; source .env; set +a
    python index_embeddings.py
    python · index_embeddings.py
    # index_embeddings.py
    import os, httpx, psycopg
    
    OLLAMA = "http://127.0.0.1:11434"
    DSN = ("host=127.0.0.1 dbname=audit user=audit "
           f"password={os.environ['POSTGRES_PASSWORD']}")
    
    def embed(texts):
        r = httpx.post(f"{OLLAMA}/api/embed",
                       json={"model": "bge-m3", "input": texts}, timeout=300)
        r.raise_for_status()
        return r.json()["embeddings"]
    
    with psycopg.connect(DSN) as conn:
        rows = conn.execute("""
            SELECT l.id, concat_ws(' ', l.actor, l.action, l.resource, l.outcome, l.details)
            FROM audit_log l LEFT JOIN audit_embeddings e ON e.log_id = l.id
            WHERE e.log_id IS NULL ORDER BY l.id LIMIT 200""").fetchall()
        if rows:
            vecs = embed([t for _, t in rows])
            assert all(len(v) == 1024 for v in vecs), "размерът на вектора не съвпада с колоната"
            for (log_id, _), v in zip(rows, vecs):
                conn.execute(
                    "INSERT INTO audit_embeddings (log_id, embedding) VALUES (%s, %s::vector)",
                    (log_id, "[" + ",".join(map(str, v)) + "]"))
        print("индексирани:", len(rows))
    
        # най-близки по смисъл събития до събитие 1
        near = conn.execute("""
            SELECT l.id, l.actor, l.action, l.details
            FROM audit_embeddings e JOIN audit_log l ON l.id = e.log_id
            ORDER BY e.embedding <=> (SELECT embedding FROM audit_embeddings WHERE log_id = 1)
            LIMIT 5""").fetchall()
        for n in near: print(n)

    Командите и скриптът не са пускани от нас. Търсенето по смисъл е помощно — разказът по-долу се гради само на SQL фактите.

  6. Правилата за аномалии в SQL

    Три прости правила. Те са откритията; моделът няма право да добавя свои. Часовата зона е часът на потребителя (Europe/Sofia) — смени я при нужда. Съберени са в скрипта по-долу.

    sql
    -- 1. достъп през нощта (22:00-06:00 местно време)
    SELECT id, ts, actor, action, resource FROM audit_log
    WHERE ts >= '2026-09-30' AND ts < '2026-10-02'
      AND (extract(hour FROM ts AT TIME ZONE 'Europe/Sofia') >= 22
        OR extract(hour FROM ts AT TIME ZONE 'Europe/Sofia') < 6)
    ORDER BY ts;
    
    -- 2. три или повече неуспешни опита на един потребител за 15 минути
    SELECT actor, date_bin('15 minutes', ts, timestamptz '2026-01-01') AS slot,
           count(*) AS failed, array_agg(id ORDER BY ts) AS ids
    FROM audit_log
    WHERE ts >= '2026-09-30' AND ts < '2026-10-02' AND outcome = 'fail'
    GROUP BY actor, slot HAVING count(*) >= 3;
    
    -- 3. повече от два различни адреса на един потребител за денонощие
    SELECT actor, (ts AT TIME ZONE 'Europe/Sofia')::date AS day,
           count(DISTINCT source_ip) AS addresses
    FROM audit_log
    WHERE ts >= '2026-09-30' AND ts < '2026-10-02'
    GROUP BY actor, day HAVING count(DISTINCT source_ip) > 2;
  7. Разказът и PDF с отпечатък

    Скриптът събира фактите, праща ги на локалния модел с ниска „температура“ (0,1), проверява дали всяко цитирано [#id] съществува и чак тогава прави PDF. Вместо име на модел пиши твоя избор (виж сметката за паметта; за локален модел на GB10 виж и урок 04-217). Качеството на български не сме измервали — прочети резултата.

    python · narrative.py
    # narrative.py
    import os, sys, re, json, hashlib, html
    import httpx, psycopg
    from psycopg.rows import dict_row
    from weasyprint import HTML
    
    OLLAMA = "http://127.0.0.1:11434"
    MODEL = os.environ.get("NARRATIVE_MODEL", "<име-на-модела>")
    DSN = ("host=127.0.0.1 dbname=audit user=audit "
           f"password={os.environ['POSTGRES_PASSWORD']}")
    SYSTEM = (
        "Ти си помощник за одитен преглед. Пишеш САМО по подадените факти във формат JSON. Не измисляй имена, часове, числа и събития. Всяко събитие, което споменаваш, посочвай като [#id]. Пиши четири раздела с тези заглавия: ВЪВЕДЕНИЕ, ХРОНОЛОГИЯ, АНОМАЛИИ, ЗАКЛЮЧЕНИЕ. Не правиш изводи за вина и не даваш правни оценки.")
    
    QUERIES = {
        "night": """-- 1. достъп през нощта (22:00-06:00 местно време)
    SELECT id, ts, actor, action, resource FROM audit_log
    WHERE ts >= %(a)s AND ts < %(b)s
      AND (extract(hour FROM ts AT TIME ZONE 'Europe/Sofia') >= 22
        OR extract(hour FROM ts AT TIME ZONE 'Europe/Sofia') < 6)
    ORDER BY ts;""",
        "failed": """-- 2. три или повече неуспешни опита на един потребител за 15 минути
    SELECT actor, date_bin('15 minutes', ts, timestamptz '2026-01-01') AS slot,
           count(*) AS failed, array_agg(id ORDER BY ts) AS ids
    FROM audit_log
    WHERE ts >= %(a)s AND ts < %(b)s AND outcome = 'fail'
    GROUP BY actor, slot HAVING count(*) >= 3;""",
        "addresses": """-- 3. повече от два различни адреса на един потребител за денонощие
    SELECT actor, (ts AT TIME ZONE 'Europe/Sofia')::date AS day,
           count(DISTINCT source_ip) AS addresses
    FROM audit_log
    WHERE ts >= %(a)s AND ts < %(b)s
    GROUP BY actor, day HAVING count(DISTINCT source_ip) > 2;""",
    }
    
    def collect(conn, a, b):
        p = {"a": a, "b": b}
        facts = {"period": [a, b]}
        facts["events"] = conn.execute(
            "SELECT id, ts, actor, action, resource, outcome FROM audit_log "
            "WHERE ts >= %(a)s AND ts < %(b)s ORDER BY ts LIMIT 300", p).fetchall()
        for name, q in QUERIES.items():
            facts[name] = conn.execute(q, p).fetchall()
        return facts
    
    def narrate(facts):
        r = httpx.post(f"{OLLAMA}/api/chat", timeout=900, json={
            "model": MODEL, "stream": False, "options": {"temperature": 0.1},
            "messages": [
                {"role": "system", "content": SYSTEM},
                {"role": "user", "content": json.dumps(facts, ensure_ascii=False, default=str)}]})
        r.raise_for_status()
        return r.json()["message"]["content"]
    
    def validate(text, facts):
        known = {e["id"] for e in facts["events"]}
        cited = {int(x) for x in re.findall(r"\[#(\d+)\]", text)}
        bad = cited - known
        if bad:
            raise SystemExit(f"Моделът цитира несъществуващи събития: {sorted(bad)}")
    
    def to_pdf(text, facts, path):
        payload = json.dumps({"facts": facts, "narrative": text},
                             sort_keys=True, ensure_ascii=False, default=str)
        content_hash = hashlib.sha256(payload.encode()).hexdigest()
        page = (f"<html lang='bg'><body style='font-family: sans-serif'>"
                f"<h1>Одитен разказ</h1><pre style='white-space: pre-wrap'>{html.escape(text)}</pre>"
                f"<p style='font-size: 9pt'>Съставен с AI от подадени факти; прегледан от човек: ______. SHA-256 на съдържанието: {content_hash}</p></body></html>")
        HTML(string=page).write_pdf(path)
        file_hash = hashlib.sha256(open(path, "rb").read()).hexdigest()
        open(path + ".sha256", "w").write(f"{file_hash}  {os.path.basename(path)}\n")
    
    if __name__ == "__main__":
        a, b = sys.argv[1], sys.argv[2]
        with psycopg.connect(DSN, row_factory=dict_row) as conn:
            facts = collect(conn, a, b)
        text = narrate(facts)
        validate(text, facts)
        to_pdf(text, facts, "report.pdf")
        print("report.pdf + report.pdf.sha256")
    bash
    export NARRATIVE_MODEL="<име-на-модела>"
    ollama pull "$NARRATIVE_MODEL"
    python narrative.py 2026-09-30 2026-10-02
    sha256sum -c report.pdf.sha256

    Ако в PDF вместо букви виждаш квадрати, инсталирай шрифт с кирилица (препоръка от документацията на WeasyPrint 70.0, която иска Python 3.10+ и Pango 1.44+).

    ✅
    Какво доказва отпечатъкът — и какво не
    SHA-256 показва, че файлът не е променян от момента, в който отпечатъкът е записан на сигурно място. Не доказва кога е създаден, от кого и че съдържанието е вярно. За стойност пред трети страни се гледат квалифициран електронен времеви печат и подпис по Регламент (ЕС) № 910/2014 (eIDAS) — ⚠️ не сме го проверявали в детайли; питай юрист. Пази .sha256 на друго място от PDF.
  8. Преглед от човек и означение

    Разказът е чернова. Човек го чете срещу самите събития, подписва и чак тогава го ползва. Означи в документа, че е съставен с AI. Задълженията за прозрачност по чл. 50 от Регламент (ЕС) 2024/1689 (AI Act) зависят от случая. Проверено на 03.10.2026 по графика на прилагането в AI Act Service Desk и съобщението на ЕК „AI Omnibus enters into force“: Регламент (ЕС) 2026/1744 („Digital Omnibus“ за AI) е в сила от 27.07.2026 и не отлага чл. 50 — правилата за прозрачност се прилагат от 2.08.2026. Виж и консолидирания текст в EUR-Lex. Като добра практика означението е полезно и за вътрешен документ.

04Проверка

Тест

1. Защо хешът на PDF файла е в отделен .sha256 файл, а не вътре в PDF?

2. Какво доказва SHA-256 отпечатъкът сам по себе си?

3. Каква е ролята на езиковия модел в този урок?

4. Защо тригерът е предпочетен пред DO INSTEAD NOTHING?

05Какво следва

06Източници

  1. PostgreSQL: политика за версиите · CREATE TRIGGER — 18.6 е текущата; тригери за UPDATE, DELETE, TRUNCATE.
  2. pgvector в Docker Hub · pgvector — таг 0.8.7-pg18, HNSW и косинусова близост.
  3. Ollama: bge-m3 · Ollama: API за вектори 🔒 локално — размер, лиценз, /api/embed.
  4. WeasyPrint 70.0: първи стъпки — изисквания, шрифтове, сигурност.
  5. Регламент (ЕС) 2016/679 (GDPR) · Регламент (ЕС) № 910/2014 (eIDAS) — официален текст в EUR-Lex.
  6. Регламент (ЕС) 2024/1689 (AI Act), консолидиран текст — прозрачност (чл. 50).