Перейти к содержимому

3.1 · Проект 1: архитектура бота и копия базы

Неделя 3 · Основы LLM · ⏱ ~4 ч · Проект

Это первый проект в портфолио: Telegram-бот «аналитик магазина», который отвечает владельцу интернет-магазина «Полка» на вопросы о заказах и продажах через tool calling. На собеседовании про такой проект спросят не «какой промпт», а «как вы не дали модели сделать что-то опасное» и «откуда она берёт данные». Сегодня закладываем ответы на оба вопроса: модель видит данные только через функции, функции видят только копию базы, а копия открыта только на чтение.

⚠️ Только чтение. Никаких функций, которые меняют заказы, оформляют возвраты или выдают промокоды, и никаких запросов к платёжному провайдеру и службе доставки с ключами — только копия базы.

Бот — это «аналитик», а не «менеджер магазина». Он умеет ровно одно: превратить вопрос на естественном языке («какая выручка за неделю?») в вызов одной или нескольких читающих функций и пересказать результат человеческим языком. Всё, что может изменить состояние мира — изменение заказа, возврат, промокод, запросы к платёжному провайдеру и службе доставки с ключами, — в проекте отсутствует физически, а не «запрещено промптом». Промпт — это просьба, а не защита: модель можно уговорить, сбить с толку длинной историей, или она просто ошибётся. Защита — это архитектура, в которой опасного действия нет в коде.

Принцип, который стоит запомнить на всю карьеру: у LLM ровно те права, которые есть у функций, которые мы ей дали. Нужно, чтобы модель не могла удалить данные, — не даём ей функцию с DELETE и не открываем базу на запись.

Telegram (владелец магазина)
│ сообщение
▼
┌──────────────┐ чужой user_id → отказ
│ aiogram bot │
└──────┬───────┘
│ вопрос + история + системный промпт с датой
▼
┌──────────────┐ tool_use: get_sales(period="week")
│ call_llm() │◄──────────────────────────────┐
│ (Ollama/DS) │──────────┐ │
└──────────────┘ ▼ │ tool_result (компактный JSON)
┌─────────────────┐ │
│ tools.py │─────────┘
│ 4 читающие │
│ функции │
└───────┬─────────┘
│ SELECT ... (mode=ro, query_only)
▼
┌───────────────────┐
│ data/shop.db │ ← VACUUM INTO из живой базы
│ data/analytics.db │ (вручную, по команде)
└───────────────────┘
Живые базы магазина (../shop-live/) — бот их НЕ открывает никогда.
Снимок (персональные данные покупателей) видит только локальная
модель (Ollama); облачные модели — только демо-базу (см. ниже).

Обратим внимание на три слоя защиты:

  1. Слой данных. Бот работает со снимком, а не с живой базой. Даже если всё остальное сломается, приложение магазина ничего не заметит.
  2. Слой соединения. Снимок открыт с mode=ro в URI и с PRAGMA query_only = ON. Любой INSERT/UPDATE/DELETE упадёт с ошибкой.
  3. Слой инструментов. Модель не пишет SQL. Она вызывает 4 функции с типизированными параметрами; SQL внутри функций пишем мы. (Вариант «дать модели text-to-SQL» обсудим ниже — для этого проекта он хуже.)

Аналогия из веб-разработки: это как если бы фронтенд вызывал только GET-роуты, а на сервере вообще не было бы POST /api/orders/{id}/refund. Не «роут есть, но кнопка спрятана», а роута нет.

Перед тем как проектировать функции, нужно знать данные. У приложения магазина две SQLite-базы, обе лежат рядом с репозиторием, в папке shop-live/ (на уровень выше репозитория):

Файл Что внутри Нужно боту
shop.db customers, products, orders, order_items, payments, shipments, events, settings products, orders, order_items, payments
shop-analytics.db product_stats (часовые корзины: просмотры и добавления в корзину по товару), searches product_stats

Схема (имена таблиц и колонок дальше используются во всех главах проекта):

customers(id, name, email, phone, city, created_at)
products(id, sku, name, category, unit, price, cost, stock, is_active) -- unit: 'pcs' | 'g'
orders(id, customer_id, status, created_at, paid_at, shipped_at, delivered_at, closed_at,
delivery_method, delivery_cost, promo_code, discount)
-- status: new | paid | shipped | delivered | cancelled | refunded
order_items(id, order_id, product_id, quantity, unit_price, unit_cost)
payments(id, order_id, provider, amount, fee, status, created_at) -- status: succeeded | failed | refunded
shipments(id, order_id, carrier, tracking_number, status, updated_at)
events(id, order_id, time, type, message)
product_stats(time, product_id, views, cart_adds) -- time: integer, hourly buckets

