Одитен дневник на GX10: разказ с локален AI
Превръщаме суров одитен дневник в четим разказ: правила в SQL намират странното, локален модел го описва на човешки език, а готовият PDF носи отпечатък, за да се вижда дали е пипан. Данните не напускат машината.
<име-на-модела>), 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.01Какво ще научиш
- Как да превърнеш суров одитен дневник в четим разказ без данните да напускат машината.
- Как да направиш таблица, в която записите могат само да се добавят (append-only), и защо тригерът е по-добър от правило.
- Как правилата в SQL откриват странното, а моделът само го описва — и как да хванеш модела, когато измисля.
- Как да направиш PDF с отпечатък (SHA-256) и какво той доказва и не доказва.
- Как да направиш груба сметка дали един модел ще се събере в 128 GB обща памет.
02Преди да започнеш
- Машина от класа NVIDIA GB10 (ASUS Ascent GX10, DGX Spark) с DGX OS. Върху нея Docker (предварително инсталиран по документацията на NVIDIA) и Python 3.10 или по-нов.
- Ollama, инсталиран по официалната му документация. По подразбиране слуша само на
127.0.0.1:11434. - Основни познания по SQL. Нямаш нужда от реален дневник — ще ползваме измислени данни.
- Свободно място на диска за образите и моделите (
df -h). - Малко повече внимание за личните данни: одитните дневници съдържат потребителски имена и адреси — виж кутията по-долу.
03Стъпки
-
Какво строим и защо така
Четири части, всяка с една задача. Защо да не пуснем целия дневник в модела? Модел, който сам „открива“ аномалии, може да пропусне или да измисли. Затова търсенето е детерминирано (SQL), а моделът само обяснява вече намерените факти.
Част Задача Къде върви PostgreSQL 18 + pgvector Дневникът, само за добавяне; вектори за търсене по смисъл Docker, достъпен само от машината SQL правила Нощен достъп, повтарящи се неуспешни опити, много адреси В базата Ollama Вектори (bge-m3) и езиков модел за текста Локално WeasyPrint PDF с отпечатък на съдържанието Локално -
База с pgvector (порт само към 127.0.0.1)
Паролата се генерира на място и стои във
.env, не в командата. Образът еpgvector/pgvector:0.8.7-pg18(към 03.10.2026 е публикуван за amd64 и arm64). Данните стоят в именуван том. РедътPGDATAпази старото място на данните при PostgreSQL 18, както в урок 04-01.bashmkdir -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-pg18PostgreSQL 18 е текущата поддържана версия (18.6, поддръжка до 14.11.2030 по postgresql.org). Командата не е пускана от нас.
-
Таблиците: дневник само за добавяне
Ето защо тригер, а не правило: правилото
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);Зареди го и провери, че промяната е забранена:
bashdocker 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, а копия се пазят на друго място. Тригерът е предпазител срещу грешки, не срещу злонамерен администратор. -
Измислени данни за проба
Адресите
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, 'нощно изтегляне');bashdocker exec -i audit-db psql -U audit -d audit < seed.sql -
Вектори с Ollama и търсене по смисъл
Теглим модела за вектори (
bge-m3: 567 милиона параметъра, 1,2 GB, лиценз MIT, до 8192 токена — по страницата му в Ollama). Скриптът проверява, че векторът има размера на колоната — ако смениш модела, размерът ще е друг.bashcurl -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.pypython · 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 фактите.
-
Правилата за аномалии в 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; -
Разказът и 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")bashexport 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. -
Преглед от човек и означение
Разказът е чернова. Човек го чете срещу самите събития, подписва и чак тогава го ползва. Означи в документа, че е съставен с 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Проверка
docker psпоказва127.0.0.1:5432, не0.0.0.0.DELETE,UPDATEиTRUNCATEвърхуaudit_logдават грешка.- Скриптът за вектори не спира на проверката за размер.
- Трите заявки връщат очакваните редове върху измислените данни.
- Разказът цитира само събития, които съществуват.
- PDF носи отпечатък на съдържанието;
.sha256стои на друго място. - Документът е означен като съставен с AI и е прегледан от човек.
Тест
1. Защо хешът на PDF файла е в отделен .sha256 файл, а не вътре в PDF?
2. Какво доказва SHA-256 отпечатъкът сам по себе си?
3. Каква е ролята на езиковия модел в този урок?
4. Защо тригерът е предпочетен пред DO INSTEAD NOTHING?
05Какво следва
06Източници
- PostgreSQL: политика за версиите · CREATE TRIGGER — 18.6 е текущата; тригери за UPDATE, DELETE, TRUNCATE.
- pgvector в Docker Hub · pgvector — таг
0.8.7-pg18, HNSW и косинусова близост. - Ollama: bge-m3 · Ollama: API за вектори 🔒 локално — размер, лиценз,
/api/embed. - WeasyPrint 70.0: първи стъпки — изисквания, шрифтове, сигурност.
- Регламент (ЕС) 2016/679 (GDPR) · Регламент (ЕС) № 910/2014 (eIDAS) — официален текст в EUR-Lex.
- Регламент (ЕС) 2024/1689 (AI Act), консолидиран текст — прозрачност (чл. 50).