{"repo": "beka4kaa/timely", "files": {"backend/config/settings.py": {"53fb265ef5548951b684333377c36779172eff84": {"t": 1785746923.1647239, "n": 22543, "h": "6d4bddcc9f510154", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"pomodoro\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\n# Содержимое доски (ChatSession.canvas) приезжает одним JSON-телом, и\n# иллюстрации лежат в нём как data-URI по сотне-другой килобайт каждая.\n# Django по умолчанию режет тело на 2.5 МБ и отвечает RequestDataTooBig ДО\n# входа во вью — то есть раньше, чем сериализатор успевает применить свой\n# предел в 12 МБ. Из-за этого доска с несколькими иллюстрациями молча\n# переставала сохраняться. Держим оба предела согласованными; последнее слово\n# и понятная ошибка остаются за ai_engine.serializers.validate_canvas.\nDATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (Seedream) обязаны попадать в `_is_image_only_model`\n# (image_enrichment.py), иначе запрос уйдёт с modalities [\"image\", \"text\"] и\n# провайдер ответит 404. То же касается любой новой модели.\n# Gemini-3-pro-image мультимодальная (отдаёт и картинку, и текст), поэтому её\n# туда добавлять НЕ нужно — проверено живым запросом.\n#\n# Проверять доступность модели нужно ТОЧЕЧНЫМ запросом\n# GET /api/v1/models//endpoints, а НЕ поиском по листингу /api/v1/models:\n# листинг отдаёт не весь каталог, и по нему легко ошибочно счесть живую модель\n# снятой. bytedance-seed/seedream-4.5 в листинге отсутствует, но её endpoint\n# `bytedance-seed/seedream-4.5-20251203` жив.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# ── Выбор модели пользователем (ai_engine.image_models) ──────────────────────\n# Пользователь переключает модель прямо на доске, поэтому `IMAGE_GEN_MODEL`\n# перестал быть единственным источником истины: он остался дефолтом последней\n# инстанции для вызовов, которые ничего не выбрали явно.\n#\n# ALLOWED — жёсткий allowlist. Model ID приходит с фронтенда, и доверять ему\n# нельзя: без этого списка пользователь смог бы направить наш OpenRouter-ключ\n# в любую модель каталога. Всё, чего нет в `ai_engine.image_models.IMAGE_MODELS`,\n# игнорируется, поэтому опечатка в env не выдаст наружу нерабочий id.\nIMAGE_GEN_ALLOWED_MODELS = os.getenv(\n \"IMAGE_GEN_ALLOWED_MODELS\",\n \"bytedance-seed/seedream-4.5,openai/gpt-5.4-image-2\",\n)\n\n# Дефолт для UI и для запросов без явного выбора. Приоритет выше, чем у\n# IMAGE_GEN_MODEL, но значение обязано входить в allowlist — иначе берётся\n# модель с флагом default из реестра.\nIMAGE_GEN_DEFAULT_MODEL = os.getenv(\n \"IMAGE_GEN_DEFAULT_MODEL\",\n \"bytedance-seed/seedream-4.5\",\n)\n\n# Качество по умолчанию: low / medium / high. Намеренно НЕ \"high\" — оно молча\n# удорожает каждую генерацию. Модели без поддержки качества параметр не\n# получают вовсе.\nIMAGE_GEN_QUALITY = os.getenv(\"IMAGE_GEN_QUALITY\", \"medium\")\n\n# Соотношение сторон генерации. Читалось в image_enrichment через\n# getattr(settings, ...), но в settings отсутствовало — значение жило только\n# во фолбэке и не настраивалось через env. Заведено явно.\nIMAGE_GEN_ASPECT_RATIO = os.getenv(\"IMAGE_GEN_ASPECT_RATIO\", \"16:9\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "c6734400664b56d0b54bd902d640ac5de3b6f4f9": {"t": 1785769056.9540017, "n": 22543, "h": "6d4bddcc9f510154", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"pomodoro\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\n# Содержимое доски (ChatSession.canvas) приезжает одним JSON-телом, и\n# иллюстрации лежат в нём как data-URI по сотне-другой килобайт каждая.\n# Django по умолчанию режет тело на 2.5 МБ и отвечает RequestDataTooBig ДО\n# входа во вью — то есть раньше, чем сериализатор успевает применить свой\n# предел в 12 МБ. Из-за этого доска с несколькими иллюстрациями молча\n# переставала сохраняться. Держим оба предела согласованными; последнее слово\n# и понятная ошибка остаются за ai_engine.serializers.validate_canvas.\nDATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (Seedream) обязаны попадать в `_is_image_only_model`\n# (image_enrichment.py), иначе запрос уйдёт с modalities [\"image\", \"text\"] и\n# провайдер ответит 404. То же касается любой новой модели.\n# Gemini-3-pro-image мультимодальная (отдаёт и картинку, и текст), поэтому её\n# туда добавлять НЕ нужно — проверено живым запросом.\n#\n# Проверять доступность модели нужно ТОЧЕЧНЫМ запросом\n# GET /api/v1/models//endpoints, а НЕ поиском по листингу /api/v1/models:\n# листинг отдаёт не весь каталог, и по нему легко ошибочно счесть живую модель\n# снятой. bytedance-seed/seedream-4.5 в листинге отсутствует, но её endpoint\n# `bytedance-seed/seedream-4.5-20251203` жив.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# ── Выбор модели пользователем (ai_engine.image_models) ──────────────────────\n# Пользователь переключает модель прямо на доске, поэтому `IMAGE_GEN_MODEL`\n# перестал быть единственным источником истины: он остался дефолтом последней\n# инстанции для вызовов, которые ничего не выбрали явно.\n#\n# ALLOWED — жёсткий allowlist. Model ID приходит с фронтенда, и доверять ему\n# нельзя: без этого списка пользователь смог бы направить наш OpenRouter-ключ\n# в любую модель каталога. Всё, чего нет в `ai_engine.image_models.IMAGE_MODELS`,\n# игнорируется, поэтому опечатка в env не выдаст наружу нерабочий id.\nIMAGE_GEN_ALLOWED_MODELS = os.getenv(\n \"IMAGE_GEN_ALLOWED_MODELS\",\n \"bytedance-seed/seedream-4.5,openai/gpt-5.4-image-2\",\n)\n\n# Дефолт для UI и для запросов без явного выбора. Приоритет выше, чем у\n# IMAGE_GEN_MODEL, но значение обязано входить в allowlist — иначе берётся\n# модель с флагом default из реестра.\nIMAGE_GEN_DEFAULT_MODEL = os.getenv(\n \"IMAGE_GEN_DEFAULT_MODEL\",\n \"bytedance-seed/seedream-4.5\",\n)\n\n# Качество по умолчанию: low / medium / high. Намеренно НЕ \"high\" — оно молча\n# удорожает каждую генерацию. Модели без поддержки качества параметр не\n# получают вовсе.\nIMAGE_GEN_QUALITY = os.getenv(\"IMAGE_GEN_QUALITY\", \"medium\")\n\n# Соотношение сторон генерации. Читалось в image_enrichment через\n# getattr(settings, ...), но в settings отсутствовало — значение жило только\n# во фолбэке и не настраивалось через env. Заведено явно.\nIMAGE_GEN_ASPECT_RATIO = os.getenv(\"IMAGE_GEN_ASPECT_RATIO\", \"16:9\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "3aa044050a9f641bdce63cd31d6ea3196d46ec06": {"t": 1785769058.481041, "n": 22714, "h": "d94cbdc327e8f3ab", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"pomodoro\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\n# Содержимое доски (ChatSession.canvas) приезжает одним JSON-телом, и\n# иллюстрации лежат в нём как data-URI по сотне-другой килобайт каждая.\n# Django по умолчанию режет тело на 2.5 МБ и отвечает RequestDataTooBig ДО\n# входа во вью — то есть раньше, чем сериализатор успевает применить свой\n# предел в 12 МБ. Из-за этого доска с несколькими иллюстрациями молча\n# переставала сохраняться. Держим оба предела согласованными; последнее слово\n# и понятная ошибка остаются за ai_engine.serializers.validate_canvas.\nDATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# ── Выбор модели пользователем (ai_engine.image_models) ──────────────────────\n# Пользователь переключает модель прямо на доске, поэтому `IMAGE_GEN_MODEL`\n# перестал быть единственным источником истины: он остался дефолтом последней\n# инстанции для вызовов, которые ничего не выбрали явно.\n#\n# ALLOWED — жёсткий allowlist. Model ID приходит с фронтенда, и доверять ему\n# нельзя: без этого списка пользователь смог бы направить наш OpenRouter-ключ\n# в любую модель каталога. Всё, чего нет в `ai_engine.image_models.IMAGE_MODELS`,\n# игнорируется, поэтому опечатка в env не выдаст наружу нерабочий id.\nIMAGE_GEN_ALLOWED_MODELS = os.getenv(\n \"IMAGE_GEN_ALLOWED_MODELS\",\n \"bytedance-seed/seedream-4.5,openai/gpt-5.4-image-2\",\n)\n\n# Дефолт для UI и для запросов без явного выбора. Приоритет выше, чем у\n# IMAGE_GEN_MODEL, но значение обязано входить в allowlist — иначе берётся\n# модель с флагом default из реестра.\n#\n# NB: см. предупреждение выше про Seedream — на 2026-07-29 её нет в каталоге\n# OpenRouter. Дефолт задан по прямому решению владельца продукта; откат на\n# рабочую модель делается этой же переменной, без правки кода.\nIMAGE_GEN_DEFAULT_MODEL = os.getenv(\n \"IMAGE_GEN_DEFAULT_MODEL\",\n \"bytedance-seed/seedream-4.5\",\n)\n\n# Качество по умолчанию: low / medium / high. Намеренно НЕ \"high\" — оно молча\n# удорожает каждую генерацию. Модели без поддержки качества параметр не\n# получают вовсе.\nIMAGE_GEN_QUALITY = os.getenv(\"IMAGE_GEN_QUALITY\", \"medium\")\n\n# Соотношение сторон генерации. Читалось в image_enrichment через\n# getattr(settings, ...), но в settings отсутствовало — значение жило только\n# во фолбэке и не настраивалось через env. Заведено явно.\nIMAGE_GEN_ASPECT_RATIO = os.getenv(\"IMAGE_GEN_ASPECT_RATIO\", \"16:9\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "0d83d4356dbd42931fca5940109137686419c00a": {"t": 1785769062.410255, "n": 20974, "h": "fbc63b5d5b27f77e", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"pomodoro\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\n# Содержимое доски (ChatSession.canvas) приезжает одним JSON-телом, и\n# иллюстрации лежат в нём как data-URI по сотне-другой килобайт каждая.\n# Django по умолчанию режет тело на 2.5 МБ и отвечает RequestDataTooBig ДО\n# входа во вью — то есть раньше, чем сериализатор успевает применить свой\n# предел в 12 МБ. Из-за этого доска с несколькими иллюстрациями молча\n# переставала сохраняться. Держим оба предела согласованными; последнее слово\n# и понятная ошибка остаются за ai_engine.serializers.validate_canvas.\nDATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "38444c56bf654961cb22fce9720a660396db233d": {"t": 1785769064.1548424, "n": 22698, "h": "232cc16dd2a3f863", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\n# Содержимое доски (ChatSession.canvas) приезжает одним JSON-телом, и\n# иллюстрации лежат в нём как data-URI по сотне-другой килобайт каждая.\n# Django по умолчанию режет тело на 2.5 МБ и отвечает RequestDataTooBig ДО\n# входа во вью — то есть раньше, чем сериализатор успевает применить свой\n# предел в 12 МБ. Из-за этого доска с несколькими иллюстрациями молча\n# переставала сохраняться. Держим оба предела согласованными; последнее слово\n# и понятная ошибка остаются за ai_engine.serializers.validate_canvas.\nDATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# ── Выбор модели пользователем (ai_engine.image_models) ──────────────────────\n# Пользователь переключает модель прямо на доске, поэтому `IMAGE_GEN_MODEL`\n# перестал быть единственным источником истины: он остался дефолтом последней\n# инстанции для вызовов, которые ничего не выбрали явно.\n#\n# ALLOWED — жёсткий allowlist. Model ID приходит с фронтенда, и доверять ему\n# нельзя: без этого списка пользователь смог бы направить наш OpenRouter-ключ\n# в любую модель каталога. Всё, чего нет в `ai_engine.image_models.IMAGE_MODELS`,\n# игнорируется, поэтому опечатка в env не выдаст наружу нерабочий id.\nIMAGE_GEN_ALLOWED_MODELS = os.getenv(\n \"IMAGE_GEN_ALLOWED_MODELS\",\n \"bytedance-seed/seedream-4.5,openai/gpt-5.4-image-2\",\n)\n\n# Дефолт для UI и для запросов без явного выбора. Приоритет выше, чем у\n# IMAGE_GEN_MODEL, но значение обязано входить в allowlist — иначе берётся\n# модель с флагом default из реестра.\n#\n# NB: см. предупреждение выше про Seedream — на 2026-07-29 её нет в каталоге\n# OpenRouter. Дефолт задан по прямому решению владельца продукта; откат на\n# рабочую модель делается этой же переменной, без правки кода.\nIMAGE_GEN_DEFAULT_MODEL = os.getenv(\n \"IMAGE_GEN_DEFAULT_MODEL\",\n \"bytedance-seed/seedream-4.5\",\n)\n\n# Качество по умолчанию: low / medium / high. Намеренно НЕ \"high\" — оно молча\n# удорожает каждую генерацию. Модели без поддержки качества параметр не\n# получают вовсе.\nIMAGE_GEN_QUALITY = os.getenv(\"IMAGE_GEN_QUALITY\", \"medium\")\n\n# Соотношение сторон генерации. Читалось в image_enrichment через\n# getattr(settings, ...), но в settings отсутствовало — значение жило только\n# во фолбэке и не настраивалось через env. Заведено явно.\nIMAGE_GEN_ASPECT_RATIO = os.getenv(\"IMAGE_GEN_ASPECT_RATIO\", \"16:9\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "6d5ec6cfabf0f2eb908403bd4bb84e0b3b9c966b": {"t": 1785769066.646309, "n": 22140, "h": "efd5285592c13e23", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# ── Выбор модели пользователем (ai_engine.image_models) ──────────────────────\n# Пользователь переключает модель прямо на доске, поэтому `IMAGE_GEN_MODEL`\n# перестал быть единственным источником истины: он остался дефолтом последней\n# инстанции для вызовов, которые ничего не выбрали явно.\n#\n# ALLOWED — жёсткий allowlist. Model ID приходит с фронтенда, и доверять ему\n# нельзя: без этого списка пользователь смог бы направить наш OpenRouter-ключ\n# в любую модель каталога. Всё, чего нет в `ai_engine.image_models.IMAGE_MODELS`,\n# игнорируется, поэтому опечатка в env не выдаст наружу нерабочий id.\nIMAGE_GEN_ALLOWED_MODELS = os.getenv(\n \"IMAGE_GEN_ALLOWED_MODELS\",\n \"bytedance-seed/seedream-4.5,openai/gpt-5.4-image-2\",\n)\n\n# Дефолт для UI и для запросов без явного выбора. Приоритет выше, чем у\n# IMAGE_GEN_MODEL, но значение обязано входить в allowlist — иначе берётся\n# модель с флагом default из реестра.\n#\n# NB: см. предупреждение выше про Seedream — на 2026-07-29 её нет в каталоге\n# OpenRouter. Дефолт задан по прямому решению владельца продукта; откат на\n# рабочую модель делается этой же переменной, без правки кода.\nIMAGE_GEN_DEFAULT_MODEL = os.getenv(\n \"IMAGE_GEN_DEFAULT_MODEL\",\n \"bytedance-seed/seedream-4.5\",\n)\n\n# Качество по умолчанию: low / medium / high. Намеренно НЕ \"high\" — оно молча\n# удорожает каждую генерацию. Модели без поддержки качества параметр не\n# получают вовсе.\nIMAGE_GEN_QUALITY = os.getenv(\"IMAGE_GEN_QUALITY\", \"medium\")\n\n# Соотношение сторон генерации. Читалось в image_enrichment через\n# getattr(settings, ...), но в settings отсутствовало — значение жило только\n# во фолбэке и не настраивалось через env. Заведено явно.\nIMAGE_GEN_ASPECT_RATIO = os.getenv(\"IMAGE_GEN_ASPECT_RATIO\", \"16:9\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "9f366155254093d4f841c2ccc9b269f18af1881c": {"t": 1785769068.1723876, "n": 20958, "h": "75a7a05eaa69d433", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\n# Содержимое доски (ChatSession.canvas) приезжает одним JSON-телом, и\n# иллюстрации лежат в нём как data-URI по сотне-другой килобайт каждая.\n# Django по умолчанию режет тело на 2.5 МБ и отвечает RequestDataTooBig ДО\n# входа во вью — то есть раньше, чем сериализатор успевает применить свой\n# предел в 12 МБ. Из-за этого доска с несколькими иллюстрациями молча\n# переставала сохраняться. Держим оба предела согласованными; последнее слово\n# и понятная ошибка остаются за ai_engine.serializers.validate_canvas.\nDATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "24887aa1d3a1ec03c49b068f743f217abb9f476b": {"t": 1785769069.632271, "n": 20400, "h": "8c2e908ea4986410", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "f70a358d12298fb2e15c6d149c93a9a6d166b6f2": {"t": 1785769071.2107348, "n": 17988, "h": "729c50aa9840900f", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: Seedream — ЧИСТО image-модель, поэтому она обязана попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"bytedance-seed/seedream-4.5\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Хост выше живёт в CGNAT-диапазоне Tailscale (100.64.0.0/10) и из облачного\n# контейнера не маршрутизируется вообще: SYN уходит в никуда, и клиент честно\n# ждёт свой таймаут. Именно так прод и «сломался» — /api/ai/chat отдавал 504\n# ровно через 60с (таймаут skills/router), потому что BOARD_LLM_BASE_URL на\n# Northflank не задали и он молча ушёл на этот адрес. Тихий фолбэк допустим\n# только в разработке; в проде отсутствие явного URL должно быть громким.\nAI_ALLOW_LOCAL_LLM_FALLBACK = os.getenv(\n \"AI_ALLOW_LOCAL_LLM_FALLBACK\", \"True\" if DEBUG else \"False\"\n).lower() in {\"1\", \"true\", \"yes\"}\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# GLM-OCR / Smart Canvas Analyzer — существующая OpenAI-совместимая GLM\n# интеграция. OCR и analyzer используют общий ai_engine.glm_client.\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8081/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\", \"sk-local\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"mlx-community/GLM-OCR-bf16\")\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "2a385a26dd8595e3401fc313f312bf8188adb7c7": {"t": 1785769072.8717208, "n": 19802, "h": "63c02f7b55ec7123", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: чисто image-модели (например Seedream) обязаны попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\n#\n# Дефолт сменён с bytedance-seed/seedream-4.5: этой модели БОЛЬШЕ НЕТ на\n# OpenRouter — она пропала из /api/v1/models, а запрос отвечает\n# 404 «No endpoints found that support the requested output modalities».\n# Из-за этого падала ЛЮБАЯ генерация иллюстрации. Gemini-3-pro-image\n# мультимодальная (отдаёт и картинку, и текст), поэтому в\n# `_is_image_only_model` её добавлять НЕ нужно — проверено живым запросом.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3-pro-image\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "c1c47707e0e69986ff0d4bee3479d36068d09b34": {"t": 1785769074.2608352, "n": 19394, "h": "b926b48d23eaf2b2", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: Seedream — ЧИСТО image-модель, поэтому она обязана попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"bytedance-seed/seedream-4.5\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (БОЛЬШЕ НЕ ДЕФОЛТ для OCR: см.\n# блок Vision/OCR ниже — OCR по умолчанию идёт в Qwen3-VL через\n# OpenRouter, а этот порт остаётся опциональным локальным путём)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Vision / OCR — Qwen3-VL через OpenRouter (ai_engine.glm_client)\n#\n# Раньше здесь стояла GLM-OCR на Mac Studio (:8081), а PRODUCT.md §7.4 называл\n# целевой моделью self-hosted PaddleOCR-VL. От self-hosted пути отказались:\n# развернуть его негде, а адрес Mac Studio лежит в CGNAT-диапазоне Tailscale\n# (100.64.0.0/10) и из облачного контейнера не маршрутизируется вообще — SYN\n# уходит в никуда, и запрос честно ждёт свой таймаут. Ровно так уже ломался чат\n# (см. AI_ALLOW_LOCAL_LLM_FALLBACK выше), и OCR ломался бы так же.\n#\n# Поэтому по умолчанию — облачная Qwen3-VL через OpenRouter: тот же ключ, что у\n# остальных моделей, никакого своего железа. Локальный путь никуда не делся,\n# он просто перестал быть значением по умолчанию: задайте VISION_API_BASE_URL и\n# VISION_MODEL_NAME, чтобы вернуться на Mac Studio.\n#\n# VISION_API_KEY отдельным ключом оставлен намеренно (можно развести квоты), но\n# по умолчанию подхватывает OPENROUTER_API_KEY, иначе после смены базового URL\n# запрос уходил бы с прежним «sk-local» и провайдер отвечал 401.\n# ──────────────────────────────────────────────────────────────────────────────\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", \"https://openrouter.ai/api/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\") or os.getenv(\"OPENROUTER_API_KEY\", \"\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"qwen/qwen3-vl-32b-instruct\")\n# 32B-модель по сети отвечает дольше локальной: прежних 30 секунд не хватает.\nVISION_TIMEOUT = int(os.getenv(\"VISION_TIMEOUT\", \"60\"))\n# Бинаризация Оцу в ocr_views.preprocess_and_encode настраивалась под выделенную\n# OCR-модель: она давит полутона и оставляет чистый чёрный текст на белом. Qwen3-VL\n# — универсальная VLM, и ей обычно легче читать изображение без порога (порог\n# съедает слабый штрих карандаша), поэтому по умолчанию выключено.\n# На чистом печатном тексте разницы нет — проверено вживую, оба режима дали\n# одинаковую транскрипцию. Разница ожидается на реальном фото тетради; чтобы\n# было чем сравнить, прежнее поведение оставлено в этом переключателе.\nVISION_OCR_BINARIZE = os.getenv(\"VISION_OCR_BINARIZE\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "89ef54e36170afd1b2b4c43f29f012da973bb18f": {"t": 1785769075.954986, "n": 17390, "h": "07a279ac779f5b39", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\n# Caps the reasoning token budget instead of letting it run unbounded: a\n# narrow/tricky topic can otherwise make the model \"think\" for most of\n# PLANNING_MAX_TOKENS before it ever writes the question JSON, which is slow\n# and burns tokens for a single adaptive follow-up question.\nPLANNING_REASONING_EFFORT = os.getenv(\"PLANNING_REASONING_EFFORT\", \"low\")\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: Seedream — ЧИСТО image-модель, поэтому она обязана попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"bytedance-seed/seedream-4.5\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# GLM-OCR / Smart Canvas Analyzer — существующая OpenAI-совместимая GLM\n# интеграция. OCR и analyzer используют общий ai_engine.glm_client.\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8081/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\", \"sk-local\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"mlx-community/GLM-OCR-bf16\")\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "4983b8d1a07ae0a29f89611a2038a55c3ce6458c": {"t": 1785769077.4143682, "n": 17034, "h": "a39c7ce8bb7637de", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n \"ai_engine.middleware.AIUsageContextMiddleware\",\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Adaptive lesson planning intake (existing OpenRouter text client)\n# ──────────────────────────────────────────────────────────────────────────────\nPLANNING_MODEL = os.getenv(\n \"PLANNING_MODEL\",\n os.getenv(\"TEXT_LLM_MODEL\", \"deepseek/deepseek-v4-flash\"),\n)\nPLANNING_ENABLED = os.getenv(\"PLANNING_ENABLED\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\nPLANNING_MAX_QUESTIONS = int(os.getenv(\"PLANNING_MAX_QUESTIONS\", \"4\"))\nPLANNING_REASONING_ENABLED = os.getenv(\n \"PLANNING_REASONING_ENABLED\", \"true\"\n).lower() in {\"1\", \"true\", \"yes\", \"on\"}\nPLANNING_TIMEOUT = int(os.getenv(\"PLANNING_TIMEOUT\", \"20\"))\nPLANNING_MAX_TOKENS = int(os.getenv(\"PLANNING_MAX_TOKENS\", \"700\"))\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels`.\n# Провайдер: OpenRouter → Seedream 4.5 (ByteDance).\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter ($0.04 за картинку).\n# Предыдущая модель впечатывала в иллюстрацию псевдо-текст («Porgls»,\n# «Courcta», «Fikicn», «Filek») даже с полным TEXT_FREE-контрактом и коротким\n# терминальным запретом — и тем ломала контракт, по которому подписи\n# накладываются отдельным слоем. Она же добавляла лишние стрелки, второй угол и\n# внешнюю силу вопреки MECHANICS/INCLINED_PLANE-контрактам.\n# NB: Seedream — ЧИСТО image-модель, поэтому она обязана попадать в\n# `_is_image_only_model` (image_enrichment.py), иначе запрос уйдёт с modalities\n# [\"image\", \"text\"] и провайдер ответит 404. То же касается любой новой модели.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"bytedance-seed/seedream-4.5\")\n\n# Vision-модель для grounding координат подписей в illustration_pipeline.\n# ВАЖНО: это НЕ IMAGE_GEN_MODEL. Раньше значение наследовалось от него, но\n# генератор картинок vision-chat не умеет, из-за чего грунтинг молча\n# падал на неточные координаты. Нужна именно чат-модель со зрением.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", \"z-ai/glm-4.6v\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# AI usage ledger and Timely-token limits\n# ──────────────────────────────────────────────────────────────────────────────\n# Text requests are charged by provider token count. Image requests that do not\n# report token usage use a deterministic Timely-token charge per generated image.\nAI_IMAGE_TOKEN_CHARGE = int(os.getenv(\"AI_IMAGE_TOKEN_CHARGE\", \"4000\"))\nAI_CONTEXT_TOKEN_LIMIT = int(os.getenv(\"AI_CONTEXT_TOKEN_LIMIT\", \"128000\"))\nAI_TOKEN_5H_LIMIT = int(os.getenv(\"AI_TOKEN_5H_LIMIT\", \"250000\"))\nAI_TOKEN_WEEKLY_LIMIT = int(os.getenv(\"AI_TOKEN_WEEKLY_LIMIT\", \"1000000\"))\n# Free is intentionally small. Pro preserves the previous global limits. Max\n# gets 20x the rolling allowance of Pro while still retaining a hard ceiling.\nAI_FREE_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_FREE_CONTEXT_TOKEN_LIMIT\", \"32000\")\n)\nAI_FREE_TOKEN_5H_LIMIT = int(os.getenv(\"AI_FREE_TOKEN_5H_LIMIT\", \"25000\"))\nAI_FREE_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_FREE_TOKEN_WEEKLY_LIMIT\", \"100000\")\n)\nAI_MAX_CONTEXT_TOKEN_LIMIT = int(\n os.getenv(\"AI_MAX_CONTEXT_TOKEN_LIMIT\", \"256000\")\n)\nAI_MAX_TOKEN_5H_LIMIT = int(os.getenv(\"AI_MAX_TOKEN_5H_LIMIT\", \"5000000\"))\nAI_MAX_TOKEN_WEEKLY_LIMIT = int(\n os.getenv(\"AI_MAX_TOKEN_WEEKLY_LIMIT\", \"20000000\")\n)\nAI_PRO_PRICE_USD = float(os.getenv(\"AI_PRO_PRICE_USD\", \"19.9\"))\nAI_MAX_PRICE_USD = float(os.getenv(\"AI_MAX_PRICE_USD\", \"199\"))\nAI_PLAN_LIMITS = {\n \"free\": {\n \"label\": \"Free\",\n \"context\": AI_FREE_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_FREE_TOKEN_5H_LIMIT,\n \"weekly\": AI_FREE_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": 0.0,\n \"usage_multiplier\": 1,\n },\n \"pro\": {\n \"label\": \"Pro\",\n \"context\": AI_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_TOKEN_5H_LIMIT,\n \"weekly\": AI_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_PRO_PRICE_USD,\n \"usage_multiplier\": 10,\n },\n \"max\": {\n \"label\": \"Max\",\n \"context\": AI_MAX_CONTEXT_TOKEN_LIMIT,\n \"five_hour\": AI_MAX_TOKEN_5H_LIMIT,\n \"weekly\": AI_MAX_TOKEN_WEEKLY_LIMIT,\n \"price_usd\": AI_MAX_PRICE_USD,\n \"usage_multiplier\": 200,\n },\n}\n# Hard limits are enabled by default; set false only for an explicit A/B rollout.\nAI_USAGE_ENFORCE_LIMITS = os.getenv(\"AI_USAGE_ENFORCE_LIMITS\", \"true\").lower() in {\n \"1\",\n \"true\",\n \"yes\",\n \"on\",\n}\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Детерминированный путь научных схем (ai_engine.vector_pipeline)\n# ──────────────────────────────────────────────────────────────────────────────\n# Механику рисует не image-модель, а VectorRenderer: LLM отдаёт только семантику\n# (surface/body/vector/angle_arc), геометрию целиком считает бэкенд. Так схема\n# получается одинаковой при повторах, в кадре нет ни одной буквы, а подписи\n# приезжают структурой с ТОЧНЫМИ якорями (середина древка каждой силы) —\n# vision-грунтинг для них не нужен.\n#\n# По умолчанию ВЫКЛЮЧЕНО: путь покрывает пока только наклонную плоскость, всё\n# остальное честно уходит в растр. Включать после проверки на живой доске.\nILLUSTRATION_VECTOR_PIPELINE = os.getenv(\"ILLUSTRATION_VECTOR_PIPELINE\", \"false\")\n\n# Новый A/B-переключатель имеет приоритет над прежним boolean-флагом.\n# `legacy` не делает дополнительных Qwen-вызовов и остаётся production default.\nDIAGRAM_PIPELINE_MODE = os.getenv(\"DIAGRAM_PIPELINE_MODE\", \"legacy\")\nDIAGRAM_PLANNER_MODEL = os.getenv(\n \"DIAGRAM_PLANNER_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_CRITIC_MODEL = os.getenv(\n \"DIAGRAM_CRITIC_MODEL\",\n \"qwen/qwen3.7-plus\",\n)\nDIAGRAM_PLANNER_ENABLED = os.getenv(\"DIAGRAM_PLANNER_ENABLED\", \"true\")\nDIAGRAM_CRITIC_ENABLED = os.getenv(\"DIAGRAM_CRITIC_ENABLED\", \"true\")\nDIAGRAM_MAX_RETRIES = int(os.getenv(\"DIAGRAM_MAX_RETRIES\", \"1\"))\nDIAGRAM_PLANNER_TIMEOUT = int(os.getenv(\"DIAGRAM_PLANNER_TIMEOUT\", \"45\"))\nDIAGRAM_CRITIC_TIMEOUT = int(os.getenv(\"DIAGRAM_CRITIC_TIMEOUT\", \"45\"))\nDIAGRAM_PLANNER_MAX_TOKENS = int(os.getenv(\"DIAGRAM_PLANNER_MAX_TOKENS\", \"2400\"))\nDIAGRAM_CRITIC_MAX_TOKENS = int(os.getenv(\"DIAGRAM_CRITIC_MAX_TOKENS\", \"1200\"))\n\n# Backward-compatible aliases for the first vector prototype. Diagram planning\n# no longer inherits the global DeepSeek text model.\nVECTOR_PLANNER_MODEL = os.getenv(\n \"VECTOR_PLANNER_MODEL\",\n DIAGRAM_PLANNER_MODEL,\n)\nVECTOR_PLANNER_TIMEOUT = int(\n os.getenv(\"VECTOR_PLANNER_TIMEOUT\", str(DIAGRAM_PLANNER_TIMEOUT))\n)\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# GLM-OCR / Smart Canvas Analyzer — существующая OpenAI-совместимая GLM\n# интеграция. OCR и analyzer используют общий ai_engine.glm_client.\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8081/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\", \"sk-local\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"mlx-community/GLM-OCR-bf16\")\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "15140e09c618c71fdb6504c8e16478be052ce33f": {"t": 1785769078.7773452, "n": 11246, "h": "eb749ebe8a1ddc2c", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels` от Llama.\n# Провайдер: OpenRouter → Nano Banana 2\n# (google/gemini-3.1-flash-image-preview, standard tier)\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter\n# \"Nano Banana 2\" / google/gemini-3.1-flash-image-preview — быстрее и дешевле\n# чем BananaPro (gemini-3-pro-image-preview), используем для тестов/прода.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3.1-flash-image-preview\")\n\n# Vision-фолбэк для grounding в illustration_pipeline. По умолчанию используем\n# ту же модель, что и для генерации картинки, чтобы не разъезжались режимы и\n# доступность в OpenRouter.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", IMAGE_GEN_MODEL)\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# GLM-OCR / Smart Canvas Analyzer — существующая OpenAI-совместимая GLM\n# интеграция. OCR и analyzer используют общий ai_engine.glm_client.\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8081/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\", \"sk-local\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"mlx-community/GLM-OCR-bf16\")\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n \"nutrition\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "9a635e224c2ee1136851acbb57577a98841bb521": {"t": 1785769080.5473182, "n": 11161, "h": "21efc88a596b6725", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels` от Llama.\n# Провайдер: OpenRouter → Nano Banana 2\n# (google/gemini-3.1-flash-image-preview, standard tier)\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter\n# \"Nano Banana 2\" / google/gemini-3.1-flash-image-preview — быстрее и дешевле\n# чем BananaPro (gemini-3-pro-image-preview), используем для тестов/прода.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3.1-flash-image-preview\")\n\n# Vision-фолбэк для grounding в illustration_pipeline. По умолчанию используем\n# ту же модель, что и для генерации картинки, чтобы не разъезжались режимы и\n# доступность в OpenRouter.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", IMAGE_GEN_MODEL)\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timelyplan.me\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# GLM-OCR / Smart Canvas Analyzer — существующая OpenAI-совместимая GLM\n# интеграция. OCR и analyzer используют общий ai_engine.glm_client.\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8081/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\", \"sk-local\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"mlx-community/GLM-OCR-bf16\")\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "b6f28973ab19a3a2263bb0b8ea99ed82e4b08d63": {"t": 1785769081.996081, "n": 11158, "h": "80e79e6158339384", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"nutrition\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels` от Llama.\n# Провайдер: OpenRouter → Nano Banana 2\n# (google/gemini-3.1-flash-image-preview, standard tier)\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter\n# \"Nano Banana 2\" / google/gemini-3.1-flash-image-preview — быстрее и дешевле\n# чем BananaPro (gemini-3-pro-image-preview), используем для тестов/прода.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3.1-flash-image-preview\")\n\n# Vision-фолбэк для grounding в illustration_pipeline. По умолчанию используем\n# ту же модель, что и для генерации картинки, чтобы не разъезжались режимы и\n# доступность в OpenRouter.\nGROUNDING_FALLBACK_MODEL = os.getenv(\"GROUNDING_FALLBACK_MODEL\", IMAGE_GEN_MODEL)\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timely.app\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# GLM-OCR / Smart Canvas Analyzer — существующая OpenAI-совместимая GLM\n# интеграция. OCR и analyzer используют общий ai_engine.glm_client.\nVISION_API_BASE_URL = os.getenv(\"VISION_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8081/v1\")\nVISION_API_KEY = os.getenv(\"VISION_API_KEY\", \"sk-local\")\nVISION_MODEL_NAME = os.getenv(\"VISION_MODEL_NAME\", \"mlx-community/GLM-OCR-bf16\")\nGLM_ANALYZER_MODEL_NAME = os.getenv(\"GLM_ANALYZER_MODEL_NAME\", VISION_MODEL_NAME)\nGLM_ANALYZER_TIMEOUT = int(os.getenv(\"GLM_ANALYZER_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n\n# ── Логирование ──────────────────────────────────────────────────────\n# По умолчанию Django показывает только WARNING+ от сторонних логгеров —\n# из-за этого diagnostic-INFO ai_engine (классификация intent, режимы\n# t2i/i2i, ход illustration-пайплайна) не попадал в консоль runserver,\n# и отлаживать поток генерации приходилось вслепую.\nLOGGING = {\n \"version\": 1,\n \"disable_existing_loggers\": False,\n \"handlers\": {\"console\": {\"class\": \"logging.StreamHandler\"}},\n \"loggers\": {\n \"ai_engine\": {\"handlers\": [\"console\"], \"level\": \"INFO\", \"propagate\": False},\n },\n}\n"}, "1f3e4dbbaac21bc36d0033d0935295142bc297e2": {"t": 1785769083.4253323, "n": 9749, "h": "af15475694158b8b", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"habits\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels` от Llama.\n# Провайдер: OpenRouter → BananaPro (google/gemini-pro-image, standard tier)\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter\n# \"Nano Banana 2\" / google/gemini-3.1-flash-image-preview — быстрее и дешевле\n# чем BananaPro (gemini-3-pro-image-preview), используем для тестов/прода.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3.1-flash-image-preview\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timely.app\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n"}, "4b3768de0a8795399e2a948282b8d679ddbba1f6": {"t": 1785769084.7784953, "n": 9735, "h": "505ca4c7b373b569", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\nload_dotenv(BASE_DIR.parent / '.env')\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n \"django_filters\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n\nAUTH_USER_MODEL = 'accounts.CustomUser'\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Image Generation (ai_engine.image_enrichment)\n# Используется для обогащения команд `image_with_labels` от Llama.\n# Провайдер: OpenRouter → BananaPro (google/gemini-pro-image, standard tier)\n# ──────────────────────────────────────────────────────────────────────────────\n\n# POST endpoint провайдера генерации изображений\nIMAGE_GEN_API_URL = os.getenv(\n \"IMAGE_GEN_API_URL\",\n \"https://openrouter.ai/api/v1/chat/completions\",\n)\n\n# API-ключ. Для OpenRouter — тот же ключ, что OPENROUTER_API_KEY.\n# В production задайте через переменную окружения IMAGE_GEN_API_KEY.\nIMAGE_GEN_API_KEY = os.getenv(\n \"IMAGE_GEN_API_KEY\",\n os.getenv(\"OPENROUTER_API_KEY\", \"\"),\n)\n\n# Идентификатор модели на OpenRouter\n# \"Nano Banana 2\" / google/gemini-3.1-flash-image-preview — быстрее и дешевле\n# чем BananaPro (gemini-3-pro-image-preview), используем для тестов/прода.\nIMAGE_GEN_MODEL = os.getenv(\"IMAGE_GEN_MODEL\", \"google/gemini-3.1-flash-image-preview\")\n\n# Таймаут одного запроса к провайдеру (секунды)\nIMAGE_GEN_TIMEOUT = int(os.getenv(\"IMAGE_GEN_TIMEOUT\", \"60\"))\n\n# Tier OpenRouter для маршрутизации запросов (standard / nitro)\nIMAGE_GEN_TIER = os.getenv(\"IMAGE_GEN_TIER\", \"standard\")\n\n# Максимальное количество одновременных запросов к API генерации\n# (актуально когда один ответ Llama содержит несколько image_with_labels команд)\nIMAGE_GEN_MAX_WORKERS = int(os.getenv(\"IMAGE_GEN_MAX_WORKERS\", \"3\"))\n\n# Публичный URL сервиса (используется в HTTP-Referer при запросах к OpenRouter)\nSITE_URL = os.getenv(\"SITE_URL\", \"https://timely.app\")\n\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Локальные модели на удалённой Mac Studio (через Tailscale, mlx_vlm.server +\n# отдельный SAM2-сервис). Используются в ai_engine.illustration_pipeline для\n# цепочки \"banana → SAM2 → Qwen → SVG\" и в ai_engine.ocr_views для OCR.\n#\n# Текущая раскладка портов на хосте MAC_STUDIO_HOST:\n# :8080 → mlx-community/Qwen3.6-27B-4bit (общая VLM: vision + текст)\n# :8081 → mlx-community/GLM-OCR-bf16 (используется в ocr_views для OCR)\n# :8002 → SAM 2 (facebook/sam2-hiera-large), отдельный FastAPI-сервис,\n# контракт: POST /api/segment/ {image_base64, points, labels}\n# → {mask_base64, score}\n# ──────────────────────────────────────────────────────────────────────────────\n\nMAC_STUDIO_HOST = os.getenv(\"MAC_STUDIO_HOST\", \"100.74.104.27\")\n\n# Qwen3.6-27B-4bit — универсальная локальная VLM (используется для семантической\n# разметки/подписей объектов в illustration_pipeline; OpenAI-совместимый API).\nQWEN_API_BASE_URL = os.getenv(\"QWEN_API_BASE_URL\", f\"http://{MAC_STUDIO_HOST}:8080/v1\")\nQWEN_API_KEY = os.getenv(\"QWEN_API_KEY\", \"sk-local\")\nQWEN_MODEL_NAME = os.getenv(\"QWEN_MODEL_NAME\", \"mlx-community/Qwen3.6-27B-4bit\")\nQWEN_TIMEOUT = int(os.getenv(\"QWEN_TIMEOUT\", \"60\"))\n\n# SAM 2 — точечная сегментация изображений (свой контракт, НЕ OpenAI-совместимый).\nSAM2_API_URL = os.getenv(\"SAM2_API_URL\", f\"http://{MAC_STUDIO_HOST}:8002/api/segment/\")\n# Таймаут на запрос немалый: даже на уменьшенной копии изображения (см.\n# ILLUSTRATION_SAM2_MAX_DIM) одна сегментация занимает несколько секунд,\n# а сервис (см. ниже) почти не выигрывает от распараллеливания.\nSAM2_TIMEOUT = int(os.getenv(\"SAM2_TIMEOUT\", \"120\"))\n\n# ──────────────────────────────────────────────────────────────────────────────\n# Параметры построения векторных иллюстраций (illustration_pipeline)\n#\n# ВАЖНО про производительность SAM2-сервиса (измерено эмпирически на реальной\n# сгенерированной иллюстрации 1408×768): время одного запроса критически\n# зависит от РАЗРЕШЕНИЯ изображения — full-res ≈ 28 секунд на точку, а при\n# нескольких ПАРАЛЛЕЛЬНЫХ запросах сервис уходит в массовые таймауты (он не\n# распараллеливает инференс — судя по всему, единственное GPU-устройство).\n# Поэтому: (1) перед отправкой в SAM2 уменьшаем длинную сторону картинки до\n# ILLUSTRATION_SAM2_MAX_DIM (это ускоряет запрос ~в 10 раз: 28с → ~3с, при\n# этом качество маски не страдает — контур всё равно упрощается на этапе\n# построения SVG); маски затем растягиваются обратно к исходному разрешению.\n# (2) держим ILLUSTRATION_SAM2_MAX_WORKERS низким (вплоть до 1 — без\n# параллелизма), чтобы не перегружать сервис и не словить контеншн-замедление.\n# ──────────────────────────────────────────────────────────────────────────────\n\n# Сетка N×N стартовых точек — ПОДСТРАХОВКА к content-aware поиску объектов\n# (см. illustration_pipeline._content_seed_points). Держим небольшим: основную\n# работу выполняет content-aware детектор, сетка лишь подстраховывает на случай\n# нетипичных изображений (текстурный фон и т.п.).\nILLUSTRATION_GRID_N = int(os.getenv(\"ILLUSTRATION_GRID_N\", \"3\"))\nILLUSTRATION_MAX_OBJECTS = int(os.getenv(\"ILLUSTRATION_MAX_OBJECTS\", \"6\"))\n\n# Воркеры для Qwen-подписей — лёгкие vision-запросы на маленьких вырезанных\n# фрагментах, параллелятся гораздо лучше, чем тяжёлая SAM2-сегментация.\nILLUSTRATION_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_MAX_WORKERS\", \"4\"))\n\n# Длинная сторона копии изображения, отправляемой в SAM2 (px). См. обоснование выше.\nILLUSTRATION_SAM2_MAX_DIM = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_DIM\", \"384\"))\n# Параллелизм запросов к SAM2 — низкий специально (см. обоснование выше).\nILLUSTRATION_SAM2_MAX_WORKERS = int(os.getenv(\"ILLUSTRATION_SAM2_MAX_WORKERS\", \"2\"))\n"}, "accb28c0d34e4ec5457f0ecc1129e286ebfe2d61": {"t": 1785769086.127853, "n": 4276, "h": "672de820ed35f03a", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n \"diary\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n"}, "74cbdda3e6b0a905d40ba454757d5f2884d837fe": {"t": 1785769087.4516594, "n": 4263, "h": "5881b9cd07ac54eb", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n \"accounts\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n"}, "1f7f6a19896a8eced8065277ab3f107a48bd28d8": {"t": 1785769088.8148577, "n": 4247, "h": "33f4fd98fba217c8", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\n# Disable APPEND_SLASH to avoid errors when Vercel strips trailing slashes\n# This allows DELETE/PUT/PATCH requests without trailing slash to work\nAPPEND_SLASH = False\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n"}, "cdc4316ee15676c970298dda1c9a2052d238a68e": {"t": 1785769090.0035164, "n": 4079, "h": "bbf81fb37f942c9a", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"corsheaders.middleware.CorsMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n \"config.middleware.UserEmailMiddleware\", # Extract user email from header\n]\n\n# CORS settings\nCORS_ALLOW_ALL_ORIGINS = True\nCORS_ALLOW_HEADERS = [\n 'content-type',\n 'x-user-email',\n 'authorization',\n 'accept',\n 'origin',\n]\n\nROOT_URLCONF = \"config.urls\"\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n\n# Cache configuration - using database cache (free, uses existing Postgres)\nCACHES = {\n 'default': {\n 'BACKEND': 'django.core.cache.backends.db.DatabaseCache',\n 'LOCATION': 'cache_table',\n 'TIMEOUT': 300, # 5 minutes default\n }\n}\n\n# Default primary key field type\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n"}, "448fc2d36bf43e7761367b4dadc9d09780e3aacf": {"t": 1785769091.3419418, "n": 3448, "h": "eaf219773af22339", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n \"corsheaders\",\n \"rest_framework\",\n \"planner\",\n \"mind\",\n \"ai_engine\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n]\n\nROOT_URLCONF = \"config.urls\"\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n"}, "e228a19728ac6e9c69ed456c552762f3c4f215bf": {"t": 1785769092.7686656, "n": 3363, "h": "894e650085f3eac8", "c": "\"\"\"\nDjango settings for config project.\n\nGenerated by 'django-admin startproject' using Django 6.0.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/6.0/topics/settings/\n\nFor the full list of settings and their values, see\nhttps://docs.djangoproject.com/en/6.0/ref/settings/\n\"\"\"\n\nfrom pathlib import Path\nimport os\nimport dj_database_url\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\n# Build paths inside the project like this: BASE_DIR / 'subdir'.\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\n# Quick-start development settings - unsuitable for production\n# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/\n\n# SECURITY WARNING: keep the secret key used in production secret!\nSECRET_KEY = os.getenv(\"SECRET_KEY\", \"django-insecure-zj7m&6fm&$(h$_#848i-y$c70_@uer&k#kj(v=+@48679w6q!t\")\n\n# SECURITY WARNING: don't run with debug turned on in production!\nDEBUG = os.getenv(\"DEBUG\", \"True\") == \"True\"\n\nALLOWED_HOSTS = os.getenv(\"ALLOWED_HOSTS\", \"*\").split(\",\")\n\n\n# Application definition\n\nINSTALLED_APPS = [\n \"django.contrib.admin\",\n \"django.contrib.auth\",\n \"django.contrib.contenttypes\",\n \"django.contrib.sessions\",\n \"django.contrib.messages\",\n \"django.contrib.staticfiles\",\n]\n\nMIDDLEWARE = [\n \"django.middleware.security.SecurityMiddleware\",\n \"whitenoise.middleware.WhiteNoiseMiddleware\",\n \"django.contrib.sessions.middleware.SessionMiddleware\",\n \"django.middleware.common.CommonMiddleware\",\n \"django.middleware.csrf.CsrfViewMiddleware\",\n \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n \"django.contrib.messages.middleware.MessageMiddleware\",\n \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n]\n\nROOT_URLCONF = \"config.urls\"\n\nTEMPLATES = [\n {\n \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n \"DIRS\": [],\n \"APP_DIRS\": True,\n \"OPTIONS\": {\n \"context_processors\": [\n \"django.template.context_processors.request\",\n \"django.contrib.auth.context_processors.auth\",\n \"django.contrib.messages.context_processors.messages\",\n ],\n },\n },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\n# Database\n# https://docs.djangoproject.com/en/6.0/ref/settings/#databases\n\nDATABASES = {\n \"default\": dj_database_url.config(\n default=\"sqlite:///\" + str(BASE_DIR / \"db.sqlite3\"),\n conn_max_age=600,\n )\n}\n\n\n# Password validation\n# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n {\n \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n },\n {\n \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/6.0/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/6.0/howto/static-files/\n\nSTATIC_URL = \"static/\"\nSTATIC_ROOT = BASE_DIR / \"staticfiles\"\nSTATICFILES_STORAGE = \"whitenoise.storage.CompressedManifestStaticFilesStorage\"\n"}}}}