Откуда берутся «живые» базы. Работающего магазина у нас нет, поэтому базы создаём сами: scripts/make_shop_db.py --seed 1 --out ../shop-live/ (генератор пишем с помощью модели в практике). Он заполняет обе базы данными за 30 дней, включая выдуманных покупателей с email и телефонами, и оставляет их в режиме WAL. Дальше считаем, что в эти базы прямо сейчас пишет приложение магазина. Данные выдуманы, но обращаемся с ними как с настоящими — эта привычка понадобится на работе. Если есть свои данные и право их использовать, можно взять их — правила главы те же.

Ключевые факты, которые повлияют на функции:

  • Один заказ = несколько строк в order_items, по одной на товар, с одинаковым order_id. Выручка и прибыль заказа — сумма по его строкам.
  • Сумма и прибыль заказа в базе не хранятся. Сумма заказа = сумма строк − discount + delivery_cost; прибыль = сумма строк − себестоимость строк − discount − fee (комиссия успешных платежей из payments). Считать придётся самим.
  • Количество означает разное: для unit = 'pcs' это штуки и цена за штуку; для unit = 'g' (чай, кофе) — граммы и цена (и unit_cost) за 100 г. Классический источник багов — держим правило в одном месте, в функции item_amount(unit, quantity, unit_price).
  • Фактических расходов на доставку в базе нет: магазин платит службам доставки по ежемесячному счёту, а delivery_cost — это то, что заплатил покупатель. Значит, прибыль из базы — это прибыль до расходов на доставку. Бот должен честно это говорить.
  • Даты created_at/closed_at и остальные пишутся в UTC в виде текста YYYY-MM-DD HH:MM:SS (так же их пишет current_timestamp в SQLite). «Сегодня» в часовом поясе владельца ≠ «сегодня» в UTC.
  • product_stats хранит 30 дней. Вопрос «сколько раз смотрели кружку „Север“ в августе» бот честно не может ответить.
  • В product_stats.time — целое число; секунды это или миллисекунды, проверяем одним SELECT time FROM product_stats LIMIT 1.
  • Возвраты и отмены — не выручка: заказы со статусами refunded и cancelled в выручку не входят.

Теперь понятно, что боту нужно из базы. Следующий вопрос — как получить эти данные, не трогая базу, в которую пишет приложение магазина. Ответ — работать с копией, и сделать её правильно сложнее, чем кажется.

Живая база в режиме WAL (write-ahead log). В этом режиме свежие изменения сначала пишутся в файл shop.db-wal, и только при checkpoint переезжают в основной файл. Если сделать cp shop.db, получится основной файл без последних транзакций из -wal, а если в момент копирования шёл checkpoint — ещё и рваные страницы. Копия выглядит нормально и может даже открыться, но данные в ней неконсистентны. Скопировать три файла (.db, -wal, -shm) тоже не вариант: между копированием первого и последнего приложение успеет записать.

VACUUM INTO 'путь' делает другое: открывает читающую транзакцию и записывает консистентный снимок базы в новый файл — со всеми закоммиченными изменениями из WAL, без мусора, компактно. Исходную базу он не меняет. Это официальный способ «бэкапа живой SQLite» наряду с Backup API (Connection.backup() в Python).

Окно терминала
# run from the shop-live folder next to the repo, where shop.db lives
sqlite3 shop.db "VACUUM INTO '/path/to/ai-engineering-journey/projects/01-shop-analyst-bot/data/shop.db'"
sqlite3 shop-analytics.db "VACUUM INTO '/path/to/ai-engineering-journey/projects/01-shop-analyst-bot/data/analytics.db'"

Важно: VACUUM INTO не перезаписывает существующий файл — если копия уже есть, её нужно удалить перед новой. И ещё одна деталь: у копии может сохраниться журнал WAL; чтобы потом открывать её строго на чтение без вспомогательных файлов, один раз переключаем её в обычный режим журнала:

Окно терминала
sqlite3 data/shop.db "PRAGMA journal_mode=DELETE;"

Это команда к копии, не к живой базе. К живой базе разрешена только одна операция — VACUUM INTO.

Копия есть; осталось открыть её так, чтобы бот физически не мог в неё писать. В Python sqlite3.connect() по умолчанию открывает файл на чтение и запись и создаёт пустую базу, если файла нет (опечатка в пути → бот молча работает с пустой базой и отвечает «заказов нет»). URI-режим решает обе проблемы:

