3.1 · Проект 1: архитектура бота и копия базы
Неделя 3 · Основы LLM · ⏱ ~4 ч · Проект
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Это первый проект в портфолио: Telegram-бот «аналитик магазина», который отвечает владельцу интернет-магазина «Полка» на вопросы о заказах и продажах через tool calling. На собеседовании про такой проект спросят не «какой промпт», а «как вы не дали модели сделать что-то опасное» и «откуда она берёт данные». Сегодня закладываем ответы на оба вопроса: модель видит данные только через функции, функции видят только копию базы, а копия открыта только на чтение.
⚠️ Только чтение. Никаких функций, которые меняют заказы, оформляют возвраты или выдают промокоды, и никаких запросов к платёжному провайдеру и службе доставки с ключами — только копия базы.
1. Что мы строим и чего не строим
Заголовок раздела «1. Что мы строим и чего не строим»Бот — это «аналитик», а не «менеджер магазина». Он умеет ровно одно: превратить вопрос на естественном языке («какая выручка за неделю?») в вызов одной или нескольких читающих функций и пересказать результат человеческим языком. Всё, что может изменить состояние мира — изменение заказа, возврат, промокод, запросы к платёжному провайдеру и службе доставки с ключами, — в проекте отсутствует физически, а не «запрещено промптом». Промпт — это просьба, а не защита: модель можно уговорить, сбить с толку длинной историей, или она просто ошибётся. Защита — это архитектура, в которой опасного действия нет в коде.
Принцип, который стоит запомнить на всю карьеру: у LLM ровно те права, которые есть у функций, которые мы ей дали. Нужно, чтобы модель не могла удалить данные, — не даём ей функцию с DELETE и не открываем базу на запись.
2. Архитектура
Заголовок раздела «2. Архитектура» 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); облачные модели — только демо-базу (см. ниже).Обратим внимание на три слоя защиты:
- Слой данных. Бот работает со снимком, а не с живой базой. Даже если всё остальное сломается, приложение магазина ничего не заметит.
- Слой соединения. Снимок открыт с
mode=roв URI и сPRAGMA query_only = ON. ЛюбойINSERT/UPDATE/DELETEупадёт с ошибкой. - Слой инструментов. Модель не пишет SQL. Она вызывает 4 функции с типизированными параметрами; SQL внутри функций пишем мы. (Вариант «дать модели text-to-SQL» обсудим ниже — для этого проекта он хуже.)
Аналогия из веб-разработки: это как если бы фронтенд вызывал только GET-роуты, а на сервере вообще не было бы POST /api/orders/{id}/refund. Не «роут есть, но кнопка спрятана», а роута нет.
3. Что в базе магазина на самом деле
Заголовок раздела «3. Что в базе магазина на самом деле»Перед тем как проектировать функции, нужно знать данные. У приложения магазина две 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 | refundedorder_items(id, order_id, product_id, quantity, unit_price, unit_cost)payments(id, order_id, provider, amount, fee, status, created_at) -- status: succeeded | failed | refundedshipments(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в выручку не входят.
4. Почему копия, и почему только VACUUM INTO
Заголовок раздела «4. Почему копия, и почему только VACUUM INTO»Теперь понятно, что боту нужно из базы. Следующий вопрос — как получить эти данные, не трогая базу, в которую пишет приложение магазина. Ответ — работать с копией, и сделать её правильно сложнее, чем кажется.
Живая база в режиме 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 livessqlite3 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.
5. Открыть на чтение: URI и query_only
Заголовок раздела «5. Открыть на чтение: URI и query_only»Копия есть; осталось открыть её так, чтобы бот физически не мог в неё писать. В Python sqlite3.connect() по умолчанию открывает файл на чтение и запись и создаёт пустую базу, если файла нет (опечатка в пути → бот молча работает с пустой базой и отвечает «заказов нет»). URI-режим решает обе проблемы:
import sqlite3from 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 connmode=ro— SQLite откроет файл только на чтение; если файла нет — ошибкаunable to open database file, а не новая пустая база.PRAGMA query_only = ON— второй замок на уровне соединения.row_factory = sqlite3.Row— строки с доступом по имени колонки (row["unit_price"]), как словари.
Есть ещё immutable=1 в URI: говорит SQLite, что файл вообще никогда не меняется, и отключает блокировки. Для снимка это корректно, но если по ошибке направить его на живой файл — получится неконсистентное чтение. Для обучения хватит mode=ro.
6. Почему не text-to-SQL
Заголовок раздела «6. Почему не text-to-SQL»Соблазн: дать модели одну функцию run_sql(query) и схему базы. Минусы для этого проекта:
| 4 узкие функции | run_sql |
|
|---|---|---|
| Безопасность | SQL пишем мы | модель пишет любой SQL (даже на ro-базе — тяжёлые запросы, чтение всего, включая персональные данные покупателей) |
| Корректность прибыли | правило штук/граммов, скидка и комиссия — в одном месте, покрыто тестами | модель каждый раз заново «вспоминает», как считать прибыль |
| Размер ответа | агрегаты, ≤ 20 строк | легко вернуть 5000 строк в контекст |
| Тестируемость | pytest на каждую функцию | почти никакой |
Text-to-SQL — легитимный паттерн для аналитики «на любые вопросы», но он требует evals и песочницы. Вернёмся к нему в главе 9.5 · MCP-сервер к базе магазина (только чтение): там свободный SQL допускается только как отдельный инструмент с тремя слоями защиты. Здесь — узкие функции.
7. Стек и структура проекта
Заголовок раздела «7. Стек и структура проекта»- 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/8. Список вопросов — это спецификация
Заголовок раздела «8. Список вопросов — это спецификация»Архитектура и данные понятны; осталось зафиксировать, что именно бот должен уметь. Прежде чем писать функции, записываем вопросы, которые владелец будет задавать. Из них в гл. 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-запрос, который мы выполним сами).
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Главное решение проекта: какая модель видит данные снимка
Заголовок раздела «Главное решение проекта: какая модель видит данные снимка»Результаты инструментов — это выручка, прибыль и заказы, а в снимке рядом лежат имена, 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 osfrom 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: сколько нужно памяти
Заголовок раздела «Ollama: сколько нужно памяти»Главное ограничение выбора локальной модели — память, которую она делит со всем, что запущено на машине:
- 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).
Установка — только из официального источника
Заголовок раздела «Установка — только из официального источника»- Скачиваем приложение с ollama.com/download, не из сторонних зеркал и не через
curl … | shбез чтения скрипта. - На macOS до первого запуска проверяем подпись и нотаризацию:
На Linux официальная установка — скрипт; сначала скачиваем и читаем его, потом запускаем.
Окно терминала 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 - Модели качаем из официальной библиотеки по полному тегу (
ollama pull qwen3:4b-instruct-2507-q4_K_M), а неlatest— иначе через месяц под тем же именем окажется другая модель и сравнения перестанут сходиться. - Python-пакеты пиннингуем:
openai==<версия>, и, если понадобится нативный API,ollama==<версия>(официальный клиент, на ноябрь 2026 — 0.6.3 на PyPI).
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Открываем 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. - Открываем SQLite: VACUUM → раздел про
VACUUM INTO(≈10 мин). Обратим внимание: исходник не меняется, целевой файл не должен существовать. - Открываем SQLite: Write-Ahead Logging → первые разделы: как работает WAL, checkpoint, файлы
-wal/-shm(≈15 мин). Берём: почемуcpдаёт неконсистентную копию. - Открываем SQLite: URI filenames → параметры
modeиimmutable(≈10 мин). - Открываем aiogram → Quick start / пример эхо-бота, затем разделы Dispatcher, Router, Filters (magic filter
F), Middlewares (≈60 мин). Учить всё не нужно: важно понять, как апдейт идётDispatcher → Router → фильтр → хендлер, и что хендлеры —async def. - Открываем Telegram Bot API: tutorial → раздел про получение токена у @BotFather (≈15 мин). Создаём бота, токен — сразу в
.env. - (Если нужен Python-рефреш) asyncio → «Coroutines and Tasks», только начало (≈20 мин).
async/awaitесть во многих языках, но event loop здесь запускаем сами черезasyncio.run(). - Открываем GitHub: Creating diagrams → Mermaid в README (≈10 мин). Схема в README рендерится прямо на GitHub.
- Открываем Ollama: macOS и Ollama FAQ → требования, где лежат модели,
keep_alive, раздел про приватность («Ollama runs locally…») (≈15 мин). Берём: локальные модели не отправляют данные, облачные (cloud) — отправляют. - Открываем Ollama: OpenAI compatibility → base URL, что поддерживается в
/v1/chat/completionsи что нет (tool_choiceнет) (≈10 мин). - Открываем DeepSeek: Models & Pricing и Your First API Call → имена моделей, base URL, пример на
openaiSDK (≈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-shmlogs/__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 Pathfrom bot.db import open_readonlyconn = open_readonly(Path("data/shop.db"))conn.execute("SELECT count(*) FROM orders").fetchone()[0] # worksconn.execute("DELETE FROM orders") # must raiseopen_readonly(Path("data/nope.db")) # must raise, no file createdsqlite3.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?
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 соответствует одному заказу и как их связать с заказом?
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.dbsqlite3 data/shop.db "PRAGMA journal_mode=DELETE;"git add data/shop.dbcp вместо 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=ollamaLLM_MODEL=gpt-oss:20b-cloudSHOP_DB_PATH=data/shop.dbТег -cloud выполняется на серверах Ollama, а не на локальной машине, — снимок с персональными данными уйдёт в облако. Для снимка нужен локальный тег; assert_data_policy из этой главы такую конфигурацию отклонит.
Что дальше
Заголовок раздела «Что дальше»В гл. 3.2 «Проект 1: функции только для чтения» превратим разведанную схему и формулу прибыли в четыре функции с Pydantic-моделями, тестами pytest и генератором демо-базы без персональных данных.