Консолидированный AI+CRM функционал ветки feature/consolidate-ai

Дата: 2026-07-22 Статус: реализовано в ветке feature/consolidate-ai (998 тестов зелёные)

Контекст

Ветка feature/consolidate-ai свела две разошедшиеся AI-ветки бэкенда CRMKA (FastAPI): feature/ai-mcp-tools (асинхронный LangGraph-агент поверх FastMCP-реестра доменных инструментов) и feature/ai-assistant-doc-inbox (прикладные фичи — инбокс документов, разбор звонков, генерация документов по шаблону и сопутствующие доменные правки). Технически расхождение было архитектурным: в одной ветке ядром был LangGraph-агент с human-in-the-loop, в другой — набор точечных AI-эндпоинтов и доменных доработок поверх старого клиента.

Консолидация выбрала единым ядром асинхронный LangGraph-агент (create_react_agent + checkpointer + MCP-bridge с interrupt-подтверждением записи), и на него портированы все прикладные фичи второй ветки: учёт стоимости сведён к провайдеро-агностичному ai_cost_log, командная строка /agent/command роутит намерения в единый AgentService, генерация документов и разбор звонков переиспользуют общий cost-guard и модельный слой. В ходе слияния закрыты 4 HIGH-дефекта (в т.ч. IndexError на MT940 без ссылки в импорте выписки, нарушение UNIQUE при замене операндов вычисляемого счётчика, «молчаливое» исчезновение объектов плана при несогласованном PATCH координат, непубличная раскладка правоустанавливающих документов в structure-инбоксе).

Итоговое состояние: 998 тестов зелёные; ядро AI-потоков и HITL-механика покрыты, часть прикладных путей (реальный LLM, postgres-checkpointer, happy-path подтверждения записи) осознанно оставлены с оговорками (см. раздел «Зрелость и покрытие тестами»).

Архитектура AI-слоя

Модельный слой (LangChain, провайдеро-нейтральность). Chat-модель разрешается фабрикой get_chat_model(settings) (provider.py) по settings.llm_provider: ChatAnthropic / ChatDeepSeek (оба max_tokens=4096) либо ChatOllama (опционально с Authorization: Bearer <llm_api_key> для удалённого Ollama за nginx). Неизвестный провайдер → ValueError. Прикладные структурные вызовы (классификация намерений, разбор звонков, маппинг переменных документа) идут через тонкий ai/client.py со structured_output, форсирующим схему через bind_tools([...], tool_choice=...). Смена провайдера не затрагивает бизнес-логику — она сосредоточена в одной фабрике и словаре MODEL_PRICING.

Оркестрация (LangGraph). Разговорный ассистент реализован как AgentService поверх двух графов create_react_agent с тремя режимами: ask (read-only, stateless, без checkpointer), act (ReAct + write-инструменты + checkpointer; на write-туле срабатывает interrupt, граф чекпойнтится и возвращает __interrupt__) и confirm (возобновление приостановленного графа через Command(resume=approved) — единственный путь фактической записи в БД). Состояние приостановленного act-графа хранит checkpointer, выбираемый фабрикой get_checkpointer(settings) по agent_checkpoint_backend: MemorySaver (dev/тесты) или AsyncPostgresSaver (прод).

MCP-bridge. Единый FastMCP-реестр доменных тулов (build_tool_registry) служит и боевому streamable-http MCP-серверу (app.mount("/{projectId}/mcp", ...)), и внутреннему агенту. Бридж (mcp_bridge.py) оборачивает каждый FastMCP-тул в LangChain StructuredTool, деля их на read/write по аннотации readOnlyHint; для write-тулов внутрь обёртки встроен human-in-the-loop (summarize_actioninterrupt → при отказе {"cancelled": True}, при подтверждении registry.call_tool). select_tools наивно-лексически сужает набор под запрос (ядро CORE + top-K по пересечению токенов). Наборы тулов агента и боевого сервера НЕ идентичны: сервер дополнительно регистрирует floor_plan_editor (mark_rooms_on_plan), которого у агента нет.

Провайдеро-агностичный учёт стоимости. Каждый вызов LLM раскладывается по четырём ценовым корзинам (input_fresh/cache_read/cache_write/output) в pydantic-Usage, оценивается в USD по MODEL_PRICING (неизвестная модель → 0.0) и пишется в таблицу ai_cost_log (failure-isolated: rollback перед вставкой, сбой лога не ломает ответ). Перед каждым платным AI-эндпоинтом _enforce_budget сверяет SUM(estimate_usd) за календарный месяц с ai_monthly_cost_limit_usd (402 ai_budget_exceeded, fail-open). PII запроса в лог не пишется (description = None или служебный маркер).

Сводка фич