import sqlite3
from pathlib import Path
def open_readonly(path: Path) -> sqlite3.Connection:
# mode=ro: read-only, and the file must already exist
uri = f"file:{path.resolve()}?mode=ro"
conn = sqlite3.connect(uri, uri=True)
conn.row_factory = sqlite3.Row # rows behave like dicts
conn.execute("PRAGMA query_only = ON") # second layer: reject any write
return conn
  • mode=ro — SQLite откроет файл только на чтение; если файла нет — ошибка unable to open database file, а не новая пустая база.
  • PRAGMA query_only = ON — второй замок на уровне соединения.
  • row_factory = sqlite3.Row — строки с доступом по имени колонки (row["unit_price"]), как словари.

Есть ещё immutable=1 в URI: говорит SQLite, что файл вообще никогда не меняется, и отключает блокировки. Для снимка это корректно, но если по ошибке направить его на живой файл — получится неконсистентное чтение. Для обучения хватит mode=ro.

Соблазн: дать модели одну функцию run_sql(query) и схему базы. Минусы для этого проекта:

4 узкие функции run_sql
Безопасность SQL пишем мы модель пишет любой SQL (даже на ro-базе — тяжёлые запросы, чтение всего, включая персональные данные покупателей)
Корректность прибыли правило штук/граммов, скидка и комиссия — в одном месте, покрыто тестами модель каждый раз заново «вспоминает», как считать прибыль
Размер ответа агрегаты, ≤ 20 строк легко вернуть 5000 строк в контекст
Тестируемость pytest на каждую функцию почти никакой

Text-to-SQL — легитимный паттерн для аналитики «на любые вопросы», но он требует evals и песочницы. Вернёмся к нему в главе 9.5 · MCP-сервер к базе магазина (только чтение): там свободный SQL допускается только как отдельный инструмент с тремя слоями защиты. Здесь — узкие функции.

  • Python 3.12+, менеджер окружения — uv или venv + pip.
  • aiogram 3 — асинхронный фреймворк ботов. Аналог: как веб-фреймворк (Flask, Express) для HTTP, только для Telegram-апдейтов. Ключевые понятия: Bot (клиент Bot API), Dispatcher (корневой роутер, раздаёт апдейты), Router (группы хендлеров), фильтры (Command, F.text), middleware.
  • openai SDK как клиент к Ollama и DeepSeek (оба говорят на формате OpenAI Chat Completions, отличается только base_url) + наша обёртка call_llm() из главы 2.7 · Стриминг, ретраи, rate limits, prompt caching. anthropic SDK — по желанию, для сравнения на демо-базе.
  • pydantic — модели результатов (гл. 3.2), pytest — тесты.

Проект живёт в учебном репозитории из гл. 1.2, в папке projects/01-shop-analyst-bot/ со своим pyproject.toml и README:

01-shop-analyst-bot/
├── README.md
├── .gitignore
├── .env.example # names of variables, no values
├── pyproject.toml
├── data/ # ignored: snapshots and demo db
├── scripts/
│ ├── make_shop_db.py # "live" shop databases in ../shop-live/ (this chapter)
│ ├── snapshot.sh # VACUUM INTO from the live db (manual run)
│ └── make_fake_db.py # demo data (AIL-14)
├── bot/
│ ├── main.py # aiogram entry point (AIL-15)
│ ├── db.py # open_readonly(), paths from env
│ ├── tools.py # read-only functions (AIL-14)
│ ├── llm_utils.py # call_llm() from AIL-11
│ └── config.py
└── tests/

Архитектура и данные понятны; осталось зафиксировать, что именно бот должен уметь. Прежде чем писать функции, записываем вопросы, которые владелец будет задавать. Из них в гл. 3.2 вырастут четыре функции: get_open_orders() — заказы в работе, get_sales(period) — выручка и прибыль за день, неделю или месяц, get_product_interest(product, period) — просмотры и добавления в корзину, get_recent_orders(period, limit) — последние заказы. Имена нужны уже сегодня: в questions.md у каждого вопроса отмечаем, какая функция на него отвечает.

Список из 10–15 вопросов — не формальность. Это наш первый мини-eval: в гл. 3.3 бот считается готовым, когда правильно отвечает на 12 вопросов из 15. Хороший список:

  • покрывает каждую функцию минимум дважды;
  • содержит вопросы, требующие двух функций («какие заказы сейчас в работе и какая выручка за неделю?»);
  • содержит относительные даты («вчера», «за прошлую неделю», «с начала месяца»);
  • содержит вопросы вне возможностей («оформи возврат по заказу 1042», «сколько мы на самом деле заплатили за доставку», «сколько раз смотрели кружку „Север“ в августе») — правильный ответ на них: вежливый отказ с объяснением;
  • для каждого вопроса записан ожидаемый ответ или способ его проверить (SQL-запрос, который мы выполним сами).

Главное решение проекта: какая модель видит данные снимка

Заголовок раздела «Главное решение проекта: какая модель видит данные снимка»

Результаты инструментов — это выручка, прибыль и заказы, а в снимке рядом лежат имена, email и телефоны покупателей. Всё, что вернула функция, уходит в контекст модели, то есть на сервер провайдера. Поэтому в этом проекте провайдер выбирается не по цене и не по качеству, а по данным:

Какие данные видит бот Ollama (локально) DeepSeek Claude / OpenAI
Снимок data/shop.db (персональные данные покупателей) ✅ основной путь ❌ никогда: облако, серверы в Китае ❌ облако, в этом проекте нет
Демо-база data/demo_shop.db (гл. 3.2) ✅ ✅ основной облачный путь ✅ только для сравнения

У Ollama модель крутится на нашей машине, запрос идёт на http://localhost:11434 и никуда не уходит. Оговорка: в библиотеке Ollama есть облачные модели (с пометкой cloud или суффиксом -cloud в теге, например gpt-oss:20b-cloud) — они выполняются на серверах Ollama. Для снимка — только локальные теги.

Это то же правило, что и с возвратами: защита — в коде, а не в дисциплине. Бот при старте сам проверяет, что облачному провайдеру подсунута только демо-база.

И DeepSeek, и Ollama принимают формат OpenAI Chat Completions (проверено по документации, ноябрь 2026): у DeepSeek base_url="https://api.deepseek.com", у Ollama — http://localhost:11434/v1/ и любой непустой api_key (он игнорируется). Значит, весь бот пишется на одном SDK openai, а провайдер переключается переменными окружения:

# bot/config.py (illustrative)
import os
from pathlib import Path
PROVIDERS = {
# name: (base_url, env var holding the key, runs on this machine?)
"ollama": ("http://localhost:11434/v1/", None, True),
"deepseek": ("https://api.deepseek.com", "DEEPSEEK_API_KEY", False),
}
DEMO_SHOP_DB = Path("data/demo_shop.db").resolve()
def llm_settings() -> tuple[str, str, str]:
provider = os.environ["LLM_PROVIDER"] # "ollama" | "deepseek"
base_url, key_env, _ = PROVIDERS[provider]
api_key = os.environ[key_env] if key_env else "ollama" # Ollama ignores the key
return provider, base_url, api_key
def assert_data_policy(provider: str, model: str, shop_db: Path) -> None:
# A cloud model may only ever see the demo database (no personal data).
_, _, local = PROVIDERS[provider]
if model.endswith("-cloud") or ":cloud" in model: # Ollama cloud tags run remotely
local = False
if not local and shop_db.resolve() != DEMO_SHOP_DB:
raise SystemExit(f"{provider} is a cloud provider: "
f"SHOP_DB_PATH must be {DEMO_SHOP_DB}, got {shop_db}")

assert_data_policy вызывается в main.py до того, как бот начнёт получать сообщения из Telegram (polling, глава 3.3), и в run_questions.py (скрипт прогона вопросов из questions.md, тоже глава 3.3) до первого вопроса. Если по привычке поставить LLM_PROVIDER=deepseek рядом с SHOP_DB_PATH=data/shop.db, бот не запустится — а не «запустится и отправит данные покупателей в облако». На это добавляем тест pytest.

Мелочь, на которой легко потерять час: если в окружении задан HTTP_PROXY/HTTPS_PROXY, openai-клиент (он на httpx) может отправить даже запрос к localhost в прокси. Добавляем NO_PROXY=localhost,127.0.0.1.