ФичаНазначениеЗрелостьТестовКлючевые endpoints
AI-агент на LangGraphРазговорный ассистент CRM: ask/act/confirm с HITL-подтверждением записи🟡 caveats18POST /{projectId}/agent/ask, /act, /confirm
MCP-tools bridge и отбор туловFastMCP-реестр → LangChain StructuredTool с HITL, лексический отбор под запрос🟡 caveats11MOUNT /{projectId}/mcp/mcp, агентные /ask /act /confirm
Учёт стоимости AI и бюджет-гардРазложенный по корзинам лог токенов + USD, месячный cost-guard🟡 caveats12все /{projectId}/agent/* платные
AI-инбокс документовМассовый разбор входящих: привязка к арендатору/зданию, раскладка в хранилище🟡 caveats11POST /{projectId}/doc-inbox/classify, /commit, /structure/*
Разбор звонков отдела продажТранскрипт → структурированный CallAnalysis, сохранение в карточку🟠 weak3POST /{projectId}/agent/call-analysis, /save, DELETE /call-records/{id}
AI-генерация документов по шаблонуШаблон + ИИ-маппинг переменных, детерминированный тег contract, docxtpl-рендер🟡 caveats3POST /{projectId}/agent/document, GET /generated-documents
Единая командная строка /agent/commandHaiku-классификатор намерения → роутинг ask/act/navigate/help🟡 caveats12POST /{projectId}/agent/command, /confirm
Импорт банковской выпискиДетерминированный разбор 1С/CSV/MT940, матчинг платежей, дедуп🟡 caveats34POST /{projectId}/agent/statement/preview, /commit
Вычисляемые счётчикиВиртуальный счётчик SUM/DIFFERENCE над операндами + координаты на плане🟡 caveats19.../computed-meters/, .../{id}/value, .../computed-meter-plan-coordinates/
Контрагенты — расширенные атрибутыОснование полномочий/доверенность подписанта, менеджер, канал доставки счетов🟡 caveats6POST/PATCH /{projectId}/business-entities/, /signatories/, GET /members/brief
Планы — ревалидация координат и размеры изображенияПроверка consistency PATCH/PUT координат + image_width/height🟡 caveats17.../plan-coordinates/{id}, .../plans/, ?fields=has_floor_plans
Инвойсы — фильтр по дате отправки + media-категорияДиапазон sent_to_email_time + колонка category у медиа🟡 caveats4GET /{projectId}/invoices/, GET /{projectId}/media/

Детали фич

AI-агент на LangGraph (ask / act / confirm)

Назначение

Разговорный ассистент CRM коммерческой недвижимости (CRMKA), работающий строго по данным текущего проекта. Реализован как тонкий сервис AgentService поверх двух LangGraph-графов, собранных через create_react_agent. Даёт три режима:

Ключевые файлы: src/app/services/ai/agent_service.py (оркестрация), agent_graph.py (сборка графов), checkpointer.py (фабрика состояния), provider.py (chat-модель), prompts.py (системные промпты), mcp_bridge.py (обёртка тулов + interrupt), tool_selection.py (динамический отбор тулов), api/v1/endpoints/agent.py (HTTP-слой).

Как работает (пошагово)

Сборка графа (agent_graph.py). Оба режима строятся одинаковой prebuilt-функцией create_react_agent(model, tools=…, prompt=…):

Модель берётся из get_chat_model(settings). Перед передачей тулов вызывается _with_model_compat: если имя модели содержит command-r (шаблон Ollama), к списку добавляется псевдо-тул directly-answer (иначе command-r не умеет отвечать без вызова инструмента). Для остальных моделей набор тулов не меняется. Никакого tool_choice/forced tool use в самом агенте нет — решение звать ли инструмент принимает LLM.

Провайдер (provider.py). get_chat_model по settings.llm_provider возвращает ChatAnthropic / ChatDeepSeek (оба с max_tokens=4096) или ChatOllama (опционально с Authorization: Bearer <llm_api_key> для удалённого Ollama за nginx). Неизвестный провайдер → ValueError.

Инструменты (mcp_bridge.py, tool_selection.py). build_tools(session, project) берёт FastMCP-реестр и оборачивает каждый инструмент в StructuredTool, деля их на read/write по аннотации readOnlyHint. В обёртке _wrap._invoke перед реальным вызовом устанавливаются ContextVar'ы (current_session, current_project, current_project_id). Для write-тула вставлен human-in-the-loop:

if not is_read:
    summary = await summarize_action(session, name, kwargs)
    approved = interrupt({"tool": name, "input": kwargs, "summary": summary})
    if not approved:
        return {"cancelled": True}
result = await registry.call_tool(name, kwargs)
return result.structured_content

То есть interrupt живёт ВНУТРИ write-инструмента, а не в отдельном узле графа. select_tools(query, read, write, mode, k=15) сужает набор: для ask пул = read-тулы, для act = read+write; из пула берётся фиксированное ядро CORE плюс top-K по лексическому пересечению токенов запроса и «документа» тула (name/description/ru-метаданные/имена параметров).

Поток ask (AgentService.ask). build_toolsselect_tools(mode="ask")build_ask_agentgraph.ainvoke({"messages": [("user", message)]}) БЕЗ config (нет thread_id, нет состояния). Из финальных сообщений собирается ответ: answer (_final_text — последний непустой строковый AIMessage), used_tools (_used_tools — имена из tool_calls), data (_collect_data), usage (_accumulate_usage), model_used, provider.

Поток act (AgentService.act). Генерируется thread_id = uuid.uuid4().hex. Строится build_act_agent(selected, settings, checkpointer), вызывается graph.ainvoke({...}, config={"configurable": {"thread_id": thread_id}}). ReAct-цикл может сначала вызвать read-тулы (найти id), затем выбрать write-тул; на write-туле срабатывает interrupt, LangGraph чекпойнтит состояние и возвращает __interrupt__. Ветвление результата:

Поток confirm (AgentService.confirm). Возобновление идёт без сообщения пользователя, поэтому отбор тулов невозможен и биндится полный набор: build_act_agent(read + write, settings, checkpointer) с тем же thread_id. Сначала проверяется, что граф реально приостановлен: state = await graph.aget_state(config); if not state.next: raise ValueError(...). Затем graph.ainvoke(Command(resume=approved), config) — внутри write-тула interrupt(...) возвращает approved; при False тул отдаёт {"cancelled": True} (записи нет), при True выполняется registry.call_tool (реальная запись). Из последнего ToolMessage извлекается result, tool_errored = (status == "error"); ответ: success = not tool_errored, summary, result, usage.

Где живёт состояние / thread_id. Состояние приостановленного act-графа хранит LangGraph-checkpointer, ключ — thread_id. Фабрика get_checkpointer(settings) (checkpointer.py) по agent_checkpoint_backend (по умолчанию "memory") возвращает:

Инстанс достаётся в эндпоинтах через get_agent_checkpointer(request) из request.app.state.agent_checkpointer.

_accumulate_usage. Суммирует токены всех AIMessage по ценовым корзинам Usage. Важная деталь: в LangChain input_tokens уже ВКЛЮЧАЕТ кэш-токены, поэтому:

input_total = um.get("input_tokens", 0) or 0
part = Usage(
    input_fresh=max(input_total - cache_read - cache_write, 0),
    cache_read=cache_read, cache_write=cache_write,
    output=um.get("output_tokens", 0) or 0,
)
part.estimate_usd = estimate_cost_usd(model, input_fresh=..., cache_read=..., cache_write=..., output=...)
total.add(part)

cache_read/cache_write берутся из usage_metadata["input_token_details"] (cache_read / cache_creation). У deepseek/ollama деталей кэша нет → корзины кэша 0; estimate_cost_usd для неизвестной модели вернёт 0. Usage.add суммирует корзины и округляет estimate_usd до 6 знаков.

Модель данных / схема

Usage (app/ai/schemas.py) — четыре токен-корзины плюс оценка стоимости:

class Usage(BaseModel):
    input_fresh: int = 0
    cache_read: int = 0
    cache_write: int = 0   # только Anthropic
    output: int = 0
    estimate_usd: float = 0.0

Значение interrupt (черновик действия) — dict {"tool": name, "input": kwargs, "summary": summary}, он же мапится в action ответа act.

Pydantic-контракты эндпоинтов (endpoints/agent.py):

class AskRequest(BaseModel):
    message: str = Field(..., min_length=1)

class AskDataBlock(BaseModel):
    tool: str; entity: str
    present: str            # empty | card | table
    list_fields: list[str] = []
    result: dict[str, Any] = {}

class AskResponse(BaseModel):
    answer: str; used_tools: list[str]
    data: list[AskDataBlock] = []
    usage: Optional[dict[str, Any]] = None

class ProposedAction(BaseModel):
    tool: str; input: dict[str, Any]; summary: str

class ActResponse(BaseModel):
    status: str                     # answered | needs_confirmation
    answer: str; used_tools: list[str]
    action: Optional[ProposedAction] = None
    thread_id: str = ""
    usage: Optional[dict[str, Any]] = None

class ConfirmRequest(BaseModel):
    thread_id: str = Field(..., min_length=1)
    approved: bool = True

class ConfirmResponse(BaseModel):
    success: bool; summary: str; result: dict[str, Any]

Структурные блоки data[] собирает _collect_data: для каждого успешного ToolMessage, у чьего тула есть metadata["entity"], берётся распарсенный результат; плоский результат get_*-тула (без items) нормализуется к {"items": [obj], "total": 1}, а _present выбирает форму показа по кардинальности (0 → empty, 1 → card, N → table).

Endpoints

Все под project-scope (verify_jwt_for_project + get_tenant_session), тег agent:

_guard_api_key — provider-aware 503 ai_not_configured (ollama всегда сконфигурен, deepseek — по deepseek_api_key, иначе anthropic — по anthropic_api_key). _enforce_budget — месячный cost-guard по AiCostLog, 402 ai_budget_exceeded, fail-open (сбой запроса учёта не блокирует). _log_cost_safe — failure-isolated запись ai_cost_log с await session.rollback() ДО вставки (сессия могла остаться в failed-состоянии после проглоченной ошибки read-тула в tool-loop).

Примечание: модульный docstring agent.py устарел (в описании ask-ответа не показывает data[], а тело /confirm описывает как {"tool","input"} вместо актуального {thread_id, approved}). Источник истины — Pydantic-модели выше.

Поведение и edge cases

Ограничения

Перечислены в поле limitations. Главное: покрыт только memory-чекпойнтер (postgres — нет), happy-path подтверждения с реальной записью не имеет интеграционного теста, реальный LLM не тестируется, _enforce_budget не применяется в /confirm, «один write-тул» — договорённость промпта, а не код, ask полностью stateless, TTL-чистка postgres-черновиков не реализована.

Покрытие тестами

Итого 18 тест-кейсов (offline, без сети/реального LLM):

Оценка зрелости — caveats: ядро потоков и HITL-механика (interrupt → resume) покрыты и работают, но есть явные границы (postgres-бэкенд и approve-путь без тестов, отсутствие TTL-чистки, реальный LLM вне тестов).

MCP-tools bridge и динамический отбор тулов

Назначение

Фича связывает три слоя AI-стека воедино: единый реестр доменных инструментов на FastMCP (app/mcp/registry.py), бридж, превращающий эти тулы в langchain_core.tools.StructuredTool с человек-в-цикле (HITL) на write-операциях (app/services/ai/mcp_bridge.py), и динамический отбор релевантных тулов под конкретный запрос пользователя (app/services/ai/tool_selection.py). Ключевая идея — один источник истины: одна и та же фабрика build_tool_registry() служит основой и для боевого MCP-сервера (fast_mcp = build_tool_registry(auth=mcp_auth)fast_mcp.http_app(transport="streamable-http", stateless_http=True) в server.py), и для внутреннего LangGraph-агента (build_tool_registry() без auth). За счёт этого агент и внешние MCP-клиенты видят согласованные аннотации и мета-данные. ВАЖНО: наборы тулов НЕ идентичны — боевой сервер после фабрики дополнительно вызывает register_floor_plan_editor(fast_mcp), поэтому внешние клиенты видят mark_rooms_on_plan, а агент — нет (набор агента — строгое подмножество боевого).

Как работает (пошагово)

  1. Сборка реестра. build_tool_registry(auth=None, name="CRMKA MCP SERVER") создаёт голый FastMCP(name, auth=auth) и последовательно вызывает десять групповых регистраторов (register_structure_tools, register_generator_tools, register_business_entity_tools, register_contract_tools, register_invoice_tools, register_payment_tools, register_resources_services_tools, register_plan_tools, register_media_tools, register_report_tools). Группа floor_plan_editor в фабрику намеренно НЕ входит (её тул mark_rooms_on_plan отсутствует в наборе агента); на боевом сервере она добавляется отдельно в server.py. Импорты регистраторов — локальные внутри функции, чтобы фабрика оставалась чистой (без auth/transport).
  1. Кэш и разбиение. В бридже реестр кэшируется через @lru_cache(maxsize=1) _cached_registry(). build_tools(session, project) перебирает await registry.list_tools() и для каждого FastMCP-тула определяет read/write по аннотации (_is_read читает annotations.readOnlyHint), оборачивает в StructuredTool и раскладывает в два списка read/write.
  1. Обёртка тула. _wrap(...) строит асинхронный _invoke(**kwargs), который: разворачивает конверт аргументов (_unwrap_args), проставляет contextvars (current_session, current_project, current_project_id), а для write-тулов — считает превью summarize_action(...) и вызывает interrupt({...}). Если пользователь не подтвердил — возвращается {"cancelled": True}. Затем await registry.call_tool(name, kwargs) и наружу отдаётся result.structured_content.
  1. Отбор под запрос. select_tools(query, read_tools, write_tools, *, mode, k=15) формирует пул: в режиме ask — только read-тулы, в act — read+write. Пул ранжируется LexicalSelector (пересечение токенов запроса и «документа» тула), берётся top-K, поверх добавляется фиксированное ядро CORE. Результат — дедуплицированный по имени список.
  1. Потребление в агенте. AgentService.ask/act (agent_service.py) вызывает build_toolsselect_toolsbuild_ask_agent/build_act_agent, затем graph.ainvoke. При interrupt-е act возвращает status="needs_confirmation" с action={tool,input,summary} и thread_id. confirm(thread_id, approved) возобновляет граф через Command(resume=approved), при этом биндит полный набор read + write (без отбора — прерванный write-тул обязан присутствовать в графе).

Модель данных / схема

Никаких новых таблиц/колонок фича не вводит — это оркестрационный слой поверх существующих доменных сервисов/репозиториев. Значимы структуры-описания.

Мета-данные тула (передаются в @mcp.tool(meta=...)) несут два независимых назначения — отбор и отрисовку:

meta={
  "ru": "контрагент контрагенты юрлицо ИНН реквизиты список найти арендатор",  # лексика для отбора
  "domain": "business_entities",
  "entity": "business_entity",                                    # для отрисовки
  "list_fields": ["full_name", "inn", "type", "ogrn"],           # для отрисовки
}

Read/write различаются аннотацией MCP:

annotations=ToolAnnotations(readOnlyHint=True)   # → попадёт в read
# readOnlyHint=False → write (требует HITL)

Обёртка в LangChain-тул (mcp_bridge._wrap):

StructuredTool(
    name=name,
    description=ft.description or name,
    args_schema=ft.parameters,        # JSON-schema из FastMCP
    coroutine=_invoke,
    metadata=dict(ft.meta or {}),     # ru/domain (отбор) + entity/list_fields (отрисовка)
)

Фиксированное ядро отбора:

CORE = {
    "list_objects", "list_contracts", "get_contract", "list_invoices",
    "list_payments", "create_payment", "list_business_entities",
}

Скоринг (лексический, офлайн, без зависимостей):

def _tokens(text): return {w.lower() for w in _WORD.findall(text or "") if len(w) > 2}
def _doc(tool):
    props = tool.args_schema.get("properties", {}) ...
    parts = [tool.name.replace("_"," "), tool.description or "", meta.get("ru",""), " ".join(props)]
    return _tokens(" ".join(parts))
# select: score = len(q & _doc(t)); отбрасываются нулевые; сортировка по убыванию; top-k

HITL-конверт interrupt-а: {"tool": name, "input": kwargs, "summary": summary} — именно он всплывает в act как __interrupt__[0].value и в эндпоинте /act мапится в ProposedAction.

Endpoints

Прямых HTTP-эндпоинтов у самих модулей нет — они внутренние. Их HTTP-поверхность:

Поведение и edge cases

Ограничения

Отбор намеренно наивный: пересечение токенов (слова длиннее 2 символов), офлайн, без эмбеддингов и семантики — синонимы вне meta['ru'] не находятся. CORE зашит константой. summarize_action даёт богатое превью только для create_payment/update_payment, иначе — сырой JSON payload. Реестр строится один раз на процесс (lru_cache(maxsize=1)), session/project инъектируются per-invoke через contextvars. Путь confirm биндит все тулы без отбора. k из agent_service не прокидывается — всегда дефолт 15. Набор тулов агента — строгое подмножество боевого MCP-сервера (агент без floor_plan_editor).

Покрытие тестами

Всего 11 тест-кейсов в трёх файлах.

tests/services/ai/test_mcp_bridge.py (6): test_split_and_schema — разбиение на read/write по readOnlyHint и наличие args_schema.properties; test_metadata_carries_render_keys — render-мета (entity, list_fields) и ru доезжают до StructuredTool.metadata; test_read_executes_without_interrupt — read исполняется без interrupt-а; test_write_calls_interrupt_before_execute — write зовёт interrupt с payload {tool,input} до выполнения; test_unwrap_command_r_envelope — четыре ветки разворота конверта; test_write_unwraps_envelope_before_interrupt — конверт разворачивается ДО interrupt-а.

tests/services/ai/test_tool_selection.py (4): test_payment_query_surfaces_payment_tools — платёжный запрос поднимает create_payment + контрактные read; test_core_always_present — ядро присутствует при нерелевантном запросе; test_ask_mode_excludes_write — в ask write-тулы отсутствуют даже при «создай платёж»; test_size_bounded — размер набора ограничен k + |ядро|.

tests/mcp/test_registry.py (1): test_registry_has_core_tools_and_excludes_floor_plan — реестр содержит ядровые тулы, исключает mark_rooms_on_plan и любые floor_plan-имена, len(names) > 100.

Примечание по зрелованию: тесты бриджа и отбора работают на фейковых реестрах/тулах и мок-объектах; сквозной поток act/confirm с реальным графом покрыт в отдельном tests/services/ai/test_act_confirm.py (вне данной секции). Фича боевая (смонтирована, используется агентом), но с оговорками выше.

Учёт стоимости AI и бюджет-гард

Назначение

Каждый вызов LLM должен оставлять воспроизводимый след стоимости, а совокупные расходы AI-модуля — не выходить за месячный бюджет. Фича решает обе задачи: пишет в ai_cost_log разложенный по четырём ценовым корзинам расход токенов с оценкой в USD (аудит, наблюдаемость, воспроизводимость при смене прайса) и перед каждым платным AI-эндпоинтом сверяет накопленный за месяц расход с лимитом, возвращая 402 ai_budget_exceeded при переборе. Учёт провайдеро-агностичен: одна и та же раскладка обслуживает Anthropic, DeepSeek и локальный Ollama, а добавление нового провайдера сводится к одной строке в MODEL_PRICING.

Как работает (пошагово)

  1. Эндпоинт (ask / act / command / document / call-analysis) сначала зовёт _guard_api_key() (provider-aware 503 ai_not_configured), затем await _enforce_budget(session). Исключение — confirm: он зовёт только _guard_api_key(), бюджет-гард не вызывает.
  2. _enforce_budget читает лимит из settings.ai_monthly_cost_limit_usd; при limit <= 0 guard выключен и сразу возвращает управление. Иначе считает SUM(coalesce(estimate_usd, 0)) по ai_cost_log с начала календарного месяца UTC и при spent >= limit бросает HTTPException(402, "ai_budget_exceeded"). Любое исключение SUM-запроса проглатывается (fail-open) — guard не роняет сервис на сбое БД.
  3. Вызывается модель (через AgentService/run_command/generate_document/analyze_call). LangChain-ответ несёт usage_metadata.
  4. Раскладка по корзинам: _usage_from_messageai/client.py) и _accumulate_usageservices/ai/agent_service.py) переводят usage_metadata в Usage. Ключевая деталь — в LangChain input_tokens уже ВКЛЮЧАЕТ кэш-токены, поэтому input_fresh = input_tokens − cache_read − cache_write (с клампом max(..., 0)), а cache_read/cache_write берутся из input_token_details (cache_read / cache_creation).
  5. Оценка стоимости: estimate_cost_usd(model, ...) умножает каждую корзину на свой тариф из MODEL_PRICING и делит на 1e6. Неизвестная модель → 0.0 (self-hosted/локалки не платные), формула при этом не меняется.
  6. Запись лога: эндпоинт зовёт _log_cost_safe(...), который делает await session.rollback() (сессия могла остаться в failed-состоянии после проглоченной ошибки read-инструмента внутри tool-loop) и затем log_cost(...). log_cost собирает AiCostLog из корзин Usage, кладёт estimate_usd как Decimal(str(...)), flush + commit. Весь блок failure-isolated: сбой лога → rollback + logger.warning, ответ пользователю не ломается.
  7. PII: пользовательский текст запроса в description НЕ пишется. Прямые вызовы /ask и /act логируются с description=None; диспетчнутые из /command — с константным маркером description="via /command". Сам вопрос/команда нигде в ai_cost_log не сохраняется.

Модель данных / схема

Ценовая модель — TokenPrice (цена за 1 млн токенов) и словарь по моделям в ai/config.py:

class TokenPrice(NamedTuple):
    input: float        # некэшированный вход
    output: float       # выход
    cache_read: float   # вход из кэша
    cache_write: float  # запись в кэш

MODEL_PRICING: dict[str, TokenPrice] = {
    MODEL_HAIKU:  TokenPrice(1.00, 5.00, 0.10, 1.25),
    MODEL_SONNET: TokenPrice(3.00, 15.00, 0.30, 3.75),
    MODEL_OPUS:   TokenPrice(5.00, 25.00, 0.50, 6.25),
}

def estimate_cost_usd(model, *, input_fresh=0, cache_read=0, cache_write=0, output=0) -> float:
    price = MODEL_PRICING.get(model)
    if price is None:
        return 0.0
    cost = (input_fresh * price.input + cache_read * price.cache_read
            + cache_write * price.cache_write + output * price.output) / 1_000_000
    return round(cost, 6)

Транспортный контракт корзин — pydantic Usage (ai/schemas.py) с методом слияния вызовов:

class Usage(BaseModel):
    input_fresh: int = 0
    cache_read: int = 0
    cache_write: int = 0
    output: int = 0
    estimate_usd: float = 0.0

    def add(self, other: "Usage") -> "Usage":
        self.input_fresh += other.input_fresh
        self.cache_read += other.cache_read
        self.cache_write += other.cache_write
        self.output += other.output
        self.estimate_usd = round(self.estimate_usd + other.estimate_usd, 6)
        return self

Персистентная запись — SQLAlchemy-модель AiCostLog (models/tenant/ai.py), корзины хранятся раздельно по тарифу, estimate_usd — снапшот на момент записи:

class AiCostLog(Base):
    __tablename__ = "ai_cost_log"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    kind: Mapped[str] = mapped_column(String(64), nullable=False)
    task_class: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
    provider: Mapped[str] = mapped_column(String(20), nullable=False, default="anthropic")
    model: Mapped[str] = mapped_column(String(64), nullable=False)
    tokens_input_fresh: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    tokens_cache_read: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    tokens_cache_write: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    tokens_output: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    estimate_usd: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=Decimal(0))
    usage_raw: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON, nullable=True)
    description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    created_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
    created_time: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)

Таблица создаётся миграцией 0014_ai_persistence (revises 0013_business_entity_hid); provider имеет server_default 'anthropic', все tokens_* и estimate_usdserver_default 0, а под запрос cost-guard заведён индекс ix_ai_cost_log_created_time на created_time. Бюджетный потолок живёт в настройках отдельно от прайса: ai_monthly_cost_limit_usd: float = Field(default=200.0, alias="AI_MONTHLY_COST_LIMIT_USD").

Сам guard (api/v1/endpoints/agent.py):

async def _enforce_budget(session: AsyncSession) -> None:
    limit = getattr(get_settings(), "ai_monthly_cost_limit_usd", 0) or 0
    if limit <= 0:
        return
    try:
        now = datetime.now(timezone.utc)
        month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
        spent = (await session.execute(
            select(func.coalesce(func.sum(AiCostLog.estimate_usd), 0))
            .where(AiCostLog.created_time >= month_start))).scalar() or 0
    except Exception:  # noqa: BLE001 — сбой учёта не блокирует запрос
        return
    if float(spent) >= float(limit):
        raise HTTPException(status_code=402, detail="ai_budget_exceeded")

Endpoints

Все под префиксом /{projectId}/agent (project JWT + tenant AsyncSession):

Поведение и edge cases

Ограничения

Покрытие тестами

Файл tests/test_ai_cost_and_call_records.py содержит 12 тест-кейсов, из которых 9 покрывают именно учёт стоимости и бюджет-гард (оставшиеся 3 — delete_call_record соседней фичи разбора звонков):

AI-инбокс документов (арендаторы + structure-режим)

Назначение

Фича закрывает задачу массового разбора «свалки» входящих файлов: оператор заливает во временную зону (temp/<project>/…) пачку сканов/документов, а бэкенд предлагает, к какому арендатору (режим арендаторов) или к какому зданию/литере/объекту (structure-режим) их отнести, после чего по подтверждению раскладывает их в постоянное хранилище и создаёт записи base_media_files. Классификация комбинирует дешёвые детерминированные эвристики (уникальный ИНН в тексте, литера и категория по имени файла) с LLM/vision-классификацией через LangChain + Claude. Спорные документы разбираются в чат-диалоге (resolve). Модуль расположен в apps/fastapi_backend/src/app/services/ai/doc_inbox/ (service.py — оркестратор, classify.py, extract.py, structure.py) и роутере apps/fastapi_backend/src/app/api/v1/endpoints/doc_inbox.py.

Как работает (пошагово)

Режим арендаторов — classify (DocInboxService.classify):

  1. Грузятся все бизнес-сущности проекта (BusinessEntityService.list_entities(limit=1000)), строятся индекс by_inn (ИНН → список арендаторов), name_by_id и текстовый листинг - id=… | Название | ИНН … для промпта.
  2. Для каждого файла проверяется расширение: если не .docx/.pdf и не изображение — результат method="unsupported", confidence=0.0, без арендатора.
  3. Файл читается из S3 (_read_temp, с проверкой префикса temp/<project>/); при ошибке — method="error".
  4. extract.doc_content_blocks(filename, data) возвращает (text | None, doc_blocks).
  5. Быстрый путь по ИНН: если есть текст и match_inn_fast нашёл ровно одного уникального арендатора — method="inn", confidence=0.99, doc_type="—", LLM не вызывается.
  6. Если контент извлечь не удалось (doc_blocks пуст) — method="error", «Не удалось извлечь содержимое документа».
  7. Иначе — classify_llm: model.bind_tools([ClassifyDocument], tool_choice="ClassifyDocument") (forced tool use) с системным промптом DOC_CLASSIFY_SYSTEM. method="vision", если text is None (скан/изображение), иначе "llm".

resolve (DocInboxService.resolve): заново читает файл, собирает контекст (при недоступном контенте подставляется блок «(содержимое недоступно)»), прогоняет историю сообщений через resolve_llm с bind_tools([ResolveDocument], tool_choice="auto"). Если модель вызвала инструмент — status="resolved" (арендатор, doc_type, reply); иначе status="question" и текст вопроса из resp.content.

commit (DocInboxService.commit): по каждому элементу — пропуск, если нет business_entity_id или путь не начинается с temp/<project>/; иначе file_service.move_file(project, src, dst, make_public=False) в {url_name}/business-entities/{be_id}/{uuid}{ext} и media.create(...) c reference_object_type=BUSINESS_ENTITY. Коммит делается на каждый элемент отдельно, при исключении — rollback + warning + continue (битый/уже перенесённый файл не рушит батч и не откатывает уже разложенное — идемпотентность повторного импорта).

folders: list_folders группирует медиа арендаторов по reference_object_id (count + max updated_time), сортирует по последнему обновлению; folder_docs отдаёт документы папки с download_url=/{url_name}/file/get/?path=….

structure-режим (structure.py):

Модель данных / схема

Таблица-приёмник — BaseMediaFile (base_media_files, зеркало Django):

class BaseMediaFile(Base):
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
    description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
    category: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)  # structure-режим
    path: Mapped[str] = mapped_column(String(250), nullable=False)
    reference_object_type: Mapped[ReferenceObjectTypeEnum]  # SAEnum(native_enum=False)
    reference_object_id: Mapped[int] = mapped_column(BigInteger, nullable=False)

Используемые значения ReferenceObjectTypeEnum: BUSINESS_ENTITY (режим арендаторов), OBJECT/BUILDING (structure). В режиме арендаторов тип документа кладётся в description; в structure — категория в category.

LLM-инструменты (pydantic, bind_tools):

class ClassifyDocument(BaseModel):
    business_entity_id: Optional[int]; confidence: float = 0.0
    doc_type: str = "Прочее"; reason: str = ""

class ResolveDocument(BaseModel):
    business_entity_id: int; doc_type: Optional[str]; reason: Optional[str]

class ClassifyStructureDocument(BaseModel):
    scope: str  # building | object
    building_id: Optional[int]; category: str = "other"
    confidence: float = 0.0; reason: str = ""

Извлечение (extract.py): _MAX_TEXT_CHARS=8000, _MIN_PDF_TEXT=40, image_mime = jpg/jpeg/png/webp/gif. doc_content_blocks: DOCX → всегда текст (extract_docx, включая таблицы); PDF → текст если ≥ 40 символов, иначе base64-document-блок (vision); изображение → base64-image-блок. Быстрый ИНН: _INN_RE = \b(\d{10}|\d{12})\b; match_inn_fast возвращает арендатора только при len(uniq_ids) == 1.

API-схемы (doc_inbox.py): InboxFile{file_path, original_filename}, Suggestion{file_path, original_filename, business_entity_id?, entity_name?, confidence, doc_type, method, reason}, CommitItem{file_path, original_filename, business_entity_id, name?, doc_type?}, SavedItem{id, business_entity_id, name?, path}, ResolveResponse{status, reply, business_entity_id?, entity_name?, doc_type?}, FolderItem/FolderDoc, StructureSuggestion{scope?, building_id?, building_name?, category, …}, StructureCommitItem{scope, building_id?, category, name?}, StructureSavedItem{reference_object_type, reference_object_id, …}.

Endpoints (роутер подключается с префиксом /{projectId}/doc-inbox в router.py)

Гейт _require_key() — provider-aware: ollama не требует ключа, deepseek — по deepseek_api_key, иначе anthropic_api_key; при отсутствии ключа — 503 {"detail":"ai_not_configured"} (функция при успехе всегда возвращает anthropic_api_key). commit/structure/commit LLM не вызывают, но защищены тем же гейтом; folders-эндпоинты работают без ключа. Все эндпоинты защищены verify_jwt_for_project.

Поведение и edge cases

Покрытие тестами

Всего 11 тест-кейсов в 4 файлах; преимущественно детерминированные хелперы плюс один сервис-тест skip-ветки commit (все без S3/LLM):

LLM-оркестрация (classify_llm/resolve_llm/_classify_llm), путь move_file+media.create в commit/commit_structure, resolve, folders интеграционными тестами не покрыты (покрыта лишь skip-ветка commit без business_entity_id) — отсюда зрелость caveats: логика раскладки и границы (vision только для изображений/PDF-сканов, поддерживаемые форматы, идемпотентность) реализованы и осмысленны, но проверены преимущественно на уровне детерминированных хелперов.

Разбор звонков отдела продаж

Назначение

Фича превращает транскрипт телефонного звонка менеджера отдела продаж с клиентом в структурированный разбор CallAnalysis (резюме, потребности клиента, возражения, оценка качества менеджера по критериям 0–2, теплота лида, следующие шаги, готовые данные для карточки и флаги-риски) и позволяет сохранить этот разбор в карточку контрагента. Реализация живёт в apps/fastapi_backend/src/app/ai/calls.py, HTTP-обвязка — в apps/fastapi_backend/src/app/api/v1/endpoints/agent.py, персистентность — в модели AiCallRecord (apps/fastapi_backend/src/app/models/tenant/ai.py). Звонок отнесён к классу задач TaskClass.CALL (= "call") и по умолчанию считается моделью класса Sonnet (MODEL_SONNET = "claude-sonnet-4-6").

Как работает (пошагово)

  1. Приём транскрипта. ingest_transcript(text) тримит вход и на пустой строке кидает ValueError("Пустой транскрипт"). Это единая «точка входа» текста — в докстроке помечена как место для будущего аудио → STT (Whisper), но самой STT-обработки нет.
  2. Анализ. Эндпоинт POST /call-analysis вызывает analyze_call(transcript, meta, api_key=...). Функция берёт AI-клиента (get_ai_client() или переданный), прогоняет транскрипт через ingest_transcript, при наличии meta дописывает строку Метаданные звонка: k=v, ..., и вызывает client.structured_output(model=MODEL_SONNET, output_format=CallAnalysis, system=_SYSTEM, messages=[...], max_tokens=MAX_TOKENS_DEFAULT). Системный промпт _SYSTEM фиксирует роль «аналитик отдела продаж коммерческой недвижимости» и правила заполнения (не выдумывать, шкала quality 0–2, семантика temperature hot/warm/cold, null для непрозвучавших потребностей, компактные пары в crm_updates, только реальные риски в flags). Возвращается {"analysis": <dump>, "model_used": MODEL_SONNET, "usage": <dump>}.
  3. Учёт стоимости. После успешного анализа эндпоинт логирует расход через _log_cost_safe(kind="call_analysis", model=MODEL_SONNET, usage_data=..., task_class=TaskClass.CALL, user_id=...) (failure-isolated: сбой лога не ломает ответ). Provider в лог не передаётся — берётся дефолт "anthropic".
  4. Сохранение в карточку. Разбор уже готов на стороне ассистента; POST /call-analysis/save вызывает save_call_record(...), который повторно модель не зовёт (не тратит токены). Он тримит транскрипт через ingest_transcript, валидирует переданный analysis через CallAnalysis.model_validate, денормализует поля для списка и вставляет строку AiCallRecord, затем flush + commit. Возвращает {"id", "created_time"}.
  5. Чтение/удаление. GET .../counterparties/{id}/call-recordslist_call_records (свежие сверху). DELETE .../call-records/{record_id}delete_call_record (детерминированно, без модели).

Модель данных / схема

Pydantic-контракт разбора (ai/calls.py):

class ClientRequirements(BaseModel):
    area_sqm: str | None = Field(default=None, ...)
    budget: str | None = Field(default=None, ...)
    location: str | None = Field(default=None, ...)
    timeline: str | None = Field(default=None, ...)
    purpose: str | None = Field(default=None, ...)

class QualityScore(BaseModel):
    greeting: int = Field(ge=0, le=2)
    needs_qualification: int = Field(ge=0, le=2)
    objection_handling: int = Field(ge=0, le=2)
    next_step_set: int = Field(ge=0, le=2)
    script_adherence: int = Field(ge=0, le=2)

class CallAnalysis(BaseModel):
    summary: str
    client_requirements: ClientRequirements
    objections: list[str] = Field(default_factory=list)
    quality: QualityScore
    temperature: Literal["hot", "warm", "cold"]
    next_actions: list[str] = Field(default_factory=list)
    crm_updates: dict[str, Any] = Field(default_factory=dict)
    flags: list[str] = Field(default_factory=list)

Денормализация: _quality_total(analysis) = сумма пяти критериев QualityScore (диапазон 0–10), кладётся в отдельную колонку для списков на карточке.

SQLAlchemy-модель (models/tenant/ai.py):

class AiCallRecord(Base):
    __tablename__ = "ai_call_records"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    counterparty_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
    transcript: Mapped[str] = mapped_column(Text, nullable=False)
    summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    temperature: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)  # hot/warm/cold
    quality_total: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)   # 0..10
    analysis: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)         # полный CallAnalysis
    model_used: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    created_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
    created_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

counterparty_id намеренно сделан плоским индексируемым Integer, а не FK: таблица business_entities принадлежит Django, жёсткий FK не заводится. Таблица создаётся миграцией 0014_ai_persistence (вместе с ai_generated_documents и ai_cost_log), там же — индекс ix_ai_call_records_counterparty_id.

API-схемы запросов/ответов (endpoints/agent.py): CallAnalysisRequest{transcript: min_length=1, meta}, CallAnalysisResponse{analysis, model_used, usage}, CallRecordSaveRequest{counterparty_id, transcript: min_length=1, analysis, model_used?}, CallRecordSaveResponse{id, created_time}, CallRecordOut{id, created_time, summary, temperature, quality_total, analysis, model_used}.

Endpoints (метод + путь)

Все эндпоинты project-scoped: verify_jwt_for_project + tenant AsyncSession (get_tenant_session). Есть также «одностроковый» путь через /command с intent=CALL, но это заглушка (not_implemented=True, текст «Анализ звонков одной строкой — на подходе (Фаза 4).»).

Поведение и edge cases

Ограничения

Покрытие тестами

Единственный тест-файл — tests/test_ai_cost_and_call_records.py (12 тест-функций всего, из них по данной фиче — 3, все про путь удаления):

  1. test_delete_call_record_removes_existing_row — хелпер delete_call_record удаляет существующую строку, возвращает True, в таблице 0 записей.
  2. test_delete_call_record_missing_returns_false — по несуществующему id → False.
  3. test_delete_call_record_endpoint_success_then_404 — эндпоинт DELETE: успех → {"success": True} и записи нет; повтор → HTTPException 404 с detail Call record not found.

Прямых тестов на analyze_call (структурный вызов Sonnet), ingest_transcript, save_call_record (включая валидацию схемы и денормализацию quality_total) и list_call_records нет — ядро фичи не покрыто. Косвенно AiCallRecord фигурирует в тестах как фикстура, но не как проверяемое поведение фичи. Остальные 9 тестов файла проверяют раскладку usage по корзинам, cost-лог /ask и /act, изоляцию сбоя лога, роутинг /command и месячный budget-guard — к разбору звонков напрямую не относятся.

AI-генерация документов по шаблону

Назначение

Реализует подход «шаблон + ИИ»: юридический каркас — это выверенный docx-шаблон из системы document_templates, а модель отвечает ТОЛЬКО за маппинг данных CRM в простые строковые переменные шаблона. Реквизиты сторон (системный тег contract) подставляются детерминированно из build_contract_context по contract_id, минуя модель. Рендер docx выполняется на нашей стороне (docxtpl через DocumentTemplatesService.generate). Результат сохраняется как версия вывода AiGeneratedDocument вместе с диффом 80/20 (что заполнено автоматически vs что проверить) и логом стоимости вызова.

Точка входа — generate_document в app/ai/documents.py; реестр читается list_generated_documents там же.

Как работает (пошагово)

  1. Разрешается AI-клиент: переданный client либо get_ai_client().
  2. По template_id через DocumentTemplatesRepository.get_template берётся шаблон. Если шаблона нет → {"status": "error", "message": "Шаблон не найден"}; если current_version_id is None{"status": "error", "message": "У шаблона нет опубликованной версии"}.
  3. Загружаются переменные опубликованной версии. Они делятся: _fillable_vars (все, у кого value_type != "contract") и флаг _has_contract_tag (есть ли переменная с value_type == "contract").
  4. Гейт выбора договора: если в шаблоне есть тег contract, но contract_id is None, возвращается status: "needs_input", needs_field: "contract_id", пустые auto_filled/needs_review и сообщение «Укажите договор — из него берутся стороны и их реквизиты.».
  5. Сбор контекста CRM: если contract_id задан, context["contract"] = await build_contract_context(session, contract_id) — тот же богатый человекочитаемый источник (имена сторон, номер, даты, позиции/помещения), что и для детерминированного тега contract.
  6. Если есть заполняемые переменные — собирается промпт: построчный список переменных (- {name} ({value_type}) [обязательная]: {description}), JSON-контекст CRM и указания пользователя. Вызывается client.structured_output(model=MODEL_SONNET, output_format=DocMapping, system=_SYSTEM, messages=[...], max_tokens=MAX_TOKENS_DOCUMENT), возвращающий (DocMapping, Usage).
  7. Значения фильтруются защитно: остаются только ключи из множества известных имён переменных (known = {v.name for v in fillable}) и непустые строки (защита от галлюцинированных ключей). needs_review матчится по известным именам: entry == k or entry.startswith(k) (модель иногда возвращает «имя: причина»), результат — отсортированный список.
  8. Рендер: build_document_templates_service(repo, session, project).generate(template_id, values, contract_id=contract_id); внутри же валидируются обязательные поля. Если рендер бросает исключение (например BusinessRuleError «не заполнены обязательные…»), возвращается status: "needs_input" с message=str(exc), при этом отдаются накопленный auto_filled=values, needs_review, model_used, usage — но без файла.
  9. Формируется filename = f"{(template.name or 'document').strip()}.docx" и дифф 80/20: auto_filled — значения не из review, needs_review{name: value} по именам из review.
  10. Персист только на success и атомарно (оба хелпера с commit=False, один общий session.commit()): persist_generated_document(... kind="document_generation", status="ok", task_class=TaskClass.TEMPLATED, template_id, contract_id, file_path=None, auto_filled, needs_review, model_used=MODEL_SONNET, user_id) и log_cost(... kind="document_generation", model=MODEL_SONNET, usage, task_class=TaskClass.TEMPLATED, user_id, description="template=… contract=…"). Любой сбой записи изолирован: session.rollback() + logger.warning, документ всё равно отдаётся (сбой персиста не должен ронять выдачу).
  11. Возврат: {"status": "ok", "file_base64": <docx в base64>, "filename", "auto_filled", "needs_review", "model_used": MODEL_SONNET, "usage": usage.model_dump()}. Файл отдаётся inline (base64); S3-записи на этом шаге нет, поэтому file_path=None, signatory_id неприменим.

Модель данных / схема

Pydantic-контракт результата маппинга (app/ai/documents.py):

class DocMapping(BaseModel):
    values: dict[str, str] = Field(default_factory=dict, ...)       # ключи — точные имена переменных
    needs_review: list[str] = Field(default_factory=list, ...)      # имена переменных под проверку

SQLAlchemy-модель AiGeneratedDocument (таблица ai_generated_documents, app/models/tenant/ai.py):

id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(64), nullable=False)          # lease_agreement, ...
task_class: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
template_id: Mapped[Optional[int]] = mapped_column(
    ForeignKey("document_templates.id", ondelete="SET NULL"), nullable=True)
contract_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)   # плоский, без FK
signatory_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
file_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # S3-ключ (None здесь)
status: Mapped[str] = mapped_column(String(20), nullable=False)              # ok / error / ...
auto_filled: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)     # дифф 80/20
needs_review: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
model_used: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_time: Mapped[datetime] = mapped_column(
    DateTime(timezone=True), server_default=func.now(), nullable=False)

Смежная модель AiCostLog (таблица ai_cost_log) хранит токены по ценовым корзинам (tokens_input_fresh, tokens_cache_read, tokens_cache_write, tokens_output), provider (default anthropic), estimate_usd Numeric(12, 6), usage_raw JSON, description Text; на created_time — индекс ix_ai_cost_log_created_time под помесячный cost-guard SUM(estimate_usd).

Миграция 0014_ai_persistence (revision = "0014_ai_persistence", down_revision = "0013_business_entity_hid") создаёт три таблицы: ai_generated_documents (без индексов), ai_cost_log (+ индекс по created_time), ai_call_records (+ индекс по counterparty_id; принадлежит фиче разбора звонка). Ограничение длины revision id ≤32 символов зафиксировано комментарием (тип alembic_version.version_numvarchar(32)).

Endpoints

Роутер agent смонтирован с префиксом /{projectId}/agent (router.py: v1_router.include_router(agent.router, prefix="/{projectId}/agent")). Отдельно, вне AI-потока, у роутера шаблонов есть детерминированный POST /{template_id}/generate (StreamingResponse, «не сохраняется») — это ручной рендер без модели.

Prefill в detail-ответах: отдельного detail-эндпоинта по id нет. Дифф-prefill (auto_filled / needs_review), которым фронт заполняет форму проверки, поставляется двумя путями — в ответе POST /document и как поле auto_filled в строках реестра (list_generated_documents). Реестр отдаёт фиксированный набор ключей: id, kind, task_class, template_id, contract_id, status, auto_filled, model_used, created_by_user_id, created_timeneeds_review в список не выводится.

Поведение и edge cases

Ограничения

Покрытие тестами

Файл tests/test_ai_documents_persistence.py — 3 тест-кейса (fake AI-клиент возвращает валидный DocMapping с пустым needs_review, fake S3-хранилище; используется опубликованный простой шаблон без тега contract):

  1. test_generate_document_persists_document_and_cost — успешный путь пишет ровно одну AiGeneratedDocument (status="ok", kind="document_generation", task_class="templated", template_id, model_used, created_by_user_id=42, file_path is None, auto_filled == {"Имя": "Иван"}) и ровно один AiCostLog (kind, model, tokens_input_fresh=1200, tokens_output=340, estimate_usd > 0, created_by_user_id=42).
  2. test_generate_document_failure_isolationpersist_generated_document подменён на бросающий; результат всё равно ok с file_base64, транзакция откачена → в БД 0 документов и 0 логов стоимости.
  3. test_list_generated_documents_returns_seeded_rows — 2 засеянные строки, свежие сверху (при равном created_time тай-брейк по id desc → первым template_id=2), auto_filled выводится в реестр (80/20-prefill), у второй строки auto_filled is None, набор ключей строки проверяется точно, пагинация limit=1 offset=1 возвращает template_id=1.

Не покрыты: ветки needs_input (гейт договора и незаполненные обязательные), ветки error, фильтрация галлюцинированных ключей, prefix-матчинг needs_review, построение контекста договора и реальный structured_output.

Единая командная строка /agent/command (авто-роутинг намерений)

Назначение

Одно текстовое поле портала вместо ручного выбора режима «Спросить/Сделать/Документ/…». Пользователь пишет фразу одной строкой, дешёвый классификатор на Haiku определяет ОДНО намерение, а оркестратор run_command роутит запрос в существующего исполнителя. Это «мозг» ассистента: intent_classifier отвечает за распознавание намерения, command.run_command — за диспетчеризацию и учёт стоимости. Модули router/classifier.py и router/model_router.py лежат рядом, но относятся к другому (TaskClass-ориентированному) пайплайну и в командную строку не подключены.

Как работает (пошагово)

  1. Эндпоинт POST /{projectId}/agent/command (app/api/v1/endpoints/agent.py) принимает CommandRequest{message}, проверяет ключ провайдера (_guard_api_key → 503 ai_not_configured) и месячный бюджет (_enforce_budget → 402 ai_budget_exceeded), затем вызывает run_command(...).
  2. run_command (app/ai/command.py) берёт AI-клиент (client or get_ai_client(), в тестах подменяется) и зовёт classify_intent(client, message) — один вызов Haiku со structured output, возвращает (IntentResult, Usage).
  3. Классификация логируется отдельной строкой стоимости: _log_cost_safe(kind="command_classify", model=MODEL_HAIKU, task_class=None). Эта строка пишется всегда, независимо от итогового интента.
  4. Формируется базовый ответ base = {intent, reason, usage} и дальше идёт ветвление по intent_result.intent:
  1. Эндпоинт перекладывает dict в CommandResponse (набор заполненных полей зависит от интента).

Учёт стоимости изолирован: _log_cost_safe перед вставкой делает session.rollback() (сессия могла остаться в failed-состоянии после проглоченной ошибки read-инструмента в tool-loop диспетчнутого ask/act), а любой сбой log_cost гасится в warning и не ломает ответ.

Модель данных / схема

Классификатор (app/ai/router/intent_classifier.py) — Pydantic/enum, не БД:

class CommandIntent(str, Enum):
    ASK = "ask"; NAVIGATE = "navigate"; ACT = "act"
    DOCUMENT = "document"; ANALYTICS = "analytics"; CALL = "call"; HELP = "help"

NAV_ENTITIES = ("object","building","room","contract","invoice","counterparty","payment","meter")

class NavTarget(BaseModel):
    entity: str = Field(description=f"Одна из: {', '.join(NAV_ENTITIES)}")
    query: str  = Field(description="Идентификатор/номер/название для поиска сущности")

class IntentResult(BaseModel):
    intent: CommandIntent
    nav: NavTarget | None = None      # заполнять только при intent=navigate
    doc_kind: str | None = None       # slug документа при intent=document
    reason: str = ""

classify_intent вызывает client.structured_output(model=MODEL_HAIKU, output_format=IntentResult, system=_SYSTEM, messages=[{"role":"user","content":text}], max_tokens=512). В client.structured_output схема форсируется через bind_tools([IntentResult], tool_choice="IntentResult"); отсутствие tool_call → ValueError.

API-схемы эндпоинта (agent.py):

class CommandRequest(BaseModel):
    message: str = Field(..., min_length=1)

class CommandResponse(BaseModel):
    intent: str; reason: str = ""
    answer: Optional[str] = None; used_tools: Optional[list[str]] = None
    status: Optional[str] = None; action: Optional[ProposedAction] = None
    thread_id: Optional[str] = None; nav: Optional[NavTargetOut] = None
    doc_kind: Optional[str] = None; not_implemented: bool = False
    usage: Optional[dict[str, Any]] = None

Новых таблиц/колонок фича не вводит. Единственный побочный эффект в БД — записи в существующий ai_cost_log (AiCostLog) с новым значением kind="command_classify" для строки классификации (плюс обычные ask/act для диспетчнутых веток).

Смежный, но НЕ подключённый к командной строке слой:

Endpoints

Поведение и edge cases

Ограничения

Покрытие тестами

Файл tests/test_ai_cost_and_call_records.py содержит 12 тест-функций, но командную строку сквозь run_command затрагивают только 2:

Оба теста подменяют классификатор моком _FakeIntentClient, то есть проверяют РОУТИНГ и учёт стоимости в run_command, а не качество распознавания. Ветки ACT, ANALYTICS, HELP, DOCUMENT, CALL, а также classify_intent, select_model и router.classifier.classify собственных тестов не имеют. Остальные тесты файла проверяют смежное: раскладку usage по корзинам (_accumulate_usage), delete_call_record (хелпер + эндпоинт), cost-лог и budget-guard эндпоинтов /ask и /act.

Импорт банковской выписки и матчинг платежей

Детерминированный (без AI) конвейер загрузки банковской выписки: разбор файла в нормализованные транзакции, сопоставление приходов с договорами/счетами по реквизитам и создание платежей с защитой от повторного импорта.

Назначение

Дать пользователю загрузить выписку из банк-клиента в одном из трёх форматов (1С 1CClientBankExchange, CSV, SWIFT MT940), увидеть предпросмотр (драфт) распознанных приходных платежей, разложенных по уровню уверенности сопоставления, отредактировать привязки и подтвердить создание платежей. Разбор и матчинг полностью детерминированы — ANTHROPIC_API_KEY не требуется, и эти эндпоинты (в отличие от ask/act/command) не бросают 503.

Как это работает (пошагово)

  1. preview принимает файл и опциональный fmt. parse_statement(content, fmt)services/bank_statement/__init__.py) резолвит формат: явный fmt или detect_format по сигнатуре. detect_format распознаёт 1c (первые строки начинаются с 1CClientBankExchange) и mt940 (наличие :61: или начало с :20:); CSV по сигнатуре не определяется — для него fmt=csv обязателен, иначе UnsupportedStatementError.
  2. Соответствующий парсер приводит файл к списку NormalizedTxn с единой моделью (дата, сумма, направление, назначение, реквизиты плательщика, bank_transaction_id).
  3. Приходные (direction == IN) идут в матчинг, расходные считаются в skipped_outgoing и в драфт не попадают.
  4. match_transactions присваивает каждой приходной транзакции уровень уверенности словами (confident/needs_review/unmatched) и предложенные привязки, читая справочники (не пишет в БД).
  5. preview группирует результаты по уровням, формирует человеко-читаемое summary (с русской плюрализацией) и возвращает StatementPreviewResponse. В БД ничего не пишется.
  6. commit принимает отредактированный клиентом список строк драфта и для каждой создаёт Payment со статусом UNPROCESSED через PaymentsService.create_payment, с дедупликацией по transaction_id. Распределение платежей по счетам делает почасовой tasks/process_payments, не этот сервис.

Разбор форматов

1С (parser_1c.py). Текстовый секционный формат, кодировка обычно cp1251 (декодер перебирает cp1251utf-8-sigutf-8, крайний случай — cp1251 с errors="replace"). Из шапки берётся РасчСчет — счёт владельца. Документы между СекцияДокумент и строкой-маркером КонецДокумента (терминатор проверяется до фильтра строк без =, иначе секция не закроется). Направление: IN, если ПолучательСчет == own_account; OUT, если ПлательщикСчет == own_account; фолбэк по ДатаПоступило/ДатаСписано; по умолчанию IN. transaction_id строится с учётом того, что номер п/п присваивает отправитель и он не уникален между плательщиками:

payer_key = payer_account or (fields.get("ПлательщикИНН") or "").strip() or "?"
bank_txn_id = f"{payer_key}-{number}-{doc_date.isoformat()}"
# пример: "40702810900000000777-123-2026-06-01"

CSV (parser_csv.py). Авто-детект разделителя (;/,/таб) через csv.Sniffer (фолбэк — ;) и кодировки. Заголовки маппятся на логические поля по словарю синонимов DEFAULT_HEADER_SYNONYMS (рус./англ.), переопределяемому параметром column_map. Направление: колонка credit/приход → IN, debit/расход → OUT, иначе по знаку общей суммы value (отрицательная → OUT, abs). Нулевые суммы и строки без валидной даты отбрасываются. bank_transaction_id берётся из колонки номер/id/идентификатор (может отсутствовать).

MT940 (parser_mt940.py). Лёгкий парсер: тег :61: (строка операции) + многострочный :86: (назначение). Признак направления из _LINE61_RE (RC|RD|C|D):

direction = TxnDirection.OUT if mark in ("D", "RC") else TxnDirection.IN
# C→IN, D→OUT, RC (реверс кредита, отток)→OUT, RD (реверс дебета, приток)→IN

bank_transaction_id — ссылка после // в хвосте :61: (с guard против пустого хвоста: :61:...// без ссылки не падает IndexError, ref = None). ИНН плательщика извлекается из текста :86: регуляркой \b(\d{12}|\d{10})\b.

Модель данных / схема

Общая нормализованная транзакция (services/bank_statement/types.py):

class TxnDirection(str, Enum):
    IN = "IN"   # приход — только такие импортируем
    OUT = "OUT" # расход — в импорт не попадают

@dataclass(slots=True)
class NormalizedTxn:
    date: date
    value: Decimal
    direction: TxnDirection
    purpose: str = ""
    payer_inn: Optional[str] = None
    payer_account: Optional[str] = None
    payer_name: Optional[str] = None
    bank_transaction_id: Optional[str] = None  # None → матчер пометит needs_review

Pydantic-схемы драфта (schemas/bank_statement.py):

Confidence = Literal["confident", "needs_review", "unmatched"]

class DraftPaymentLine(BaseModel):
    bank_transaction_id: Optional[str] = None
    date: date; value: Decimal; purpose: str = ""
    payer_inn / payer_name / payer_account: Optional[str] = None
    confidence: Confidence
    contract_id: Optional[int] = None
    invoice_id: Optional[int] = None
    candidates: list[CandidateLink] = Field(default_factory=list)
    possible_duplicate: bool = False

class StatementPreviewResponse(BaseModel):
    confident / needs_review / unmatched: list[DraftPaymentLine]
    summary: str = ""; fmt: Optional[str] = None; skipped_outgoing: int = 0

class StatementCommitResponse(BaseModel):
    created / skipped_duplicates / failed: int = 0; summary: str = ""

Запрос commit — отдельная схема StatementCommitRequest(lines: list[DraftPaymentLine]).

Целевая таблица payments — SQLAlchemy-зеркало Django-модели (models/tenant/payments.py); ключевой для дедупа — уникальный transaction_id:

transaction_id: Mapped[Optional[str]] = mapped_column(String(50), unique=True, nullable=True)
description:    Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
status:        Mapped[str] = mapped_column(String(30), nullable=False)  # commit → UNPROCESSED

Лимиты БД зеркалятся в сервисе константами _TRANSACTION_ID_MAX = 50, _DESCRIPTION_MAX = 200.

Матчинг (payment_matcher.py)

ИНН плательщика берётся из явного поля payer_inn (10/12 цифр), иначе из текста назначения: сначала по ключевому слову ИНН\s*:?\s*(\d{12}|\d{10}), затем как отдельно стоящее 10/12-значное число (границы (?<!\d)…(?!\d) не дают спутать с 20-значным счётом). Контрагент резолвится по ИНН (поле уникально), вспомогательно — по расчётному счёту (только если по счёту находится ровно один контрагент). Каскад правил (первое сработавшее задаёт уровень):

  1. confident — ровно один открытый счёт контрагента с остатком, точно равным сумме транзакции → invoice_id + его contract_id;
  2. confident — ровно один активный договор (нет точного счёта) → contract_id (счёт подберёт process_payments);
  3. needs_review — контрагент определён, но кандидатов несколько / сумма не сходится → список candidates (обрезается до _MAX_CANDIDATES = 25);
  4. unmatched — контрагент не определён.

«Активный договор» — статус в (BOOKED, REGISTERED) и дата попадает в интервал действия, контрагент — арендатор (lessee_id). «Открытый счёт» — ContractInvoice.value (не удалён, deleted is False) минус сумма PaymentAllocation.allocated_value, остаток > 0. Отдельное усиление: транзакция без bank_transaction_id не может быть confident (нет ключа дедупа) — принудительно понижается до needs_review и помечается possible_duplicate = True (флаг ставится всегда при отсутствии id, независимо от уровня).

Идемпотентность (commit)

Дедуп идёт по уникальному transaction_id. Для строк без банковского id (CSV/MT940 без ссылки) commit синтезирует стабильный ключ:

digest = hashlib.sha1(f"{line.date}|{line.value}|{line.purpose or ''}".encode("utf-8")).hexdigest()[:24]
transaction_id = f"auto-{digest}"

Повторный импорт той же выписки не задваивает платежи: create_payment делает предпроверку exists_by_transaction_id и бросает ConflictError → строка идёт в skipped_duplicatessession.rollback()). Гонка на уникальном индексе (IntegrityError) тоже считается дубликатом, а не ошибкой. Прочие сбои (напр. несуществующий contract_idNotFoundError из _validate_references) → failed. Каждый платёж коммитится своей транзакцией (create_payment вызывает session.commit()).

Endpoints

Поведение и edge cases

Ограничения

CSV требует явного fmt (не детектится по сигнатуре). Ключ синтетического дедупа на commit (date+value+purpose) не совпадает с ключом предупреждения possible_duplicate в матчере (date+value+payer_account). 1С direction по умолчанию — IN. commit не фильтрует по confidence: создаёт Payment для каждой присланной строки, включая unmatched (contract_id=None допустим — поле nullable). Драфт не хранится на сервере, клиент возвращает его целиком. Таблицы payments/payment_allocations — Django-owned зеркала, своих таблиц фича не заводит. Часть кодовых веток тестами не покрыта: CSV авто-детект разделителя/кодировки и column_map, MT940-реверсы RC/RD (единственный CSV-тест проверяет только знаковые суммы).

Покрытие тестами

Всего 34 тест-кейса в tests/bank_statement/:

Вычисляемые счётчики (SUM / DIFFERENCE)

Назначение

Фича даёт «считающийся сам» (виртуальный) счётчик — узел, у которого нет собственных показаний, а его значение за период выводится из показаний нескольких реальных счётчиков-операндов. Каждый операнд входит в формулу со знаком (+1 / −1), знаки определяются выбранной операцией: SUM (сумма расходов) или DIFFERENCE (первый операнд минус остальные). Такой счётчик можно разместить на плане объекта (координаты иконки). Таблицы — новые, FastAPI-owned; связи на Django-таблицы (metered_resources, meters, plans) read-only, миграциями Django они не трогаются.

Как работает (пошагово)

Создание (create_computed_meter):

  1. _validate_resource — проверка, что resource_id указывает на существующий MeteredResource, иначе NotFoundError(field="resource_id").
  2. _validate_operand_meters — операндов должен быть хотя бы один (BusinessRuleError), без дублей meter_id (BusinessRuleError), все meter_id должны существовать в meters (NotFoundError).
  3. Создаётся строка computed_meters (operation записывается как .value enum).
  4. _derive_signs(operation, operands) выводит per-operand sign и нормализует position; результат пишется через replace_operands.
  5. session.commit(), возврат перечитанного ComputedMeterResponse.

Вычисление значения (compute_value(id, period_start, period_end)):

  1. Загружается счётчик с операндами; если нет — возвращается None (эндпоинт отдаёт 404).
  2. Определяется unit из ресурса вычисляемого счётчика; одним запросом подтягиваются счётчики-операнды (coefficient, identifier).
  3. Для каждого операнда:
  1. value и consumption квантуются до 0.01; notes — склейка заметок через пробел или None.

Модель данных / схема

SQLAlchemy (models/tenant/computed_meters.py) — операнд со знаком и позицией, коллекция с delete-orphan:

class ComputedMeterOperand(Base):
    __tablename__ = "computed_meter_operands"
    __table_args__ = (
        UniqueConstraint("computed_meter_id", "meter_id", name="uq_computed_meter_operands"),
        ...
    )
    sign: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
    position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")

# на ComputedMeter:
operands: Mapped[list["ComputedMeterOperand"]] = relationship(
    "ComputedMeterOperand", back_populates="computed_meter",
    cascade="all, delete-orphan",
    order_by="ComputedMeterOperand.position", lazy="selectin",
)

ComputedMeterPlanCoordinates — отдельная таблица (не общий plan_object_coordinates), потому что computed_meter_id в ней unique=True.

Pydantic (schemas/computed_meters.py): operation — enum ComputedMeterOperationEnum (SUM / DIFFERENCE, дефолт SUM). Во вход-операнде sign/position опциональны:

class ComputedMeterOperandInput(BaseModel):
    meter_id: int
    sign: Optional[int] = None      # знак выводится из operation, если не задан
    position: Optional[int] = None  # иначе — из порядка в списке

compute_value отдаёт ComputedMeterValueResponse{computed_meter_id, period_start, period_end, value: Decimal, unit, components[], missing: list[int], notes}, где ComputedMeterValueComponent{meter_id, identifier, sign, consumption, is_interpolated}.

Вывод знаков (_derive_signs): SUM → все +1; DIFFERENCE → первый по effective position +1, остальные −1; явно заданный sign нормализуется (1 if sign >= 0 else -1). «Первый» для DIFFERENCE берётся по position операнда (с fallback на индекс), а не по порядку элементов во входном списке — клиент может прислать операнды не по порядку.

Endpoints

Роутер счётчиков (prefix /{projectId}/resources-services/computed-meters):

Роутер координат (prefix /{projectId}/resources-services/computed-meter-plan-coordinates): GET / (фильтры plan_id, computed_meter_id), POST / (201), PATCH /{id}, DELETE /{id} (200, {success: true}). Все эндпоинты за verify_jwt_for_project.

Поведение и edge cases

replace_operands (фикс UNIQUE): полная замена операндов делается мутацией relationship-коллекции с промежуточным flush — сначала cm.operands.clear() и flush(), только затем присваивание нового набора и повторный flush(). Без раздельного flush на ПЕРЕСЕКАЮЩЕМСЯ наборе meter_id INSERT нового операнда шёл бы до DELETE старого-орфана в одном flush → нарушение uq_computed_meter_operands → IntegrityError/500 на Postgres.

Смена operation без operands (PATCH-diff): update_computed_meter использует model_dump(exclude_unset=True, exclude={"operands"}), то есть в PATCH приходят только изменённые поля. Если пришли operands — они валидируются, знаки выводятся из body.operation (или из текущей operation счётчика) и операнды заменяются. Если operands не пришли, но пришла новая operation — знаки СУЩЕСТВУЮЩИХ операндов пересчитываются: они собираются в ComputedMeterOperandInput(sign=None) и прогоняются через _derive_signs, иначе compute_value продолжил бы считать по старой операции (неверное значение).

Прочие случаи compute_value: операнд без показаний в периоде исключается из суммы и попадает в missing (+заметка), остальные операнды считаются; счётчик, отсутствующий в meters, тоже даёт missing с заметкой «не найден»; аппроксимированный start ИЛИ end помечает компоненту is_interpolated=true и добавляет заметку «показания аппроксимированы»; несуществующий idNone → 404; coefficient операнда домножает расход.

Координаты на плане: create_plan_coords до записи проверяет существование computed_meter (NotFoundError), существование plan_id (NotFoundError вместо необработанного IntegrityError/500) и отсутствие уже созданных координат для этого счётчика (ConflictError вместо нарушения unique-индекса). Все репозиторные методы не коммитят — commit делает сервис.

Ограничения

Операнды проверяются только на существование как Meter, но не на принадлежность тому же ресурсу, что и вычисляемый счётчик — смешение ресурсов допускается, а unit в ответе берётся из ресурса вычисляемого счётчика, не из операндов. compute_value не проверяет period_start <= period_end. operation хранится как String(20) без DB-ограничения — множество значений гарантирует только Pydantic-enum. DIFFERENCE с единственным операндом вырождается в +1. Явный per-operand sign поддержан в схеме/_derive_signs «на будущее», но штатный API выводит знаки из operation. Операндами могут быть только реальные счётчики (FK на meters) — вложенность вычисляемых счётчиков не поддерживается. Значение нигде не персистится, считается на каждый запрос.

Покрытие тестами

19 тест-кейсов (все async), два файла.

test_service_crud.py (11): создание + чтение с выводом знаков DIFFERENCE[1, -1]; отказ по неизвестному ресурсу; отказ по пустым операндам; отказ по неизвестному счётчику-операнду; замена операндов и знаков при обновлении; регресс пересекающегося набора операндов без IntegrityError (фикс UNIQUE); смена operation без operands пересчитывает знаки существующих операндов (SUM [1,1]DIFFERENCE [1,-1]); список + удаление (идемпотентность повторного delete → False); CRUD координат плана (частичный PATCH не трогает нетронутые поля); дубль координат → ConflictError; неизвестный plan_idNotFoundError.

test_compute_value.py (8): SUM (150 + 50 = 200, unit, components, пустой missing); DIFFERENCE (150 − 50 = 100); применение coefficient ((100−0)*2.5 = 250); операнд без показаний исключается и попадает в missing с notes; флаг интерполяции end-показания всплывает в компоненте и заметке; знаки DIFFERENCE следуют position, а не порядку в массиве (операнды присланы наоборот, результат всё равно 150 − 50 = 100); интерполяция start-показания также всплывает; неизвестный idNone.

Контрагенты — расширенные атрибуты

Назначение

Фича добавляет к карточке контрагента (business_entities) и его подписантам (business_entity_signatories) набор атрибутов, нужных для юридически корректной AI-генерации договоров и для организационного учёта:

Миграция полностью аддитивная (все колонки nullable), консолидирована в один alembic-revision 0017_counterparty_ext_attrs (Revises: 0016_computed_meters).

Как работает (пошагово)

  1. Запись подписанта. Клиент шлёт POST на …/signatories/ или PATCH/PUT на …/signatories/{id} с authority_basis и (опционально) реквизитами доверенности. Pydantic валидирует authority_basis против SignatoryAuthorityBasisEnum, а attorney_number — по max_length=50; неизвестное основание отклоняется 422 ещё до сервиса.
  2. Нормализация. SignatoryService пропускает данные через normalize_signatory_authority(...): если authority_basis присутствует в payload и не равен POWER_OF_ATTORNEY, все три реквизита доверенности принудительно зануляются. Проверка «поле вообще прислано» ("authority_basis" in data) сохраняет PATCH-семантику exclude_unset — если основание не меняли, реквизиты не трогаются.
  3. Чтение в AI-контекст. При сборке контекста шаблона договора _signatory(sig) кладёт в SignatoryCtx человекочитаемый ярлык (authority_basis → «устав»/«доверенность» через L.authority_basis) и сырой код (authority_basis_code). Реквизиты доверенности подставляются только при authority_basis == "POWER_OF_ATTORNEY" — второй, защитный слой очистки на случай устаревших значений в БД.
  4. Запись контрагента. manager_user_id, invoice_delivery_method, invoice_email принимаются create_entity/update_entity и композитным create_composite; invoice_delivery_method валидируется против InvoiceDeliveryMethodEnum. PATCH шлёт только изменённые поля (model_dump(exclude_unset=True)), новые атрибуты попадают в общий diff-апдейт.
  5. Резолв менеджера на фронте. Так как manager_user_id — id пользователя root-БД без FK, имя менеджера карточка резолвит отдельным справочником GET /{projectId}/members/brief (краткий список активных участников проекта под project-JWT).

Модель данных / схема

SQLAlchemy-модель BusinessEntitySignatory (models/tenant/business_entities.py):

# Основание полномочий (SignatoryAuthorityBasisEnum строкой, как status/type)
# и реквизиты доверенности (при basis = POWER_OF_ATTORNEY).
authority_basis: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
attorney_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
attorney_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
attorney_valid_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)

BusinessEntity:

# Ответственный менеджер: id пользователя root-БД, без FK (кросс-БД,
# как counterparty_id в ai_call_records).
manager_user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Справочный канал доставки счетов (InvoiceDeliveryMethodEnum строкой);
# фактическую отправку (invoice_email_dispatcher) не меняет.
invoice_delivery_method: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
invoice_email: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)

Enum'ы (models/tenant/enums.py):

class SignatoryAuthorityBasisEnum(str, Enum):
    CHARTER = "CHARTER"
    POWER_OF_ATTORNEY = "POWER_OF_ATTORNEY"

class InvoiceDeliveryMethodEnum(str, Enum):
    EMAIL = "EMAIL"
    EDO = "EDO"

Pydantic-схемы (schemas/business_entities.py). В SignatoryCreateRequest/SignatoryUpdateRequest/CompositeSignatoryInput:

authority_basis: Optional[SignatoryAuthorityBasisEnum] = None
attorney_number: Optional[str] = Field(default=None, max_length=50)
attorney_date: Optional[date] = None
attorney_valid_to: Optional[date] = None

В BusinessEntityCreateRequest/BusinessEntityUpdateRequest/CompositeBusinessEntityCreateRequest:

manager_user_id: Optional[int] = None
invoice_delivery_method: Optional[InvoiceDeliveryMethodEnum] = None
invoice_email: Optional[str] = Field(default=None, max_length=150)

Ответы SignatoryResponse и BusinessEntityResponse отдают все новые поля как Optional (тип str для оснований/каналов — сырые коды). Обратите внимание: в ответах invoice_delivery_method/authority_basis типизированы как Optional[str], а не enum — валидация enum'а стоит только на входе.

Ключ-фрагмент нормализации (services/business_entities/signatory_service.py):

def normalize_signatory_authority(data: dict) -> dict:
    if "authority_basis" in data and data.get("authority_basis") != "POWER_OF_ATTORNEY":
        data["attorney_number"] = None
        data["attorney_date"] = None
        data["attorney_valid_to"] = None
    return data

Ярлык основания для AI-контекста (contract_template_labels.py): _AUTHORITY_BASIS = {"CHARTER": "устав", "POWER_OF_ATTORNEY": "доверенность"}.

Endpoints (метод + путь)

Все под project-JWT, v1_router монтируется без глобального префикса.

Поведение и edge cases

Ограничения

Покрытие тестами

Фичу напрямую покрывают 6 тест-кейсов в tests/test_counterparty_extended_attrs.py:

  1. test_signatory_authority_fields_roundtrip — async round-trip: authority_basis=POWER_OF_ATTORNEY + реквизиты сохраняются в модель и отдаются SignatoryResponse.
  2. test_signatory_create_request_rejects_unknown_basis — неизвестное основание (NOTARY) отклоняется ValidationError (422 на границе API).
  3. test_business_entity_manager_and_delivery_roundtrip — async round-trip: manager_user_id/invoice_delivery_method/invoice_email сохраняются и отдаются BusinessEntityResponse.
  4. test_business_entity_update_rejects_unknown_delivery_method — невалидный канал (PIGEON) отклоняется ValidationError.
  5. test_signatory_ctx_includes_authority_basis — AI-контекст _signatory: ярлык «доверенность», код POWER_OF_ATTORNEY, реквизиты доверенности подставлены.
  6. test_signatory_ctx_charter_label — при CHARTER ярлык «устав», attorney_number is None (read-side скраб).

Тесты работают на уровне ORM round-trip (через tenant_session) и Pydantic-валидации схем плюс unit-тесты AI-контекста; HTTP-слой не задействован.

Второй файл из задания, tests/services/test_party_field_docs.py (7 тест-кейсов), проверяет соседний, но отдельный сервис party_field_docs (справочник полей карточки DaData: inn, name, authorities, founders, licenses, address/metro, пропуск служебных source/qc, отсутствие шаблонных {% в note) — к расширенным атрибутам контрагента он прямого отношения не имеет.

Что не покрыто: HTTP-уровень create/update контрагента и подписанта с новыми полями, write-side normalize_signatory_authority напрямую, а также endpoint GET /members/brief (фильтрация/дедупликация/пагинация).

Планы — ревалидация координат и размеры изображения

Назначение

Фича закрывает две связанные задачи слоя планов (FastAPI, tenant-схема).

Первая — ревалидация консистентности PlanObjectCoordinates при частичном (PATCH) и полном (PUT) обновлении. Аудит-финдинг выявил, что update_coordinate не перепроверял связку rendering_typecoords/icon_position_x/y против эффективного состояния записи, поэтому PATCH мог снять icon_position_x/y с ICON-элемента или навесить icon-поля на POLYGON — в результате элемент «молча» исчезал с канваса на фронтенде (не проходил рендер), при этом сервер отвечал 200.

Вторая — извлечение и хранение размеров исходного изображения плана (image_width/image_height в пикселях). Размеры отдаются фронтенду в PlanResponse и нужны ему для валидации координат объектов (координаты задаются в пиксельной системе изображения) и для fit-логики канваса (масштабирование под вьюпорт).

Дополнительно у зданий есть производный флаг has_floor_plans — «есть хотя бы один этаж с загруженным планом», отдаётся опционально через ?fields=has_floor_plans (тестами в файлах этой фичи не покрыт).

Как работает

Ревалидация координат (PlansService.update_coordinate, plans_service.py):

  1. Из тела строится diff: data = body.model_dump(exclude_unset=partial) (для PATCH — только явно переданные поля, для PUT — все).
  2. Загружается существующая запись; если её нет — возвращается None (эндпоинт даёт 404).
  3. Есть защитная проверка иммутабельности plan_id и entity-ссылок (building_id, floor_id, room_id, parking_lot_id, parking_space_id, meter_id) относительно текущего значения → BusinessRuleError. Важно: через API она недостижима — схема PlanObjectCoordsUpdateRequest не содержит ни plan_id, ни entity-ссылок, поэтому эти ключи никогда не попадают в data. Реально мутировать можно только object_type, rendering_type, coords, icon_position_x/y, label, description.
  4. Вычисляется эффективное состояние — слияние existing и патча: effective_rendering = data.get("rendering_type", existing.rendering_type), аналогично effective_coords, effective_icon_x, effective_icon_y. Явный rendering_type=None (или object_type=None) отклоняется отдельной проверкой.
  5. Определяется активная entity-ссылка записи (из existing); если она есть — вызывается _validate_rendering_type(effective_rendering, entity_field) (пространственные сущности → только POLYGON, meter_id → только ICON).
  6. Вызывается _validate_coord_fields(effective_rendering, effective_coords, effective_icon_x, effective_icon_y) — та же самая проверка, что и в create_coordinate: для ICON обязательны icon_position_x/y и запрещён coords; для POLYGON обязателен coords и запрещены icon-поля.
  7. Только после этого выполняется запись и commit. BusinessRuleError транслируется в HTTP 422 (обработчик в server.py).

Размеры изображения (read_plan_image_dimensions, plans_service.py):

Функция read_plan_image_dimensions(fs, project, path) читает байты файла через FileService.read_file_bytes, открывает их Pillow (Image.open(io.BytesIO(data))) и возвращает (int(img.width), int(img.height)). Это best-effort метаданные: любое исключение (файл не читается, не изображение и т.п.) логируется warning-ом и возвращается (None, None) — запрос никогда не падает.

Вызывается в трёх точках:

Флаг has_floor_plans (buildings_repository.py): has_floor_plans(building_id) делает EXISTS(select(Plan.id).join(Floor, Plan.floor_id == Floor.id).where(Floor.building_id == building_id)); батч-версия has_floor_plans_batch собирает distinct Floor.building_id по join с Plan. BuildingsService подмешивает флаг в ответ, если has_floor_plans присутствует в fields.

Модель данных / схема

Plan (models/tenant/plans.py) — SQLAlchemy-зеркало Django-модели, новые nullable-колонки:

path: Mapped[str] = mapped_column(String(250), nullable=False)
# Размеры исходного изображения плана в пикселях. Заполняются при загрузке
# (Pillow); NULL — если определить не удалось или файл загружен до внедрения.
image_width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
image_height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)

Миграция 0018_plan_image_dimensions.py — чисто аддитивная, down_revision = "0017_counterparty_ext_attrs":

def upgrade() -> None:
    op.add_column("plans", sa.Column("image_width", sa.Integer(), nullable=True))
    op.add_column("plans", sa.Column("image_height", sa.Integer(), nullable=True))

Существующие строки остаются NULL (без backfill).

Pydantic PlanResponse (schemas/plans.py) и «краткая» PlanResponse из schemas/structure/common.py (используется в composite/floor-ответах через ?fields=plan) обе экспонируют размеры:

image_width: Optional[int] = None
image_height: Optional[int] = None

PlanObjectCoordinates хранит rendering_type (enum POLYGON/ICON), coords: Optional[str] (String 5000), icon_position_x/y: Optional[float]. PlanObjectCoordsUpdateRequest — все поля Optional и без plan_id/entity-ссылок, что и делает необходимой ревалидацию эффективного состояния при PATCH. Флаг на здании: BuildingResponse.has_floor_plans: Optional[bool] (schemas/structure/buildings.py, включается через ?fields=has_floor_plans).

Endpoints (метод + путь)

Планы (prefix=/{projectId}/plans):

Координаты (prefix=/{projectId}/plan-coordinates):

Здания: GET /{projectId}/structure/buildings и GET .../buildings/{id} с ?fields=has_floor_plans.

Поведение и edge cases

Ограничения

Покрытие тестами

Всего 17 тест-кейсов в двух файлах (оба — сервисного уровня).

test_plans_coordinates_revalidation.py (11): отклонение create POLYGON без coords и ICON с coords; PATCH не может снять coords с POLYGON, навесить icon-поля на POLYGON, снять icon_position_x/y с ICON, добавить coords к ICON; rendering_type должен соответствовать entity (meter→ICON, пространственные→POLYGON); rendering_type=None отклоняется; валидные PATCH для POLYGON и ICON работают; PUT (partial=False) валидирует полное эффективное состояние (консистентная замена проходит, PUT без coords для POLYGON — отказ).

test_plan_image_dimensions.py (6): create_plan сохраняет размеры (640×480, проверка и в ответе, и при повторном чтении из БД); не-изображение даёт NULL-ы без падения; update_plan при смене path пересчитывает размеры (640×480 → 320×200); composite floor create сохраняет размеры плана (800×600); composite floor update пересчитывает (800×600 → 1024×768); обе схемы PlanResponse (основная и brief) экспонируют image_width/image_height.

Не покрыто: иммутабельность/immutability-guard координат (недостижим через API), флаг has_floor_plans, HTTP-слой (эндпоинты/статусы) — только через прямые вызовы сервисов.

Инвойсы — фильтр по дате отправки на email + media-категория

Две небольшие, независимые правки, консолидированные в одну секцию: (1) диапазонный фильтр списка счетов по моменту отправки счёта на email на уровне репозитория и эндпоинта, и (2) новая колонка category у зеркальной таблицы base_media_files с фильтром по ней в списке медиа-файлов.

Назначение

Как работает (пошагово)

Фильтр по дате отправки:

  1. Эндпоинт list_invoices (contract_invoices.py) принимает два строковых query-параметра sent_to_email_time_from и sent_to_email_time_to (тип Optional[str], с русскими description).
  2. Для каждого непустого значения эндпоинт вызывает datetime.fromisoformat(...) и кладёт результат в kwargs (sent_to_email_time_from / sent_to_email_time_to). Пустые/None значения в kwargs не попадают.
  3. InvoicesService.list_invoices(*, fields=None, kwargs) пробрасывает kwargs в репозиторий без изменений (self._repo.list_invoices(with_payment_status=..., kwargs)).
  4. InvoicesRepository.list_invoices при заданном sent_to_email_time_from добавляет WHERE ContractInvoice.sent_to_email_time >= :from, при sent_to_email_time_toWHERE ContractInvoice.sent_to_email_time <= :to. Обе границы включительные, условия комбинируются через AND и с любыми другими фильтрами (в т.ч. is_sent_to_email).
  5. sent_to_email_time проставляется бизнес-логикой при постановке письма в очередь отправки (см. dispatch_invoice_email в сервисе; семантика — «передано в очередь», не «доставлено»).

Категория медиа:

  1. Миграция 0015_media_category добавляет nullable-колонку category (String(40)) в base_media_files.
  2. Модель BaseMediaFile и pydantic-схемы (MediaFileResponse, MediaFileCreateRequest, MediaFileUpdateRequest) получают поле category: Optional[str].
  3. Эндпоинт list_media_files (media.py) принимает category: Optional[str] = Query(None) и пробрасывает через MediaService.list_files в MediaRepository.list.
  4. MediaRepository.list при category is not None добавляет WHERE BaseMediaFile.category == :category (точное совпадение). В отличие от эндпоинта счетов, здесь нет falsy-гарда: явный category= (пустая строка) применит фильтр по category == ''.

Модель данных / схема

Репозиторий счетов (invoices_repository.py), новые параметры и условия:

sent_to_email_time_from: Optional[datetime] = None,
sent_to_email_time_to: Optional[datetime] = None,
...
if sent_to_email_time_from is not None:
    stmt = stmt.where(ContractInvoice.sent_to_email_time >= sent_to_email_time_from)
if sent_to_email_time_to is not None:
    stmt = stmt.where(ContractInvoice.sent_to_email_time <= sent_to_email_time_to)

Модель BaseMediaFile (models/tenant/media.py) — колонка категории:

# Категория документа (тех. документация): tech_passport / room_schedule /
# legal / landscaping / photo / other. NULL — без категории (старые файлы/фото).
category: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)

Pydantic (schemas/media.py): category: Optional[str] = None присутствует в MediaFileResponse, MediaFileCreateRequest, MediaFileUpdateRequest.

Миграция 0015_media_category (revises 0014_ai_persistence):

def upgrade() -> None:
    op.add_column("base_media_files", sa.Column("category", sa.String(40), nullable=True))
def downgrade() -> None:
    op.drop_column("base_media_files", "category")

Репозиторий медиа (media_repository.py):

if category is not None:
    stmt = stmt.where(BaseMediaFile.category == category)

Endpoints

Оба списочных эндпоинта защищены verify_jwt_for_project; счета также поддерживают существующую пагинацию/сортировку.

Поведение и edge cases

Ограничения

Покрытие тестами

Файл tests/test_invoices_repository_sent_to_email_range.py — 4 асинхронных теста на InvoicesRepository.list_invoices (общий сид: контракт + 4 счёта, отправленные 01/10/20 марта и один с NULL):

  1. test_sent_to_email_time_from_only — только нижняя включительная граница (from=10 марта) → {2, 3}, total=2.
  2. test_sent_to_email_time_to_only — только верхняя включительная граница (to=10 марта 12:00, ровно на границе) → {1, 2}, total=2.
  3. test_sent_to_email_time_range — диапазон [5, 15] марта{2}, total=1.
  4. test_sent_to_email_time_range_composes_with_is_sent_to_emailis_sent_to_email=True отбрасывает NULL-счёт, диапазон [1 марта, 20 марта 23:59:59] оставляет {1, 2, 3}, total=3.

Диапазонный фильтр по дате отправки — покрыт (включительность обеих границ, диапазон, композиция с булевым фильтром). Категория медиа-файлов — миграция/модель/схема/репо на месте, но фильтр без прямого тестового покрытия.

Изменения схемы БД

Все таблицы и колонки заведены alembic-миграциями FastAPI-репозитория (не Django). Часть таблиц-зеркал (base_media_files, payments, plans, business_entities) технически принадлежит Django, но перечисленные ниже колонки добавляет именно alembic — фактическое двойное владение схемой зеркал.

Новые таблицы

ТаблицаМиграцияНазначениеИндексы/ограничения
ai_generated_documents0014_ai_persistenceВерсии AI-генераций документов + дифф 80/20 (auto_filled/needs_review), метаданные, file_path (S3, пока NULL)без индексов; FK template_id → document_templates.id (ON DELETE SET NULL)
ai_cost_log0014_ai_persistenceРазложенный по 4 корзинам расход токенов + estimate_usd Numeric(12,6), provider (default anthropic)ix_ai_cost_log_created_time (под помесячный cost-guard)
ai_call_records0014_ai_persistenceСохранённые разборы звонков (CallAnalysis JSON + денормализованные temperature/quality_total)ix_ai_call_records_counterparty_id; counterparty_id без FK (кросс-БД)
computed_meters0016_computed_metersВычисляемые счётчики (operation SUM/DIFFERENCE)ix_computed_meters_resource_id
computed_meter_operands0016_computed_metersОперанды со знаком/позициейuq_computed_meter_operands (computed_meter_id, meter_id); ix_computed_meter_operands_computed_meter_id, ix_computed_meter_operands_meter_id
computed_meter_plan_coordinates0016_computed_metersКоординаты иконки вычисляемого счётчика на планеcomputed_meter_id UNIQUE

Новые колонки

ТаблицаКолонкиМиграцияНазначение
base_media_filescategory String(40) nullable0015_media_categoryКатегория документа (tech_passport/room_schedule/legal/landscaping/photo/other); также используется structure-инбоксом
business_entitiesmanager_user_id Integer nullable (без FK), invoice_delivery_method String(10) nullable, invoice_email String(150) nullable0017_counterparty_ext_attrsОтветственный менеджер + справочный канал доставки счетов
business_entity_signatoriesauthority_basis String(30) nullable, attorney_number String(50) nullable, attorney_date Date nullable, attorney_valid_to Date nullable0017_counterparty_ext_attrsОснование полномочий подписанта + реквизиты доверенности
plansimage_width Integer nullable, image_height Integer nullable0018_plan_image_dimensionsРазмеры исходного изображения плана в пикселях (best-effort, Pillow)

Цепочка миграций и сведение heads

Ревизии выстроены линейно: 0013_business_entity_hid0014_ai_persistence0015_media_category0016_computed_meters0017_counterparty_ext_attrs0018_plan_image_dimensions. Поскольку консолидация свела две расходившиеся ветки, обе привносили собственные alembic-heads; финальная миграция 0019 сводит alembic-heads в единый линейный head (merge-ревизия), чтобы alembic upgrade head не падал на множественных головах. Ограничение длины revision id ≤ 32 символа соблюдено (тип alembic_version.version_numvarchar(32)).

Конфигурация (env)

Модельный слой и оркестрация настраиваются через settings (алиасы — переменные окружения):

Оркестрация состояния агента:

Учёт стоимости:

Зрелость и покрытие тестами

Прод-готово (без существенных оговорок):

С оговорками (реализовано и осмысленно, но покрыто частично):

Слабо покрыто / известные дыры:

Известные ограничения и что доделать

Приоритет 1 — корректность/безопасность бюджета и записи (собрано из cost-guard и agent):

  1. Cost-guard fail-open: сбой SUM-запроса проглатывается, запрос пропускается — на глюке БД лимит молча перестаёт действовать.
  2. Мягкий лимит (check-then-act): бюджет сверяется ДО вызова модели, стоимость пишется ПОСЛЕ — конкурентные запросы могут превысить месячный потолок. Лимит тенант-глобальный, без разбивки per-user/per-project/per-kind.
  3. _enforce_budget не применяется в /confirm (только _guard_api_key).
  4. Happy-path подтверждения записи (act → confirm approve=True) без интеграционного теста.
  5. Command-routing применяет результат классификатора без валидации/фолбэка: ошибочный ACT уводит в write-черновик, ошибочный NavTarget — в навигацию по несуществующей сущности; на бэкенде не отсекается.

Приоритет 2 — незавершённая функциональность (заглушки/задел):

  1. /command intent DOCUMENT и CALL — заглушки (not_implemented=True, Фаза 2/4).
  2. Аудио/STT для разбора звонков не реализовано (только текст).
  3. Генерация документов не сохраняет файл в S3 (только inline base64, file_path всегда NULL); signatory_id в потоке не заполняется.
  4. Postgres-checkpointer без TTL-чистки брошенных act-черновиков.
  5. router/model_router.select_model и router/classifier.classify определены, но нигде не вызываются (мёртвый смежный слой).

Приоритет 3 — целостность данных и валидация:

  1. Кросс-БД поля без FK: manager_user_id (business_entities), counterparty_id (ai_call_records), contract_id (ai_generated_documents) — целостность на уровне БД не гарантируется; delete_call_record находит запись по глобальному id без скоупа на counterparty.
  2. Enum-поля хранятся строками без DB-ограничения (operation String(20), authority_basis String(30), invoice_delivery_method String(10), category String(40)) — корректность держится только Pydantic на входе.
  3. compute_value не проверяет period_start <= period_end; операнды не проверяются на принадлежность одному ресурсу (смешение ресурсов допускается).
  4. Импорт выписки: CSV не детектится по сигнатуре (нужен явный fmt); ключ синтетического дедупа commit (date+value+purpose) не совпадает с ключом possible_duplicate матчера (date+value+payer_account); commit не фильтрует по confidence.
  5. Инвойсы: некорректная ISO-строка sent_to_email_time_* → необработанный ValueError (нет try/except); часовой пояс не нормализуется; media-category= пустой строкой фильтрует по category == ''.

Приоритет 4 — покрытие тестами (перекрывается с разделом выше):

  1. Без выделенных тестов: classify_intent, ядро разбора звонков, LLM-оркестрация инбокса, ветки генерации документов needs_input/error, HTTP-слой расширенных атрибутов контрагентов, фильтр media-category, флаг has_floor_plans, vision на не-anthropic провайдерах, postgres-checkpointer.
  2. estimate_usd — оценка, не факт биллинга; погрешность округления копится на каждом Usage.add (6 знаков); потолок одной записи ~999 999.999999 USD (Numeric(12,6)).
  3. Инвариант «реквизиты доверенности только при POWER_OF_ATTORNEY» держится двумя независимыми реализациями (write-side normalize + read-side _signatory); write-side отдельным тестом не покрыт.
  4. Нет backfill image_width/height для планов до миграции 0018 (NULL до перезагрузки файла); has_floor_plans учитывает только планы этажей.
  5. Модульный docstring agent.py устарел (расходится с актуальными Pydantic-моделями /ask data[] и /confirm) — обновить документацию.