Claude OpenAI DeepSeek Ollama
Где выполняется облако Anthropic облако OpenAI облако, серверы в Китае наша машина (кроме cloud-тегов)
Можно снимок с персональными данными нет (в этом проекте) нет никогда да
Ключ ANTHROPIC_API_KEY OPENAI_API_KEY DEEPSEEK_API_KEY не нужен
Формат API свой Messages API Chat Completions / Responses OpenAI-совместимый; есть и Anthropic-совместимый https://api.deepseek.com/anthropic OpenAI-совместимый /v1 + нативный /api/chat
Деньги за токены за токены за токены, очень дёшево, есть пиковые и ночные цены (глава 3.4) 0; платим памятью, временем ответа и нагрузкой на машину
Tool calling да да да (deepseek-flash, deepseek-v4-pro) да, но качество зависит от модели и её размера (глава 3.3)
Работает офлайн нет нет нет да

Модели DeepSeek на ноябрь 2026 по странице цен: deepseek-flash и deepseek-v4-pro, обе с контекстом 1M; старое имя deepseek-v4-flash ещё принимается, но обслуживается новой моделью. Имена меняются — перед стартом проверяем страницу.

Главное ограничение выбора локальной модели — память, которую она делит со всем, что запущено на машине:

  • Ollama есть для macOS, Linux и Windows. На Mac она требует macOS Sonoma (14) или новее и на Apple Silicon использует GPU (docs.ollama.com/macos). Модели лежат в ~/.ollama.
  • Ориентир по размеру: 8 ГБ памяти — модели на 3–4B в квантизации q4 (≈2.5 ГБ на диске); 16 ГБ — 7–8B (≈5 ГБ); 32 ГБ и больше — 14B и крупнее. Например, gpt-oss:20b по её странице нужно от 16 ГБ памяти.
  • Не грузим модель впритык к свободной памяти. Рядом с IDE, браузером и Docker большая модель уйдёт в swap, и тормозить начнёт всё. Ollama выгружает модель через несколько минут простоя (keep_alive, по умолчанию 5m) — прогоняем вопросы, когда машина не занята тяжёлым, или держим модель маленькой.

Какую именно модель взять и как это проверить — подробно в главе 3.3 · Проект 1: tool calling + Telegram. Для старта сегодня: qwen3:4b-instruct-2507-q4_K_M (2.5 ГБ, поддерживает tools).

Установка — только из официального источника

Заголовок раздела «Установка — только из официального источника»
  1. Скачиваем приложение с ollama.com/download, не из сторонних зеркал и не через curl … | sh без чтения скрипта.
  2. На macOS до первого запуска проверяем подпись и нотаризацию:
    Окно терминала
    codesign -dv --verbose=4 /Applications/Ollama.app # who signed it (Developer ID, Team ID)
    spctl -a -vv /Applications/Ollama.app # expect: accepted, source=Notarized Developer ID
    На Linux официальная установка — скрипт; сначала скачиваем и читаем его, потом запускаем.
  3. Модели качаем из официальной библиотеки по полному тегу (ollama pull qwen3:4b-instruct-2507-q4_K_M), а не latest — иначе через месяц под тем же именем окажется другая модель и сравнения перестанут сходиться.
  4. Python-пакеты пиннингуем: openai==<версия>, и, если понадобится нативный API, ollama==<версия> (официальный клиент, на ноябрь 2026 — 0.6.3 на PyPI).
  1. Открываем sqlite3 в Python → читаем Tutorial, затем в Reference: sqlite3.connect() (параметр uri), Connection.execute, Row, row_factory, раздел How-to «How to work with SQLite URIs» и «How to use placeholders to bind values» (≈40 мин). Берём: плейсхолдеры ?/:name вместо f-строк в SQL, mode=ro.
  2. Открываем SQLite: VACUUM → раздел про VACUUM INTO (≈10 мин). Обратим внимание: исходник не меняется, целевой файл не должен существовать.
  3. Открываем SQLite: Write-Ahead Logging → первые разделы: как работает WAL, checkpoint, файлы -wal/-shm (≈15 мин). Берём: почему cp даёт неконсистентную копию.
  4. Открываем SQLite: URI filenames → параметры mode и immutable (≈10 мин).
  5. Открываем aiogram → Quick start / пример эхо-бота, затем разделы Dispatcher, Router, Filters (magic filter F), Middlewares (≈60 мин). Учить всё не нужно: важно понять, как апдейт идёт Dispatcher → Router → фильтр → хендлер, и что хендлеры — async def.
  6. Открываем Telegram Bot API: tutorial → раздел про получение токена у @BotFather (≈15 мин). Создаём бота, токен — сразу в .env.
  7. (Если нужен Python-рефреш) asyncio → «Coroutines and Tasks», только начало (≈20 мин). async/await есть во многих языках, но event loop здесь запускаем сами через asyncio.run().
  8. Открываем GitHub: Creating diagrams → Mermaid в README (≈10 мин). Схема в README рендерится прямо на GitHub.
  9. Открываем Ollama: macOS и Ollama FAQ → требования, где лежат модели, keep_alive, раздел про приватность («Ollama runs locally…») (≈15 мин). Берём: локальные модели не отправляют данные, облачные (cloud) — отправляют.
  10. Открываем Ollama: OpenAI compatibility → base URL, что поддерживается в /v1/chat/completions и что нет (tool_choice нет) (≈10 мин).
  11. Открываем DeepSeek: Models & Pricing и Your First API Call → имена моделей, base URL, пример на openai SDK (≈10 мин). Записываем в заметки дату проверки.
  • Персональные данные в публичном репозитории — главный риск проекта. data/, *.db, *.db-wal, *.db-shm, logs/, .env — в .gitignore ещё до первого коммита. Если снимок хоть раз попал в коммит, он остаётся в истории git даже после удаления файла. Проверка: git check-ignore -v data/shop.db должна показать правило.
  • Скриншоты и примеры в README — только на демо-базе. Цифры и покупатели из снимка не должны попасть в публичный репозиторий ни файлом, ни картинкой, ни строчкой в README.
  • sqlite3.connect("data/shop.db") без mode=ro создаст пустой файл при опечатке — и бот будет уверенно отвечать «заказов нет».
  • Не открываем живую базу из бота «просто посмотреть», даже на чтение: читающая транзакция в WAL мешает checkpoint и может раздувать -wal у работающего приложения. Только снимок.
  • sqlite3 -readonly на живую WAL-базу не работает. Для снимка открываем её обычным sqlite3 и выполняем только VACUUM INTO.
  • Не путаем пути: VACUUM INTO с путём, указывающим на существующий файл, упадёт — это хорошо. Но rm перед снимком направляем только на файлы в data/ бота.
  • Не подключаем к боту API платёжного провайдера и службы доставки с ключами «чтобы показывать живые статусы». Это подписанные запросы с ключами и другой класс риска (через тот же API оформляют возвраты); в этом проекте их нет.
  • Время: SQLite хранит UTC, а владелец магазина думает в местном времени. Фиксируем это в README сразу, иначе в гл. 3.3 «за сегодня» будет врать на несколько часов.
  • «Только посмотреть, как отвечает DeepSeek на снимке» — нельзя. Сравнение моделей делается на демо-базе; на снимке работает только локальная Ollama. Гарантирует это assert_data_policy, а не память.
  • -cloud-теги Ollama — это облако, хоть и запускаются той же командой ollama run.
  • Папка проекта. В учебном репозитории из гл. 1.2 создаём projects/01-shop-analyst-bot/ (репозиторий можно пока держать приватным, публичным он станет в гл. 3.4). Внутри: uv init или python -m venv .venv. Зависимости: aiogram, openai (клиент к Ollama и DeepSeek), pydantic, python-dotenv, pytest; anthropic — по желанию, для сравнения. Фиксируем версии.
  • .gitignore до первого коммита. Минимум:
    .env
    .venv/
    data/
    *.db
    *.db-wal
    *.db-shm
    logs/
    __pycache__/
    Проверяем: git check-ignore -v data/shop.db .env.
  • .env.example с именами переменных без значений: TELEGRAM_BOT_TOKEN=, OWNER_TELEGRAM_ID=, LLM_PROVIDER=ollama, LLM_MODEL=, DEEPSEEK_API_KEY=, ANTHROPIC_API_KEY= (для сравнения), SHOP_DB_PATH=data/shop.db, ANALYTICS_DB_PATH=data/analytics.db, SHOP_LIVE_DIR= (только для snapshot.sh, бот её не читает), TIMEZONE=, NO_PROXY=localhost,127.0.0.1.
  • «Живые» базы магазина. С помощью модели пишем scripts/make_shop_db.py на стандартной библиотеке. В запросе только схема из раздела 3, поэтому подойдёт любой провайдер; код читаем целиком до запуска. Требования: аргументы --seed и --out; random.Random(seed), чтобы один seed давал одни и те же данные; обе базы со схемой дословно; PRAGMA journal_mode=WAL; 8–12 товаров в 4–5 категориях, среди них чай и кофе на вес (unit = 'g'); несколько сотен заказов за 30 дней, среди них незавершённые, возвраты и отмены; покупатели с именами, email и телефонами; product_stats почасово за 30 дней; отказ, если файлы уже есть. В конце генератор печатает journal_mode (ожидаемо wal) и число строк — после этого живые базы руками не открываем. Запуск из корня репозитория, чтобы shop-live/ оказалась рядом с ним, а не внутри: python3 projects/01-shop-analyst-bot/scripts/make_shop_db.py --seed 1 --out ../shop-live/.
  • Ollama. Устанавливаем с ollama.com (на macOS перед первым запуском codesign -dv и spctl -a -vv), затем ollama pull qwen3:4b-instruct-2507-q4_K_M и проверка на вопросе без данных магазина: ollama run qwen3:4b-instruct-2507-q4_K_M "Say hi in Russian". Смотрим ollama ps: сколько памяти заняла модель.
  • Ключ DeepSeek — в .env, пополнение на пару долларов. Первый запрос — на абстрактном вопросе, не о заказах.
  • bot/config.py с llm_settings() и assert_data_policy() как в разделе выше + тест: deepseek + data/shop.db → SystemExit; deepseek + демо-база → ок; ollama + локальный тег + data/shop.db → ок; ollama + gpt-oss:20b-cloud + data/shop.db → SystemExit.
  • Снимок. Пишем scripts/snapshot.sh: берёт папку живых баз из SHOP_LIVE_DIR, удаляет старые data/shop.db и data/analytics.db, делает VACUUM INTO для обеих баз, переключает копии в journal_mode=DELETE, печатает размер и SELECT count(*) FROM orders. Скрипт запускаем вручную; в cron не ставим. Внутри скрипта к живой базе — только VACUUM INTO.
  • bot/db.py. Функция open_readonly(path) как в теории. Тест руками в python -i:
    from pathlib import Path
    from bot.db import open_readonly
    conn = open_readonly(Path("data/shop.db"))
    conn.execute("SELECT count(*) FROM orders").fetchone()[0] # works
    conn.execute("DELETE FROM orders") # must raise
    open_readonly(Path("data/nope.db")) # must raise, no file created
    Ожидаемо: второй и третий вызов падают (sqlite3.OperationalError), файл data/nope.db не появился.
  • Разведка данных. Пять SQL-запросов к копии, результаты — в личные заметки (не в репозиторий): число незавершённых заказов по статусам (new, paid, shipped), какие товары в них (JOIN order_items и products), самый старый и самый новый closed_at, есть ли аномалии вроде status = 'delivered' при delivered_at IS NULL или оплаченного заказа без строки в payments, единицы product_stats.time.
  • Формула прибыли заказа — записываем словами в заметках и сверяем вручную на 2–3 заказах из копии, хотя бы один с чаем или кофе на вес: сумма строки для pcs — quantity × unit_price, для g — quantity / 100 × unit_price (себестоимость так же через unit_cost); прибыль = сумма строк − себестоимость строк − discount − fee. Проверяем, как записаны fee и возвраты.
  • Схема в README (Mermaid или ASCII): Telegram → бот → LLM → функции → SQLite (только чтение). Отдельной строкой: «живые базы магазина, платёжный провайдер и служба доставки недоступны боту».
  • questions.md — 15 вопросов в таблице: # | вопрос | какие функции | как проверить ответ. Обязательно: 2+ вопроса с двумя функциями, 3+ с относительными датами, 3 «вне возможностей» (оформить возврат, фактические расходы на доставку, интерес к товару старше 30 дней).
  • Папка projects/01-shop-analyst-bot/ создана, .gitignore защищает data/, .env, logs/, *.db* — проверено git check-ignore.
  • make_shop_db.py создаёт обе живые базы в ../shop-live/ в режиме WAL; scripts/snapshot.sh делает копию через VACUUM INTO, копия открывается через open_readonly, запись в неё падает с ошибкой, несуществующий путь не создаёт файл.
  • В README есть схема с явной границей «только чтение».
  • Есть questions.md с 10–15 вопросами, ожидаемыми функциями и способом проверки.
  • Ollama установлена из официального источника (подпись проверена), локальная модель отвечает; assert_data_policy не даёт запустить облачного провайдера на снимке — проверено тестом.

Вопрос 1

Почему для копии живой WAL-базы нельзя использовать cp shop.db?

  • a) cp медленнее
  • b) в копию могут не попасть транзакции из -wal и она может оказаться неконсистентной
  • c) cp меняет права файла
  • d) SQLite блокирует cp.

b. Последние транзакции могут лежать в -wal, а во время checkpoint основной файл меняется — копия получится неконсистентной. VACUUM INTO пишет согласованный снимок.

Вопрос 2

Что произойдёт при sqlite3.connect("data/shpo.db") (опечатка) и при sqlite3.connect("file:data/shpo.db?mode=ro", uri=True)?

Первый вызов молча создаст пустую базу shpo.db, и бот будет отвечать «нет данных». Второй упадёт с unable to open database file — ошибка видна сразу.

Вопрос 3

Какие два независимых механизма делают соединение только для чтения в нашей open_readonly?

mode=ro в URI (SQLite открывает файл только на чтение) и PRAGMA query_only = ON (соединение отклоняет любые изменения).

Вопрос 4

Почему «в системном промпте написано не оформлять возвраты» — недостаточная защита?

Промпт — это просьба, модель может ошибиться или поддаться инъекции. Настоящая защита — отсутствие функции и прав: нечего вызывать и нечем писать.

Вопрос 5

Сколько строк order_items соответствует одному заказу и как их связать с заказом?

  • a) одна
  • b) по одной на каждый товар, по order_id
  • c) по одной на товар, по product_id
  • d) зависит от службы доставки.

b. По строке на каждый товар заказа, все с одинаковым order_id; выручка и прибыль заказа — сумма по строкам.

Вопрос 6

Строка order_items: quantity = 250, unit_price = 480. Какова сумма строки, если товар — чай (unit = 'g')? А если это кружки (unit = 'pcs') с теми же числами?

Чай: quantity в граммах, цена за 100 г → 250 / 100 × 480 = 1200. Кружки: quantity в штуках, цена за штуку → 250 × 480 = 120000. Поэтому правило живёт в одной функции item_amount.

Вопрос 7

Что не так в этом скрипте снимка?

Окно терминала
cp "$SHOP_LIVE_DIR/shop.db" data/shop.db
sqlite3 data/shop.db "PRAGMA journal_mode=DELETE;"
git add data/shop.db

cp вместо VACUUM INTO (неконсистентная копия) и git add снимка — персональные данные покупателей уйдут в историю git; data/ должна быть в .gitignore.

Вопрос 8

Вопрос «сколько мы чисто заработали за неделю с учётом расходов на доставку?». Что должен ответить бот на данных из этой базы?

Что фактических расходов на доставку в базе нет: может назвать прибыль за неделю до расходов на доставку и явно оговорить, что они не учтены.

Вопрос 9

Каковы два минуса подхода «одна функция run_sql(query)» для этого проекта?

Модель пишет произвольный SQL (риск тяжёлых запросов, чтения персональных данных и неверной логики прибыли и граммов/штук каждый раз заново), сложно ограничить размер выдачи, почти нечего тестировать.

Вопрос 10

Почему closed_at >= date('now', '-1 day') может не совпасть с «вчера» владельца магазина?

date('now') в SQLite — UTC, а «вчера» владельца — в его часовом поясе; границы суток сдвинуты на разницу часовых поясов.

Вопрос 11

Для чего в questions.md нужны вопросы, на которые бот ответить не может?

Это проверка поведения на границах: бот должен вежливо отказать (нет функции возврата, нет данных о фактических расходах на доставку, нет статистики товаров старше 30 дней), а не выдумать ответ.

Вопрос 12

Нужно сравнить, как DeepSeek и локальная Qwen отвечают на 15 вопросов. На какой базе запускается каждая модель и почему?

Обе — на демо-базе: результаты инструментов уходят провайдеру, а DeepSeek — облако (серверы в Китае), которому нельзя показывать персональные данные покупателей. Локальную модель потом дополнительно прогоняют на снимке; DeepSeek его не видит никогда.

Вопрос 13

Почему один и тот же SDK openai подходит и для DeepSeek, и для Ollama? Что меняется в коде при переключении?

Оба реализуют формат OpenAI Chat Completions. Меняются только base_url, api_key (у Ollama любой, игнорируется) и имя модели — то есть переменные окружения, а не код.

Вопрос 14

Что не так в этой конфигурации?

LLM_PROVIDER=ollama
LLM_MODEL=gpt-oss:20b-cloud
SHOP_DB_PATH=data/shop.db

Тег -cloud выполняется на серверах Ollama, а не на локальной машине, — снимок с персональными данными уйдёт в облако. Для снимка нужен локальный тег; assert_data_policy из этой главы такую конфигурацию отклонит.

В гл. 3.2 «Проект 1: функции только для чтения» превратим разведанную схему и формулу прибыли в четыре функции с Pydantic-моделями, тестами pytest и генератором демо-базы без персональных данных.