Консолидированный 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_action → interrupt → при отказе {"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-подтверждением записи | 🟡 caveats | 18 | POST /{projectId}/agent/ask, /act, /confirm |
| MCP-tools bridge и отбор тулов | FastMCP-реестр → LangChain StructuredTool с HITL, лексический отбор под запрос | 🟡 caveats | 11 | MOUNT /{projectId}/mcp/mcp, агентные /ask /act /confirm |
| Учёт стоимости AI и бюджет-гард | Разложенный по корзинам лог токенов + USD, месячный cost-guard | 🟡 caveats | 12 | все /{projectId}/agent/* платные |
| AI-инбокс документов | Массовый разбор входящих: привязка к арендатору/зданию, раскладка в хранилище | 🟡 caveats | 11 | POST /{projectId}/doc-inbox/classify, /commit, /structure/* |
| Разбор звонков отдела продаж | Транскрипт → структурированный CallAnalysis, сохранение в карточку | 🟠 weak | 3 | POST /{projectId}/agent/call-analysis, /save, DELETE /call-records/{id} |
| AI-генерация документов по шаблону | Шаблон + ИИ-маппинг переменных, детерминированный тег contract, docxtpl-рендер | 🟡 caveats | 3 | POST /{projectId}/agent/document, GET /generated-documents |
| Единая командная строка /agent/command | Классификатор намерения малой моделью (провайдеро-нейтрально) → роутинг ask/act/navigate/help | 🟡 caveats | 13 | POST /{projectId}/agent/command, /confirm |
| Импорт банковской выписки | Детерминированный разбор 1С/CSV/MT940, матчинг платежей, дедуп | 🟡 caveats | 34 | POST /{projectId}/agent/statement/preview, /commit |
| Вычисляемые счётчики | Виртуальный счётчик SUM/DIFFERENCE над операндами + координаты на плане | 🟡 caveats | 19 | .../computed-meters/, .../{id}/value, .../computed-meter-plan-coordinates/ |
| Контрагенты — расширенные атрибуты | Основание полномочий/доверенность подписанта, менеджер, канал доставки счетов | 🟡 caveats | 6 | POST/PATCH /{projectId}/business-entities/, /signatories/, GET /members/brief |
| Планы — ревалидация координат и размеры изображения | Проверка consistency PATCH/PUT координат + image_width/height | 🟡 caveats | 17 | .../plan-coordinates/{id}, .../plans/, ?fields=has_floor_plans |
| Инвойсы — фильтр по дате отправки + media-категория | Диапазон sent_to_email_time + колонка category у медиа | 🟡 caveats | 4 | GET /{projectId}/invoices/, GET /{projectId}/media/ |
Детали фич
AI-агент на LangGraph (ask / act / confirm)
Назначение
Разговорный ассистент CRM коммерческой недвижимости (CRMKA), работающий строго по данным текущего проекта. Реализован как тонкий сервис AgentService поверх двух LangGraph-графов, собранных через create_react_agent. Даёт три режима:
- ask — read-only вопросы к данным проекта (сколько объектов, какие договоры и т.п.);
- act — команда на изменение данных: агент находит сущности read-инструментами и предлагает ОДИН write-инструмент, но не выполняет запись, а приостанавливается на подтверждение;
- confirm — возобновляет приостановленный act-граф с решением approve/reject; это единственный путь фактической записи в БД.
Ключевые файлы: 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=…):
build_ask_agent(tools, settings)— граф «Спросить», read-only ReAct, БЕЗ checkpointer (stateless), промптAGENT_ASK_SYSTEM + today_hint().build_act_agent(tools, settings, checkpointer)— граф «Сделать», ReAct + write-tools +checkpointer, промптAGENT_ACT_SYSTEM + today_hint().
Модель берётся из 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_tools → select_tools(mode="ask") → build_ask_agent → graph.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__. Ветвление результата:
- если
"__interrupt__" in result→status="needs_confirmation",action = {tool, input, summary}изresult["__interrupt__"][0].value, плюсthread_id; - иначе (LLM ответил текстом без записи) →
status="answered",answer = _final_text(...) or "Готово.",action=None(thread_id тоже возвращается).
Поток 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") возвращает:
memory→MemorySaver()(dev/тесты, живёт в памяти процесса, чистка не нужна);postgres→AsyncPostgresSaver.from_conn_string(settings.langgraph_pg_dsn)с__aenter__()и однократным.setup()(создаётся на старте приложения, кладётся вapp.state);- иначе →
ValueError.
Инстанс достаётся в эндпоинтах через 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:
- POST
/{projectId}/agent/ask→AskResponse._guard_api_key+_enforce_budget, лог стоимостиkind="ask",TaskClass.ANALYTICAL. - POST
/{projectId}/agent/act→ActResponse._guard_api_key+_enforce_budget, логkind="act",TaskClass.REACTIVE. - POST
/{projectId}/agent/confirm→ConfirmResponse._guard_api_key(без_enforce_budget);ValueErrorиз сервиса →HTTPException(410, "draft_not_found"); логkind="confirm",REACTIVE. - POST
/{projectId}/agent/command— единая ИИ-командная строка с авто-роутингом по намерению (делегирует вapp.ai.command.run_command); для intent=act отдаёт тот жеaction/thread_id, замыкая цикл на/confirm.
_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).
Примечание: модульный docstringagent.pyустарел (в описании ask-ответа не показываетdata[], а тело/confirmописывает как{"tool","input"}вместо актуального{thread_id, approved}). Источник истины — Pydantic-модели выше.
Поведение и edge cases
- Отклонение действия:
Command(resume=False)→ write-тул возвращает{"cancelled": True},interruptне повторяется, граф завершается финальным ответом модели; записи в БД нет. - Устаревший/неизвестный thread_id:
aget_stateдаётstate.next == ()→ValueError("Черновик не найден или устарел")→ 410. При memory-бэкенде это происходит и после рестарта процесса (состояние потеряно). - act без записи: если LLM отвечает текстом и не зовёт write-тул,
__interrupt__не появляется →status="answered",answerили заглушка"Готово.". - Доменная ошибка write-тула:
registry.call_toolрейзит, LangGraph помечаетToolMessage.status="error";confirmвозвращаетsuccess=False, а исключения не глотаются blanket-обёрткой (уходят в exception-handler'ы FastAPI). - Ошибочные read-результаты в
_collect_dataфильтруются:status="error", невалидный JSON, отсутствиеentity, плоский{"error": …}— такие блоки вdata[]не попадают. - Разбиение input/cache-токенов:
input_freshне уходит в минус за счётmax(..., 0).
Ограничения
Перечислены в поле limitations. Главное: покрыт только memory-чекпойнтер (postgres — нет), happy-path подтверждения с реальной записью не имеет интеграционного теста, реальный LLM не тестируется, _enforce_budget не применяется в /confirm, «один write-тул» — договорённость промпта, а не код, ask полностью stateless, TTL-чистка postgres-черновиков не реализована.
Покрытие тестами
Итого 18 тест-кейсов (offline, без сети/реального LLM):
test_agent_service.py(3):askвозвращает answer+used_tools через фейк-граф;actпри__interrupt__даётstatus="needs_confirmation",action.tool, непустойthread_id;confirmдля неизвестного thread_id (пустойstate.next) →ValueError,ainvokeне вызывается.test_act_confirm.py(1): контроль-флоу через реальныйcreate_react_agent+MemorySaverс фейк-моделью — write-тулcreate_paymentвызываетinterrupt(проверяется__interrupt__иdraft["input"]["value"] == 100.0), затемCommand(resume=False)отменяет запись и граф завершается сообщением «Отменено.». (Approve-путь с реальной записью НЕ покрыт.)test_ask_data.py(12): кардинальность_present(empty/card/table и fallback поlen(items)),_safe_json(dict/строка/мусор/None),_collect_data(учёт тула с entity, пропуск без entity, пропускstatus="error", пропуск не-JSON, нормализация плоскогоget_*→card, пропуск плоского{error}), и интеграционныйtest_ask_builds_data_block—AgentService.askсобираетdata[]сentity/list_fields/present="table".test_checkpointer.py(2):memory→MemorySaver; неизвестный backend →ValueError.
Оценка зрелости — 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, а агент — нет (набор агента — строгое подмножество боевого).
Как работает (пошагово)
- Сборка реестра.
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).
- Кэш и разбиение. В бридже реестр кэшируется через
@lru_cache(maxsize=1) _cached_registry().build_tools(session, project)перебираетawait registry.list_tools()и для каждого FastMCP-тула определяет read/write по аннотации (_is_readчитаетannotations.readOnlyHint), оборачивает вStructuredToolи раскладывает в два спискаread/write.
- Обёртка тула.
_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.
- Отбор под запрос.
select_tools(query, read_tools, write_tools, *, mode, k=15)формирует пул: в режимеask— только read-тулы, вact— read+write. Пул ранжируетсяLexicalSelector(пересечение токенов запроса и «документа» тула), берётся top-K, поверх добавляется фиксированное ядроCORE. Результат — дедуплицированный по имени список.
- Потребление в агенте.
AgentService.ask/act(agent_service.py) вызываетbuild_tools→select_tools→build_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-поверхность:
POST /{projectId}/agent/ask— read-only,select_tools(mode="ask").POST /{projectId}/agent/act— драфт write-действия,select_tools(mode="act"), interrupt перед записью.POST /{projectId}/agent/confirm— единственный путь записи; возобновляет граф, биндит полный read+write.app.mount("/{projectId}/mcp", mcp_app)— боевой streamable-http MCP-сервер (полный путь тула/{projectId}/mcp/mcp). Его реестр =build_tool_registry(auth=mcp_auth)ПЛЮСregister_floor_plan_editor(в отличие от набора агента).
Поведение и edge cases
- Разворот конверта аргументов.
_unwrap_argsпокрывает модели (command-r через Ollama), присылающие{"tool_name": ..., "parameters": {...}}— при условииset(kwargs) <= {"tool_name","parameters"}возвращаются плоскиеparameters. Тул с реальным полемparametersрядом с другими ключами не разворачивается. - Ask отсекает write из ядра. В
askпул = только read, аCOREдобавляется лишь если имя есть в пуле (if name in by_name), поэтомуcreate_paymentиз ядра в ask-режиме отсеивается. - Гарантия ядра. Даже при нулевом лексическом совпадении («погода на марсе») ядро всегда в наборе — read-тулы контрактов и
create_payment(в act). - Ошибки тула наружу.
registry.call_toolрейзит на любой ошибке (ValidationError/ToolError); исключение пробрасывается, LangGraph помечаетToolMessageкакstatus=error. Доменные ошибки внутри самих тулов часто перехвачены и возвращаются как{"error": ...}(см. business_entities/invoices/payments). - Отклонение. Если пользователь reject-нул write,
_invokeвернёт{"cancelled": True}без вызова сервиса. - Размер набора. Итог =
CORE ∪ ranked, ограниченk + |CORE|(в тестах —<= 3 + 7). - Реестр велик. Тест фиксирует
len(names) > 100тулов и наличие ядровых имён; отсутствие любыхfloor_plan-тулов (в наборе агента, т.е. в фабрике безregister_floor_plan_editor).
Ограничения
Отбор намеренно наивный: пересечение токенов (слова длиннее 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.
Как работает (пошагово)
- Эндпоинт (
ask/act/command/document/call-analysis) сначала зовёт_guard_api_key()(provider-aware503 ai_not_configured), затемawait _enforce_budget(session). Исключение —confirm: он зовёт только_guard_api_key(), бюджет-гард не вызывает. _enforce_budgetчитает лимит изsettings.ai_monthly_cost_limit_usd; приlimit <= 0guard выключен и сразу возвращает управление. Иначе считаетSUM(coalesce(estimate_usd, 0))поai_cost_logс начала календарного месяца UTC и приspent >= limitбросаетHTTPException(402, "ai_budget_exceeded"). Любое исключение SUM-запроса проглатывается (fail-open) — guard не роняет сервис на сбое БД.- Вызывается модель (через
AgentService/run_command/generate_document/analyze_call). LangChain-ответ несётusage_metadata. - Раскладка по корзинам:
_usage_from_message(вai/client.py) и_accumulate_usage(вservices/ai/agent_service.py) переводятusage_metadataвUsage. Ключевая деталь — в LangChaininput_tokensуже ВКЛЮЧАЕТ кэш-токены, поэтомуinput_fresh = input_tokens − cache_read − cache_write(с клампомmax(..., 0)), аcache_read/cache_writeберутся изinput_token_details(cache_read/cache_creation). - Оценка стоимости:
estimate_cost_usd(model, ...)умножает каждую корзину на свой тариф изMODEL_PRICINGи делит на 1e6. Неизвестная модель →0.0(self-hosted/локалки не платные), формула при этом не меняется. - Запись лога: эндпоинт зовёт
_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, ответ пользователю не ломается. - 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_usd — server_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):
POST /{projectId}/agent/ask— guard + одна строка логаkind="ask",task_class="analytical".POST /{projectId}/agent/act— guard + одна строка логаkind="act",task_class="reactive".POST /{projectId}/agent/confirm— БЕЗ бюджет-гарда (возобновляет уже одобренное действие), но логирует финальный вызовkind="confirm",task_class="reactive".POST /{projectId}/agent/command— guard + строкаcommand_classify(малая модель провайдера,task_class=None); при intentask/analytics/actдополнительно пишется строка диспетчнутого исполнителя (kind="ask"/"act") сdescription="via /command"— итого ДВЕ строки, а в ответ отдаётся суммарный usage (классификация + исполнитель). При intentnavigate/document/call/helpмодель дальше не зовётся — остаётся РОВНО ОДНА строкаcommand_classify.POST /{projectId}/agent/document— guard; стоимость логируется внутриgenerate_document(эндпоинт_log_cost_safeсам не зовёт).POST /{projectId}/agent/call-analysis— guard + строка логаkind="call_analysis",task_class="call"(provider по умолчанию"anthropic"— аргумент provider в_log_cost_safeне передаётся).
Поведение и edge cases
- Провайдеро-агностичность:
providerфиксирует, каким прайсом считалось; у локалок (ollama) корзины кэша всегда 0, аestimate_usd= 0, но токены всё равно логируются — расход в токенах виден даже когда деньги за него не платятся. - Кэш Anthropic:
cache_read ≈ 0.1×входа,cache_write ≈ 1.25×входа;cache_writeосмыслен только для Anthropic, у остальных провайдеров детали кэша отсутствуют → корзины 0. - Мульти-вызовы: агентный tool-loop делает несколько обращений к модели;
_accumulate_usageсуммирует usage всехAIMessage, а сообщения безusage_metadata(например, tool-результаты) молча пропускаются. - Guard-выключатель:
ai_monthly_cost_limit_usd = 0(или пусто) полностью отключает проверку — запрос проходит при любых накопленных расходах. - Failure isolation лога: перед вставкой всегда
rollback(снимает возможный failed-state транзакции после проглоченной ошибки read-инструмента); сбой самой вставки →rollback+ warning, ответ не ломается, строка в БД не появляется. - PII:
description— служебный маркер (Noneили"via /command"), исходный текст запроса пользователя в cost-log не попадает.
Ограничения
- Cost-guard fail-open: сбой SUM-запроса проглатывается и запрос пропускается — на глюке БД лимит молча перестаёт действовать.
- Мягкий, не жёсткий лимит (check-then-act): бюджет сверяется ДО вызова модели, а стоимость пишется ПОСЛЕ; конкурентные запросы, стартовавшие до записи, все пройдут — месячный потолок может быть превышен.
- Лимит тенант-глобальный и календарно-месячный (с первого числа месяца UTC), без разбивки per-user / per-project / per-kind.
estimate_usd— оценка, а не факт биллинга провайдера; погрешность округления накапливается на каждомUsage.add(6 знаков).- Бюджет-гард отдельными тестами покрыт только на
/ask; на/act,/command,/document,/call-analysisон подключён, но не проверяется. estimate_usd—Numeric(12,6), потолок одной записи ~999 999.999999 USD.
Покрытие тестами
Файл tests/test_ai_cost_and_call_records.py содержит 12 тест-кейсов, из которых 9 покрывают именно учёт стоимости и бюджет-гард (оставшиеся 3 — delete_call_record соседней фичи разбора звонков):
test_accumulate_usage_splits_buckets_and_prices_cache— раскладка usage LangGraph по корзинам (input_fresh = input − cache), тарификацияcache_read/cache_writeпо своим ставкам, суммарныйestimate_usd.test_accumulate_usage_unknown_model_zero_cost— локалка не вMODEL_PRICING: корзины токенов есть, стоимость 0.test_ask_endpoint_logs_cost—/askпишет одну строкуkind="ask",task_class="analytical",provider="anthropic", корзины/estimate_usd>0,created_by_user_id,description is None(нет PII).test_act_endpoint_logs_cost— то же для/act(kind="act",task_class="reactive").test_ask_endpoint_cost_log_failure_is_isolated— сбойlog_costпослеflush→rollback+ warning; ответ отдаётся, строк вai_cost_log= 0.test_command_ask_intent_logs_classify_and_ask_rows—intent=ASK: ДВЕ строки (command_classifyна Haiku +ask), суммарный usage в ответе,description="via /command".test_command_navigate_intent_logs_only_classification—intent=NAVIGATE: модель дальше не зовётся → ровно одна строкаcommand_classify(Haiku,task_class=None).test_ask_budget_guard_blocks_when_over_limit— расход за месяц ≥ лимита →402 ai_budget_exceededДО вызова модели.test_ask_budget_guard_off_when_limit_zero— лимит 0 (выключен) → запрос проходит при ненулевых расходах.
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):
- Грузятся все бизнес-сущности проекта (
BusinessEntityService.list_entities(limit=1000)), строятся индексby_inn(ИНН → список арендаторов),name_by_idи текстовый листинг- id=… | Название | ИНН …для промпта. - Для каждого файла проверяется расширение: если не
.docx/.pdfи не изображение — результатmethod="unsupported",confidence=0.0, без арендатора. - Файл читается из S3 (
_read_temp, с проверкой префиксаtemp/<project>/); при ошибке —method="error". extract.doc_content_blocks(filename, data)возвращает(text | None, doc_blocks).- Быстрый путь по ИНН: если есть текст и
match_inn_fastнашёл ровно одного уникального арендатора —method="inn",confidence=0.99,doc_type="—", LLM не вызывается. - Если контент извлечь не удалось (
doc_blocksпуст) —method="error", «Не удалось извлечь содержимое документа». - Иначе —
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):
classify_structure:_structure_targetsберёт первыйObjectи всеBuilding(id,identifier); изidentifier/имени файла регуляркой_LITERA_REизвлекается литера,_guess_categoryопределяет категорию по ключевым словам. Быстрый путь по имени файла: категория из_OBJECT_CATEGORIES(legal,landscaping) + наличие объекта →scope="object",confidence=0.9,method="filename"; литера + категория →scope="building"аналогично. Иначе —_classify_llm(bind_tools([ClassifyStructureDocument], tool_choice="ClassifyStructureDocument"), промптDOC_STRUCT_SYSTEM). Для каждого файла проверяются расширение и_is_temp(иначеmethod="error", «Недопустимый путь файла»).commit_structure:scope="object"→reference_object_type=OBJECT+obj.id; иначеBUILDING+building_id; пропуск безref_id.move_file(..., make_public=False)вmedia/{url_name}/{uuid}{ext},media.createсcategory. Также по-элементный commit/rollback.
Модель данных / схема
Таблица-приёмник — 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)
POST …/classify— классификация по арендаторам.POST …/commit— раскладка подтверждённых по папкам арендаторов.POST …/resolve— чат-уточнение по спорному документу.GET …/folders— список папок арендаторов (count + last_updated).GET …/folders/{business_entity_id}— документы папки арендатора.POST …/structure/classify— классификация тех.документации по зданиям/объекту.POST …/structure/commit— раскладка тех.документации по зданиям/объекту.
Гейт _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
- Неподдерживаемое расширение →
method="unsupported", ручное назначение. - Ошибка чтения S3 →
method="error"с текстом причины. - Пустой/неизвлекаемый контент →
method="error"; в structure при наличии литеры из имени всё же предлагается здание сconfidence=0.3. - Неоднозначный ИНН (несколько разных арендаторов) → быстрый путь не срабатывает, уходит в LLM.
- Модель не вернула tool_call → безопасный дефолт (
business_entity_id=null,confidence=0.0). - commit: пропуск элемента без
business_entity_id/ref_idили с путём внеtemp/<project>/; имя обрезается[:200], тип[:500], категория[:40]. - Фиксы structure-режима, зафиксированные в коде:
move_file(make_public=False)(тех.документация, в т.ч. правоустанавливающая, не публична);_is_temp-валидация пути на каждом элементе classify и commit (защита от переноса чужого объекта); по-элементный commit с rollback для идемпотентности повторного импорта.
Покрытие тестами
Всего 11 тест-кейсов в 4 файлах; преимущественно детерминированные хелперы плюс один сервис-тест skip-ветки commit (все без S3/LLM):
test_classify.py(3):match_inn_fast— уникальное совпадение, отсутствие совпадений (None), неоднозначность двух разных ИНН (None).test_doc_structure.py(4):_guess_categoryпо ключевым словам (tech_passport/room_schedule/legal/landscaping/None), регэксп литеры case-insensitive («литер Б», «лит. Д», отсутствие),_is_temp-гард чужих путей, состав_OBJECT_CATEGORIES(legal+landscaping).test_doc_extract.py(3):extract_docxвозвращает текст,doc_content_blocksдля PNG даётtext=Noneи image-блок, наличие.pngвimage_mime.test_doc_inbox_service.py(1):test_commit_skips_items_without_entity—DocInboxService.commitвозвращает[]для элемента безbusiness_entity_id(фикстураtenant_session, без 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").
Как работает (пошагово)
- Приём транскрипта.
ingest_transcript(text)тримит вход и на пустой строке кидаетValueError("Пустой транскрипт"). Это единая «точка входа» текста — в докстроке помечена как место для будущего аудио → STT (Whisper), но самой STT-обработки нет. - Анализ. Эндпоинт
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>}. - Учёт стоимости. После успешного анализа эндпоинт логирует расход через
_log_cost_safe(kind="call_analysis", model=MODEL_SONNET, usage_data=..., task_class=TaskClass.CALL, user_id=...)(failure-isolated: сбой лога не ломает ответ). Provider в лог не передаётся — берётся дефолт"anthropic". - Сохранение в карточку. Разбор уже готов на стороне ассистента;
POST /call-analysis/saveвызываетsave_call_record(...), который повторно модель не зовёт (не тратит токены). Он тримит транскрипт черезingest_transcript, валидирует переданныйanalysisчерезCallAnalysis.model_validate, денормализует поля для списка и вставляет строкуAiCallRecord, затемflush+commit. Возвращает{"id", "created_time"}. - Чтение/удаление.
GET .../counterparties/{id}/call-records→list_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 (метод + путь)
POST /{projectId}/agent/call-analysis— транскрипт →CallAnalysis(+ usage, model_used). Guarded:_guard_api_key+_enforce_budget.ValueErrorизanalyze_call/ingest_transcript→ HTTP 400.POST /{projectId}/agent/call-analysis/save— детерминированная запись готового разбора в карточку. Не вызывает_guard_api_key/_enforce_budget(модель не дёргается).ValueError(в т.ч. невалидная схема или пустой транскрипт) → HTTP 400.GET /{projectId}/agent/counterparties/{counterparty_id}/call-records— список сохранённых разборов (свежие сверху).DELETE /{projectId}/agent/call-records/{record_id}— удаление; отсутствующая запись → HTTP 404Call record not found, иначе{"success": True}.
Все эндпоинты project-scoped: verify_jwt_for_project + tenant AsyncSession (get_tenant_session). Есть также «одностроковый» путь через /command с intent=CALL, но это заглушка (not_implemented=True, текст «Анализ звонков одной строкой — на подходе (Фаза 4).»).
Поведение и edge cases
- Пустой/пробельный транскрипт. Pydantic
min_length=1отсекает пустую строку на входе (422). Строка из одних пробелов проходит валидацию, ноingest_transcriptеё тримит и кидаетValueError→ 400Пустой транскрипт. - Невалидный разбор при сохранении.
save_call_recordловитValidationErrorи пере-кидываетValueError(f"Некорректный разбор звонка: {exc.error_count()} ошибок схемы")— наружу уходит только число ошибок, без утечки содержимого (PII/структуры) → 400. - save без повторного вызова модели. По контракту разбор делается один раз в
/call-analysis;/saveтолько валидирует форму и пишет — токены на сохранение не тратятся;model_usedберётся из тела запроса (клиент возвращает его обратно). - quality_total. Считается детерминированно суммированием
QualityScore(0–10), не приходит от модели. - Порядок списка.
order_by(created_time.desc(), id.desc()), дефолтныйlimit=50(вlist_call_records); в GET-эндпоинте limit захардкожен дефолтом функции (query-параметр не проброшен). - temperature ограничена
Literal["hot","warm","cold"]на уровне схемы; в БД хранится какString(10). - Учёт стоимости идёт только для
/call-analysis(kind=call_analysis, task_class=call);/save,/list,/deleteего не пишут.
Ограничения
- STT/аудио — только задел (комментарий в
ingest_transcript), обрабатывается исключительно текст. counterparty_idбез FK:save/listне проверяют существование карточки;deleteнаходит запись по глобальномуrecord_idбез скоупа на counterparty.analyze_callжёстко на Anthropic/Sonnet (ключanthropic_api_key,MODEL_SONNET, provider лога по умолчаниюanthropic) — не provider-aware в отличие от ask/act.- Реальной пагинации у списка нет (только limit, offset отсутствует).
Покрытие тестами
Единственный тест-файл — tests/test_ai_cost_and_call_records.py (12 тест-функций всего, из них по данной фиче — 3, все про путь удаления):
test_delete_call_record_removes_existing_row— хелперdelete_call_recordудаляет существующую строку, возвращаетTrue, в таблице 0 записей.test_delete_call_record_missing_returns_false— по несуществующему id →False.test_delete_call_record_endpoint_success_then_404— эндпоинт DELETE: успех →{"success": True}и записи нет; повтор →HTTPException404 с detailCall 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 там же.
Как работает (пошагово)
- Разрешается AI-клиент: переданный
clientлибоget_ai_client(). - По
template_idчерезDocumentTemplatesRepository.get_templateберётся шаблон. Если шаблона нет →{"status": "error", "message": "Шаблон не найден"}; еслиcurrent_version_id is None→{"status": "error", "message": "У шаблона нет опубликованной версии"}. - Загружаются переменные опубликованной версии. Они делятся:
_fillable_vars(все, у когоvalue_type != "contract") и флаг_has_contract_tag(есть ли переменная сvalue_type == "contract"). - Гейт выбора договора: если в шаблоне есть тег
contract, ноcontract_id is None, возвращаетсяstatus: "needs_input",needs_field: "contract_id", пустыеauto_filled/needs_reviewи сообщение «Укажите договор — из него берутся стороны и их реквизиты.». - Сбор контекста CRM: если
contract_idзадан,context["contract"] = await build_contract_context(session, contract_id)— тот же богатый человекочитаемый источник (имена сторон, номер, даты, позиции/помещения), что и для детерминированного тегаcontract. - Если есть заполняемые переменные — собирается промпт: построчный список переменных (
- {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). - Значения фильтруются защитно: остаются только ключи из множества известных имён переменных (
known = {v.name for v in fillable}) и непустые строки (защита от галлюцинированных ключей).needs_reviewматчится по известным именам:entry == k or entry.startswith(k)(модель иногда возвращает «имя: причина»), результат — отсортированный список. - Рендер:
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— но без файла. - Формируется
filename = f"{(template.name or 'document').strip()}.docx"и дифф 80/20:auto_filled— значения не из review,needs_review—{name: value}по именам из review. - Персист только на 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, документ всё равно отдаётся (сбой персиста не должен ронять выдачу). - Возврат:
{"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_num — varchar(32)).
Endpoints
POST /{projectId}/agent/document(app/api/v1/endpoints/agent.py) — телоDocumentGenRequest(template_id,contract_id,instructions), ответDocumentGenResponse(status,message,file_base64,filename,auto_filled,needs_review,needs_field,model_used,usage). Зависимости/гейты:get_tenant_session,get_project,verify_jwt_for_project, а также_guard_api_key()иawait _enforce_budget(session);user_idберётся из JWT (_jwt.get("user_id")).GET /{projectId}/agent/generated-documents?limit=50&offset=0— реестр генераций (свежие сверху), ответlist[GeneratedDocumentOut].
Роутер 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_time — needs_review в список не выводится.
Поведение и edge cases
- Нет шаблона / нет опубликованной версии →
status: "error". - Есть тег
contract, но нетcontract_id→needs_inputсneeds_field: "contract_id". - Незаполненные обязательные поля (рендер бросает исключение) →
needs_inputс текстом ошибки, диффauto_filled/needs_reviewиusageотдаются, файла нет. - Нет заполняемых переменных (все —
contract) → вызов модели пропускается,valuesпустые,usageостаётся нулевым, шаблон рендерится сразу. - Галлюцинированные ключи и пустые строки отбрасываются; имена в
needs_reviewматчатся по точному совпадению или префиксу. - Тег
contractподставляется детерминированно изbuild_contract_context, не моделью; system-промпт явно запрещает модели трогать реквизиты сторон. - Изоляция сбоя персиста: при исключении на записи транзакция откатывается (
rollback), частичных строк не остаётся, ноstatus: "ok"иfile_base64всё равно возвращаются.
Ограничения
- Файл не сохраняется в S3 на этом шаге — только inline base64; в реестре
file_pathвсегдаNone, в БД лежат метаданные и дифф, но не сам документ. signatory_idв этом потоке не заполняется.- У
ai_generated_documentsнет индексов — сортировка реестра поcreated_time desc, id descидёт полным сканом. needs_reviewв списочном ответе не возвращается (толькоauto_filled).- Модель фиксирована
MODEL_SONNET, провайдерanthropic; требуетсяanthropic_api_keyи прохождение бюджет-гейта. kindжёстко ="document_generation"(примерlease_agreementв комментарии модели — не факт кода; в миграции такого комментария нет).
Покрытие тестами
Файл tests/test_ai_documents_persistence.py — 3 тест-кейса (fake AI-клиент возвращает валидный DocMapping с пустым needs_review, fake S3-хранилище; используется опубликованный простой шаблон без тега contract):
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).test_generate_document_failure_isolation—persist_generated_documentподменён на бросающий; результат всё равноokсfile_base64, транзакция откачена → в БД 0 документов и 0 логов стоимости.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 (авто-роутинг намерений)
Назначение
Одно текстовое поле портала вместо ручного выбора режима «Спросить/Сделать/Документ/…». Пользователь пишет фразу одной строкой, дешёвый классификатор (малая модель — у Anthropic Haiku, у deepseek/ollama настроенная модель) определяет ОДНО намерение, а оркестратор run_command роутит запрос в существующего исполнителя. Это «мозг» ассистента: intent_classifier отвечает за распознавание намерения, command.run_command — за диспетчеризацию и учёт стоимости. Модули router/classifier.py и router/model_router.py лежат рядом, но относятся к другому (TaskClass-ориентированному) пайплайну и в командную строку не подключены.
Как работает (пошагово)
- Эндпоинт
POST /{projectId}/agent/command(app/api/v1/endpoints/agent.py) принимаетCommandRequest{message}, проверяет ключ провайдера (_guard_api_key→ 503ai_not_configured) и месячный бюджет (_enforce_budget→ 402ai_budget_exceeded), затем вызываетrun_command(...). run_command(app/ai/command.py) берёт AI-клиент (client or get_ai_client(), в тестах подменяется) и зовётclassify_intent(client, message)— один вызов малой модели (tier="small") со structured output, возвращает(IntentResult, Usage).- Классификация логируется отдельной строкой стоимости:
_log_cost_safe(kind="command_classify", model=model_for_tier(settings.llm_provider, settings.llm_model, "small"), provider=settings.llm_provider, task_class=None)— фактическая модель/провайдер, не хардкод Anthropic. Эта строка пишется всегда, независимо от итогового интента. - Формируется базовый ответ
base = {intent, reason, usage}и дальше идёт ветвление поintent_result.intent:
ASKиANALYTICS→AgentService(...).ask(message)(read-only инструменты по данным проекта); пишется строкаkind="ask",task_class=ANALYTICAL; в ответ кладётся СУММАРНЫЙ usage (классификация + ask), плюсanswer,used_tools.ACT→AgentService(...).act(message)(черновик write-действия, исполнение только после отдельного/confirm); строкаkind="act",task_class=REACTIVE; в ответеstatus,answer,used_tools,action,thread_id, суммарный usage.NAVIGATE→ без обращения к модели дальше: возвращаетсяnav = intent_result.nav.model_dump()(илиNone); конкретный маршрут и резолв id строит фронт.HELP→ статический_HELP_TEXT.DOCUMENT/CALL→ заглушка_SOON_TEXT.get(intent, _HELP_TEXT),not_implemented=True, пробросdoc_kind.
- Эндпоинт перекладывает 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(output_format=IntentResult, tier="small", system=_SYSTEM, messages=[{"role":"user","content":text}], max_tokens=512) — конкретную модель под провайдера разрешает model_for_tier внутри _chat_model (anthropic→Haiku, deepseek/ollama→settings.llm_model). Схема форсируется через 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 для диспетчнутых веток).
Смежный, но НЕ подключённый к командной строке слой:
router/classifier.py:classify(client, text) -> (Classification{task_class: TaskClass, kind, reason}, Usage)— другая таксономия (класс ДОКУМЕНТА, а не намерение строки). Вызовов в бэкенде нет.router/model_router.py:select_model(task_class)маппитTEMPLATED→Sonnet, ANALYTICAL→Opus, REACTIVE→Opus, CALL→Sonnet(иначе Sonnet). Вызовов в бэкенде нет.
Endpoints
POST /{projectId}/agent/command— единая ИИ-командная строка (авто-роутинг по намерению). ТелоCommandRequest{message}, ответCommandResponse. Ошибки: 503ai_not_configured(нет ключа провайдера), 402ai_budget_exceeded(месячный лимит расходов). Для намеренияactполученныйthread_idзатем используется во внешнемPOST /{projectId}/agent/confirm(единственный путь записи).
Поведение и edge cases
- Логирование классификации происходит всегда, даже для
navigate/help(где модель дальше не зовётся) — в БД остаётся ровно одна строкаcommand_classify. - Для
ask/actв ответномusageвозвращается СУММА (классификация малой моделью + диспетчнутый вызов) через_combined, а не только стоимость классификации. ASKиANALYTICSроутятся в один и тот жеAgentService.ask(read-only); отдельного аналитического обработчика пока нет.NAVIGATEпри пустомnavотдаётnav: None; резолв id и маршрут — на фронте. ЗначениеentityвнеNAV_ENTITIESбэкенд не отбраковывает.DOCUMENT/CALLдеградируют мягко:not_implemented=True+ текст «на подходе»,doc_kindпробрасывается для будущего использования.- Подсказка модели в
_SYSTEM: при сомнении междуaskиnavigateвыбиратьnavigate, если явно назван один объект с номером/именем. - Сбой записи стоимости (
log_cost) не влияет на пользовательский ответ: rollback + warning; незакоммиченных доменных записей к этому моменту нет по построению (ask read-only, act возвращает stateless-черновик).
Ограничения
- Классификация намерений НЕ покрыта выделенными тестами: точность
classify_intent(system-prompt, распознавание) не проверяется — тестыrun_commandиспользуют мок_FakeIntentClient, возвращающий готовыйIntentResult. - Реальный риск неверного роутинга: результат классификатора применяется без валидации/фолбэка. Ошибочный
ACTуводит запрос в write-ветку (черновик), ошибочныйNavTarget— в навигацию по несуществующей сущности; на бэкенде это не отсекается. - Реализованы только
ASK/ANALYTICS,ACT,NAVIGATE,HELP;DOCUMENTиCALL— заглушки (Фаза 2/4). model_router.select_modelиrouter.classifier.classifyопределены, но не вызываются нигде — не участвуют в /command.- Классификатор провайдеро-нейтрален (
tier="small"→model_for_tier): anthropic→Haiku, deepseek/ollama→настроенная модель; cost-log пишет фактическую модель/провайдер. При отсутствии ключа выбранного провайдера эндпоинт падает в 503 до классификации.
Покрытие тестами
Файл tests/test_ai_cost_and_call_records.py содержит 13 тест-функций, но командную строку сквозь run_command затрагивают 3:
test_command_ask_intent_logs_classify_and_ask_rows—intent=ASK: проверяет две строки лога (command_classify+ask, порядок и поля), суммарный usage (30+305 input, 10+50 output, сумма estimate_usd),description="via /command"без PII.test_command_navigate_intent_logs_only_classification—intent=NAVIGATE: модель дальше не вызывается, ровно одна строкаcommand_classify(anthropic-настройки → Haiku,task_class=None),nav={"entity":"contract","query":"2024-15"}.test_command_classify_logs_configured_model_not_haiku— model-independence: приllm_provider="deepseek"/llm_model="deepseek-chat"строкаcommand_classifyлогируетmodel="deepseek-chat",provider="deepseek"(а не Haiku/anthropic).
Тесты подменяют классификатор моком _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.
Как это работает (пошагово)
- 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. - Соответствующий парсер приводит файл к списку
NormalizedTxnс единой моделью (дата, сумма, направление, назначение, реквизиты плательщика,bank_transaction_id). - Приходные (
direction == IN) идут в матчинг, расходные считаются вskipped_outgoingи в драфт не попадают. match_transactionsприсваивает каждой приходной транзакции уровень уверенности словами (confident/needs_review/unmatched) и предложенные привязки, читая справочники (не пишет в БД).previewгруппирует результаты по уровням, формирует человеко-читаемоеsummary(с русской плюрализацией) и возвращаетStatementPreviewResponse. В БД ничего не пишется.- commit принимает отредактированный клиентом список строк драфта и для каждой создаёт
Paymentсо статусомUNPROCESSEDчерезPaymentsService.create_payment, с дедупликацией поtransaction_id. Распределение платежей по счетам делает почасовойtasks/process_payments, не этот сервис.
Разбор форматов
1С (parser_1c.py). Текстовый секционный формат, кодировка обычно cp1251 (декодер перебирает cp1251 → utf-8-sig → utf-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-значным счётом). Контрагент резолвится по ИНН (поле уникально), вспомогательно — по расчётному счёту (только если по счёту находится ровно один контрагент). Каскад правил (первое сработавшее задаёт уровень):
confident— ровно один открытый счёт контрагента с остатком, точно равным сумме транзакции →invoice_id+ егоcontract_id;confident— ровно один активный договор (нет точного счёта) →contract_id(счёт подберётprocess_payments);needs_review— контрагент определён, но кандидатов несколько / сумма не сходится → списокcandidates(обрезается до_MAX_CANDIDATES = 25);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_duplicates (с session.rollback()). Гонка на уникальном индексе (IntegrityError) тоже считается дубликатом, а не ошибкой. Прочие сбои (напр. несуществующий contract_id → NotFoundError из _validate_references) → failed. Каждый платёж коммитится своей транзакцией (create_payment вызывает session.commit()).
Endpoints
POST /{projectId}/agent/statement/preview— multipartfile+ опц.fmt(1c|csv|mt940). ВозвращаетStatementPreviewResponse. Нераспознанный/битый формат → 422 (UnsupportedStatementError). Требует project JWT (verify_jwt_for_project) + tenant-сессию.POST /{projectId}/agent/statement/commit— body{"lines": [DraftPaymentLine,...]}. ВозвращаетStatementCommitResponse(created/skipped_duplicates/failed/summary).
Поведение и edge cases
- preview оборачивает в 422 только ошибку шага разбора (бэкстоп над
parse_statement); сбои матчинга/БД остаются 500 — сознательно. - Реальный регресс из ревью: MT940 со строкой
:61:...//без ссылки раньше падалIndexError→ 500, теперь разбирается штатно (bank_transaction_id = None). - Расходные операции не матчатся вообще (
match_transactionsпропускаетdirection != IN), только считаются вskipped_outgoing. - Полностью оплаченный счёт (аллокация == value) не считается открытым — при единственном активном договоре матч уходит на договор.
- Длинное назначение усекается до 200 символов;
transaction_id— до 50. - summary формируется с русской плюрализацией (1 платёж / 2 платежа / 5 платежей).
Ограничения
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/:
- test_parsers.py (9): 1С — декодирование cp1251 и нормализация (включая
transaction_idсо счётом плательщика), пометка расходной, авто-детект безfmt; CSV — знаковые суммы и направления; MT940 — базовый разбор + извлечение ИНН из:86:+ регресс на trailing//; ошибка на нераспознанном формате; CSV требует явногоfmt; константаSUPPORTED_FORMATS == {1c, csv, mt940}. - test_matcher.py (14):
extract_inn(явное поле / ключевое слово / отсутствие / игнор 20-значного счёта);resolve_business_entityпо ИНН и по счёту; каскад — точный счёт → confident, единственный договор → confident, несколько договоров → needs_review, неизвестный ИНН → unmatched, ИНН из назначения резолвится, отсутствиеtransaction_id→ needs_review + possible_duplicate, полностью оплаченный счёт не матчится как точный, расходные игнорируются. - test_commit.py (7): бэкстоп preview оборачивает ошибку разбора в UnsupportedStatementError; malformed MT940 не роняет preview; commit создаёт UNPROCESSED-платежи; повторный импорт даёт skipped_duplicates и не задваивает; сохранение выбранного пользователем contract_id; усечение назначения до 200; невалидный contract_id → failed.
- test_endpoints.py (4): happy-path preview (skipped_outgoing, распределение по bucket'ам), commit happy → duplicate, требование авторизации (401) на commit и preview.
Вычисляемые счётчики (SUM / DIFFERENCE)
Назначение
Фича даёт «считающийся сам» (виртуальный) счётчик — узел, у которого нет собственных показаний, а его значение за период выводится из показаний нескольких реальных счётчиков-операндов. Каждый операнд входит в формулу со знаком (+1 / −1), знаки определяются выбранной операцией: SUM (сумма расходов) или DIFFERENCE (первый операнд минус остальные). Такой счётчик можно разместить на плане объекта (координаты иконки). Таблицы — новые, FastAPI-owned; связи на Django-таблицы (metered_resources, meters, plans) read-only, миграциями Django они не трогаются.
Как работает (пошагово)
Создание (create_computed_meter):
_validate_resource— проверка, чтоresource_idуказывает на существующийMeteredResource, иначеNotFoundError(field="resource_id")._validate_operand_meters— операндов должен быть хотя бы один (BusinessRuleError), без дублейmeter_id(BusinessRuleError), всеmeter_idдолжны существовать вmeters(NotFoundError).- Создаётся строка
computed_meters(operationзаписывается как.valueenum). _derive_signs(operation, operands)выводит per-operandsignи нормализуетposition; результат пишется черезreplace_operands.session.commit(), возврат перечитанногоComputedMeterResponse.
Вычисление значения (compute_value(id, period_start, period_end)):
- Загружается счётчик с операндами; если нет — возвращается
None(эндпоинт отдаёт 404). - Определяется
unitиз ресурса вычисляемого счётчика; одним запросом подтягиваются счётчики-операнды (coefficient,identifier). - Для каждого операнда:
start = latest_reading_on_or_before(meter_id, period_start)— последнее показание на дату начала периода или раньше (нижняя граница не ограничена);end = latest_reading_in_window(meter_id, period_start, period_end)— последнее показание в окне[period_start, period_end];- если счётчик не найден в
metersили нетstart/end— операнд пропускается,meter_idпопадает вmissing, добавляется заметка вnotes; - иначе
consumption = (end.value − start.value) * meter.coefficient,total += sign * consumption; is_interpolatedкомпоненты истинно, если аппроксимировано ЛЮБОЕ из показаний (startилиend).
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):
GET /— список (фильтрresource_ids, сортировкаsort_by/sort_order, пагинацияoffset/limit1..200);POST /— создание (201);GET /{id}— получение (404 если нет);PATCH /{id}— частичное обновление;DELETE /{id}— удаление (200,{success: true});GET /{id}/value?period_start=&period_end=— вычисление значения за период.
Роутер координат (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 и добавляет заметку «показания аппроксимированы»; несуществующий id → None → 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_id → NotFoundError.
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-показания также всплывает; неизвестный id → None.
Контрагенты — расширенные атрибуты
Назначение
Фича добавляет к карточке контрагента (business_entities) и его подписантам (business_entity_signatories) набор атрибутов, нужных для юридически корректной AI-генерации договоров и для организационного учёта:
- основание полномочий подписанта (
authority_basis: «устав» / «доверенность») и реквизиты доверенности (attorney_number,attorney_date,attorney_valid_to) — чтобы в договоре корректно указывалось, на каком основании действует подписант; - ответственный менеджер контрагента (
manager_user_id) — организационная привязка карточки к сотруднику проекта; - справочный канал доставки счетов (
invoice_delivery_method: EMAIL / EDO) и адрес (invoice_email) — метаданные для оператора, без влияния на фактическую отправку.
Миграция полностью аддитивная (все колонки nullable), консолидирована в один alembic-revision 0017_counterparty_ext_attrs (Revises: 0016_computed_meters).
Как работает (пошагово)
- Запись подписанта. Клиент шлёт
POSTна…/signatories/илиPATCH/PUTна…/signatories/{id}сauthority_basisи (опционально) реквизитами доверенности. Pydantic валидируетauthority_basisпротивSignatoryAuthorityBasisEnum, аattorney_number— поmax_length=50; неизвестное основание отклоняется 422 ещё до сервиса. - Нормализация.
SignatoryServiceпропускает данные черезnormalize_signatory_authority(...): еслиauthority_basisприсутствует в payload и не равенPOWER_OF_ATTORNEY, все три реквизита доверенности принудительно зануляются. Проверка «поле вообще прислано» ("authority_basis" in data) сохраняет PATCH-семантикуexclude_unset— если основание не меняли, реквизиты не трогаются. - Чтение в AI-контекст. При сборке контекста шаблона договора
_signatory(sig)кладёт вSignatoryCtxчеловекочитаемый ярлык (authority_basis→ «устав»/«доверенность» черезL.authority_basis) и сырой код (authority_basis_code). Реквизиты доверенности подставляются только приauthority_basis == "POWER_OF_ATTORNEY"— второй, защитный слой очистки на случай устаревших значений в БД. - Запись контрагента.
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-апдейт. - Резолв менеджера на фронте. Так как
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 монтируется без глобального префикса.
POST /{projectId}/business-entities/— создать контрагента (принимаетmanager_user_id,invoice_delivery_method,invoice_email).PATCH /{projectId}/business-entities/{id}— частичное обновление (только изменённые поля).PUT /{projectId}/business-entities/{id}— полное обновление.GET /{projectId}/business-entities/{id}иGET /{projectId}/business-entities/— новые скалярные поля всегда в ответе;default_signatories(с их реквизитами доверенности) подгружаются опционально поfields=default_signatories.POST /{projectId}/business-entities/composite— композитное создание контрагента вместе с подписантами;entity_dataвcreate_compositeвключаетmanager_user_id/invoice_delivery_method/invoice_email, а каждый вложенный подписант проходит черезnormalize_signatory_authority.POST /{projectId}/business-entities/signatories/— создать подписанта (authority_basis+attorney_*).PATCH/PUT /{projectId}/business-entities/signatories/{id}— обновление подписанта с нормализацией реквизитов.GET /{projectId}/business-entities/signatories/— список подписантов.GET /{projectId}/members/brief— краткий справочник активных участников проекта (user_id,name,email) для селекта «Ответственный менеджер»; фильтрq, пагинацияlimit/offset, дедупликация поuser_id.
Поведение и edge cases
- Скраб реквизитов на запись: смена основания на
CHARTERв PATCH зануляетattorney_number/date/valid_to, даже если они были заполнены. Еслиauthority_basisв PATCH не прислан — реквизиты сохраняются (не затираются). - Скраб на чтение:
_signatoryне тянетattorney_*в шаблон приauthority_basis != POWER_OF_ATTORNEY, защищая от «протухших» значений в строке БД. - POWER_OF_ATTORNEY без реквизитов: допустимо — реквизиты остаются
None, ошибок нет (обязательность номера/даты при доверенности не enforced). - manager_user_id записывается как есть, без проверки существования пользователя; несуществующий id просто не резолвится в имя на фронте.
- invoice_delivery_method = EDO не запускает никакой ЭДО-логики — поле справочное.
- /members/brief выгребает членство целиком (постранично, предохранитель
_MAX_PAGES=50), затем сам фильтрует поuser_is_active AND active, дедуплицирует роли поuser_id, применяет поискqи режет поoffset:offset+limit;totalсчитается по отфильтрованному списку (после active/дедуп/q), а не по сырым строкам.
Ограничения
manager_user_id— без FK (кросс-БД), целостность на уровне БД не гарантируется; нет привязки к фактическому членству в проекте.- Канал доставки счетов и
invoice_email— чисто информационные, на реальную рассылку не влияют; email проверяется только по длине (max_length=150), не по формату. - Инвариант «реквизиты доверенности только при POWER_OF_ATTORNEY» держится двумя независимыми реализациями (write-side
normalize_signatory_authority+ read-side_signatory); write-side отдельным тестом не покрыт. - enum'ы хранятся строками (
String(30)/String(10)) — БД значения не ограничивает, корректность держится Pydantic-валидацией на входе.
Покрытие тестами
Фичу напрямую покрывают 6 тест-кейсов в tests/test_counterparty_extended_attrs.py:
test_signatory_authority_fields_roundtrip— async round-trip:authority_basis=POWER_OF_ATTORNEY+ реквизиты сохраняются в модель и отдаютсяSignatoryResponse.test_signatory_create_request_rejects_unknown_basis— неизвестное основание (NOTARY) отклоняетсяValidationError(422 на границе API).test_business_entity_manager_and_delivery_roundtrip— async round-trip:manager_user_id/invoice_delivery_method/invoice_emailсохраняются и отдаютсяBusinessEntityResponse.test_business_entity_update_rejects_unknown_delivery_method— невалидный канал (PIGEON) отклоняетсяValidationError.test_signatory_ctx_includes_authority_basis— AI-контекст_signatory: ярлык «доверенность», кодPOWER_OF_ATTORNEY, реквизиты доверенности подставлены.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_type ↔ coords/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):
- Из тела строится diff:
data = body.model_dump(exclude_unset=partial)(дляPATCH— только явно переданные поля, дляPUT— все). - Загружается существующая запись; если её нет — возвращается
None(эндпоинт даёт404). - Есть защитная проверка иммутабельности
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. - Вычисляется эффективное состояние — слияние
existingи патча:effective_rendering = data.get("rendering_type", existing.rendering_type), аналогичноeffective_coords,effective_icon_x,effective_icon_y. Явныйrendering_type=None(илиobject_type=None) отклоняется отдельной проверкой. - Определяется активная entity-ссылка записи (из
existing); если она есть — вызывается_validate_rendering_type(effective_rendering, entity_field)(пространственные сущности → толькоPOLYGON,meter_id→ толькоICON). - Вызывается
_validate_coord_fields(effective_rendering, effective_coords, effective_icon_x, effective_icon_y)— та же самая проверка, что и вcreate_coordinate: дляICONобязательныicon_position_x/yи запрещёнcoords; дляPOLYGONобязателенcoordsи запрещены icon-поля. - Только после этого выполняется запись и
commit.BusinessRuleErrorтранслируется в HTTP422(обработчик в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) — запрос никогда не падает.
Вызывается в трёх точках:
create_plan— после переноса временного файла в постоянный путь:data["image_width"], data["image_height"] = await read_plan_image_dimensions(...).update_plan— только внутри веткиif "path" in data and data["path"] != existing.path, то есть при фактической замене файла плана; заодно удаляется старый файл.- Composite floor (
floors_service.py,CompositeFloorsService): вcreate_compositeразмеры считаются при наличииplan_path; вupdate_composite— в веткахplan_action == "create"и"update". Результат передаётся вplans_repo.create/update(image_width/image_height).
Флаг 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):
POST /—create_plan(201), вычисляет размеры при загрузке.PATCH /{id}—update_plan(partial=True), пересчёт размеров при сменеpath.PUT /{id}—replace_plan(partial=False), пересчёт размеров при сменеpath.GET /{id},GET /— отдаютimage_width/height.DELETE /{id}.
Координаты (prefix=/{projectId}/plan-coordinates):
POST /—create_coordinate(201), валидация consistency.PATCH /{id}—update_coordinate(partial=True), ревалидация эффективного состояния.PUT /{id}—replace_coordinate(partial=False), ревалидация.GET /,GET /{id},DELETE /{id}.
Здания: GET /{projectId}/structure/buildings и GET .../buildings/{id} с ?fields=has_floor_plans.
Поведение и edge cases
PATCHсcoords=NoneнаPOLYGON→422;PATCHсicon_position_x/yнаPOLYGON→422;PATCHсicon_position_x=NoneнаICON→422;PATCHсcoordsнаICON→422.- Смена
rendering_typeбез согласованного набора полей отклоняется;rendering_type=None/object_type=Noneв патче →422. - Валидные патчи проходят: смена
coords/labelуPOLYGON, сменаicon_position_x/yуICON, полныйPUTс консистентным набором. PUT(partial=False) валидирует полное присланное состояние —PUTбезcoordsдляPOLYGONотклоняется.- Загрузка не-изображения (например PDF) →
image_width/height = NULL, ноcreate_plan/update_planзавершаются успешно (никогда не500). update_planпересчитывает размеры только при фактической сменеpath(сравнение сexisting.path); прочие поля не триггерят чтение файла.- Композитные операции над этажом (create с
plan_path, update сplan_action=create/update) заполняют размеры теми же вызовамиread_plan_image_dimensions.
Ограничения
- Размеры — best-effort: при ошибке чтения/декодирования колонки остаются NULL, фронт должен уметь работать без них.
- Нет backfill для строк, созданных до миграции 0018 — у них размеры NULL до перезагрузки файла.
has_floor_plansучитывает только планы этажей (черезPlan.floor_id), не планы здания/объекта напрямую.- Ревалидация проверяет только согласованность полей с
rendering_type, но не разбирает/не валидирует саму геометрию строкиcoords. - Иммутабельность
plan_id/entity-ссылок вupdate_coordinateзащитная и недостижима через API (схема их не экспонирует). - Тесты только сервисного уровня (без HTTP/интеграционных);
has_floor_plansне покрыт тестами в файлах этой фичи.
Покрытие тестами
Всего 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 с фильтром по ней в списке медиа-файлов.
Назначение
- Счета: позволить сузить выдачу
GET /{projectId}/invoices/по интервалу времени, когда счёт был отправлен (точнее — поставлен в очередь отправки) на email. Дополняет уже существовавший булев фильтрis_sent_to_email(отправлен / не отправлен) собственно диапазоном поsent_to_email_time. - Медиа: добавить документам здания/объекта категорию (тех. паспорт, экспликация помещений, юридические, благоустройство, фото, прочее) и дать фильтровать список медиа-файлов по этой категории.
Как работает (пошагово)
Фильтр по дате отправки:
- Эндпоинт
list_invoices(contract_invoices.py) принимает два строковых query-параметраsent_to_email_time_fromиsent_to_email_time_to(типOptional[str], с русскимиdescription). - Для каждого непустого значения эндпоинт вызывает
datetime.fromisoformat(...)и кладёт результат вkwargs(sent_to_email_time_from/sent_to_email_time_to). Пустые/Noneзначения вkwargsне попадают. InvoicesService.list_invoices(*, fields=None, kwargs)пробрасываетkwargsв репозиторий без изменений (self._repo.list_invoices(with_payment_status=..., kwargs)).InvoicesRepository.list_invoicesпри заданномsent_to_email_time_fromдобавляетWHERE ContractInvoice.sent_to_email_time >= :from, приsent_to_email_time_to—WHERE ContractInvoice.sent_to_email_time <= :to. Обе границы включительные, условия комбинируются через AND и с любыми другими фильтрами (в т.ч.is_sent_to_email).sent_to_email_timeпроставляется бизнес-логикой при постановке письма в очередь отправки (см.dispatch_invoice_emailв сервисе; семантика — «передано в очередь», не «доставлено»).
Категория медиа:
- Миграция
0015_media_categoryдобавляет nullable-колонкуcategory(String(40)) вbase_media_files. - Модель
BaseMediaFileи pydantic-схемы (MediaFileResponse,MediaFileCreateRequest,MediaFileUpdateRequest) получают полеcategory: Optional[str]. - Эндпоинт
list_media_files(media.py) принимаетcategory: Optional[str] = Query(None)и пробрасывает черезMediaService.list_filesвMediaRepository.list. 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
GET /{projectId}/invoices/—list_invoices, новые query-параметрыsent_to_email_time_from,sent_to_email_time_to(ISO 8601, включительные границы).GET /{projectId}/media/—list_media_files, новый query-параметрcategory(точное совпадение).
Оба списочных эндпоинта защищены verify_jwt_for_project; счета также поддерживают существующую пагинацию/сортировку.
Поведение и edge cases
- Границы
sent_to_email_timeвключительные (>=/<=). Тест на верхнюю границу подтверждает: счёт, отправленный ровно в момент_to, попадает в выдачу. - Значение без времени трактуется как полночь:
'2026-03-20'=2026-03-20T00:00:00. Для «по конец дня» нужно явно передавать'2026-03-20T23:59:59'— авторасширения нет. - Композиция с
is_sent_to_email=True: булев фильтр отбрасывает записи сsent_to_email_time IS NULL, диапазон дополнительно сужает по времени (совместно проверено тестом). - Пустая строка в
sent_to_email_time_from/toсчитается falsy и молча игнорируется (фильтр не применяется). - Некорректная ISO-строка приводит к необработанному
ValueErrorизdatetime.fromisoformat(нет try/except в эндпоинте). category— точное сравнение по строке; регистр и частичное совпадение не поддерживаются;NULL-категория при фильтрации по конкретному значению не возвращается. Условие срабатывает приcategory is not None(не при «непустом»): пустая строка у медиа-эндпоинта фильтрует поcategory == ''.
Ограничения
- Фильтр по
categoryне покрыт тестами (вtests/нет кейсов наMediaRepository.listсcategory). category— свободныйString(40)без enum-валидации ни в модели, ни в схемах; переченьtech_passport/room_schedule/legal/landscaping/photo/otherживёт только в комментарии.- Комментарий модели гласит «Миграциями владеет Django», но колонку добавляет alembic-миграция 0015 этого репозитория — фактическое двойное владение схемой таблицы-зеркала.
- Часовой пояс не нормализуется на входе эндпоинта; поведение naive vs aware datetime зависит от БД/драйвера.
Покрытие тестами
Файл tests/test_invoices_repository_sent_to_email_range.py — 4 асинхронных теста на InvoicesRepository.list_invoices (общий сид: контракт + 4 счёта, отправленные 01/10/20 марта и один с NULL):
test_sent_to_email_time_from_only— только нижняя включительная граница (from=10 марта) →{2, 3}, total=2.test_sent_to_email_time_to_only— только верхняя включительная граница (to=10 марта 12:00, ровно на границе) →{1, 2}, total=2.test_sent_to_email_time_range— диапазон[5, 15] марта→{2}, total=1.test_sent_to_email_time_range_composes_with_is_sent_to_email—is_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_documents | 0014_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_log | 0014_ai_persistence | Разложенный по 4 корзинам расход токенов + estimate_usd Numeric(12,6), provider (default anthropic) | ix_ai_cost_log_created_time (под помесячный cost-guard) |
ai_call_records | 0014_ai_persistence | Сохранённые разборы звонков (CallAnalysis JSON + денормализованные temperature/quality_total) | ix_ai_call_records_counterparty_id; counterparty_id без FK (кросс-БД) |
computed_meters | 0016_computed_meters | Вычисляемые счётчики (operation SUM/DIFFERENCE) | ix_computed_meters_resource_id |
computed_meter_operands | 0016_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_coordinates | 0016_computed_meters | Координаты иконки вычисляемого счётчика на плане | computed_meter_id UNIQUE |
Новые колонки
| Таблица | Колонки | Миграция | Назначение |
|---|---|---|---|
base_media_files | category String(40) nullable | 0015_media_category | Категория документа (tech_passport/room_schedule/legal/landscaping/photo/other); также используется structure-инбоксом |
business_entities | manager_user_id Integer nullable (без FK), invoice_delivery_method String(10) nullable, invoice_email String(150) nullable | 0017_counterparty_ext_attrs | Ответственный менеджер + справочный канал доставки счетов |
business_entity_signatories | authority_basis String(30) nullable, attorney_number String(50) nullable, attorney_date Date nullable, attorney_valid_to Date nullable | 0017_counterparty_ext_attrs | Основание полномочий подписанта + реквизиты доверенности |
plans | image_width Integer nullable, image_height Integer nullable | 0018_plan_image_dimensions | Размеры исходного изображения плана в пикселях (best-effort, Pillow) |
Цепочка миграций и сведение heads
Ревизии выстроены линейно: 0013_business_entity_hid → 0014_ai_persistence → 0015_media_category → 0016_computed_meters → 0017_counterparty_ext_attrs → 0018_plan_image_dimensions. Поскольку консолидация свела две расходившиеся ветки, обе привносили собственные alembic-heads; финальная миграция 0019 сводит alembic-heads в единый линейный head (merge-ревизия), чтобы alembic upgrade head не падал на множественных головах. Ограничение длины revision id ≤ 32 символа соблюдено (тип alembic_version.version_num — varchar(32)).
Конфигурация (env)
Модельный слой и оркестрация настраиваются через settings (алиасы — переменные окружения):
LLM_PROVIDER(llm_provider) — провайдер chat-модели агента:anthropic|deepseek|ollama. Определяет веткуget_chat_modelи provider-aware гейт_guard_api_key/_require_key.LLM_MODEL(llm_model) — имя модели агента (для Ollama — в т.ч. шаблонcommand-r, включающий совместимостьdirectly-answer).LLM_BASE_URL(llm_base_url) — базовый URL для Ollama (удалённый инстанс за nginx).LLM_API_KEY(llm_api_key) — токен для удалённого Ollama (Authorization: Bearer <...>).DEEPSEEK_API_KEY(deepseek_api_key) — ключ DeepSeek; при провайдереdeepseekего отсутствие → 503ai_not_configured.ANTHROPIC_API_KEY(anthropic_api_key) — ключ Anthropic; нужен для прикладных потоков, жёстко привязанных к Anthropic (генерация документов на Sonnet, разбор звонков на Sonnet). Классификатор/commandпровайдеро-нейтрален (tier="small"): на Anthropic — Haiku, на deepseek/ollama — настроенная модель.
Оркестрация состояния агента:
AGENT_CHECKPOINT_BACKEND(agent_checkpoint_backend) —memory(по умолчанию, dev/тесты,MemorySaver) |postgres(AsyncPostgresSaver, прод). Неизвестное значение →ValueErrorна старте.LANGGRAPH_PG_DSN(langgraph_pg_dsn) — DSN для postgres-checkpointer (используется только приagent_checkpoint_backend=postgres).
Учёт стоимости:
AI_MONTHLY_COST_LIMIT_USD(ai_monthly_cost_limit_usd, default200.0) — месячный потолок расходов AI;_enforce_budgetсверяет с нимSUM(estimate_usd)за календарный месяц UTC.0(или пусто) полностью отключает cost-guard.
Зрелость и покрытие тестами
Прод-готово (без существенных оговорок):
- Импорт банковской выписки и матчинг платежей — самый покрытый блок (34 теста): парсеры 1С/MT940, каскад матчинга, идемпотентность commit, эндпоинты и авторизация. Полностью детерминирован, LLM не требует.
- Вычисляемые счётчики (19 тестов): CRUD, вывод знаков,
compute_valueсо всеми ветками (missing/интерполяция/coefficient/position), фикс UNIQUE при замене операндов, координаты плана. - Ревалидация координат планов и размеры изображения (17 тестов, сервисный уровень): consistency PATCH/PUT, best-effort размеры, композитные операции.
- Учёт стоимости и бюджет-гард (9 профильных тестов): раскладка usage по корзинам, cost-лог
/ask//act, изоляция сбоя лога, budget-guard.
С оговорками (реализовано и осмысленно, но покрыто частично):
- AI-агент LangGraph (18 тестов): ядро потоков и HITL (interrupt → resume) покрыты, но happy-path подтверждения с реальной записью (
Command(resume=True)→registry.call_tool) НЕ имеет интеграционного теста (покрыт только resume=False/отмена). - MCP-bridge и отбор тулов (11 тестов): работают на фейковых реестрах; сквозной act/confirm покрыт в отдельном тесте.
- AI-инбокс документов (11 тестов): преимущественно детерминированные хелперы; LLM-оркестрация и путь move+create не покрыты интеграционно.
- Генерация документов (3 теста): happy-path, изоляция сбоя, чтение реестра; ветки needs_input/error и реальный structured_output не покрыты.
- Расширенные атрибуты контрагентов (6 тестов): ORM round-trip + Pydantic + AI-контекст; HTTP-слой и
GET /members/briefне покрыты. - Фильтр даты отправки инвойсов (4 теста): покрыт; media-
category— без прямого теста фильтра.
Слабо покрыто / известные дыры:
- Command-routing (
/agent/command): точностьclassify_intentНЕ покрыта выделенными тестами — тестыrun_commandмокают_FakeIntentClientи проверяют только роутинг/учёт стоимости. ВеткиACT/ANALYTICS/HELP/DOCUMENT/CALLсвоих тестов не имеют. - Разбор звонков (weak, 3 теста): покрыт только путь
delete; ядро (analyze_call,ingest_transcript,save_call_record,list_call_records) НЕ покрыто. Аудио/STT для звонков не реализовано — только текст (комментарий-задел). - Vision на не-anthropic провайдерах: инбокс-классификация/vision привязаны к Claude; поведение на deepseek/ollama не гарантировано.
- Postgres-checkpointer (
AsyncPostgresSaver): без тестов, работает только в прод-env; TTL-чистка брошенных act-черновиков не реализована (заготовка-комментарий). - Реальный LLM нигде не тестируется — всё offline на фейк-моделях/графах.
Известные ограничения и что доделать
Приоритет 1 — корректность/безопасность бюджета и записи (собрано из cost-guard и agent):
- Cost-guard fail-open: сбой SUM-запроса проглатывается, запрос пропускается — на глюке БД лимит молча перестаёт действовать.
- Мягкий лимит (check-then-act): бюджет сверяется ДО вызова модели, стоимость пишется ПОСЛЕ — конкурентные запросы могут превысить месячный потолок. Лимит тенант-глобальный, без разбивки per-user/per-project/per-kind.
_enforce_budgetне применяется в/confirm(только_guard_api_key).- Happy-path подтверждения записи (act → confirm approve=True) без интеграционного теста.
- Command-routing применяет результат классификатора без валидации/фолбэка: ошибочный
ACTуводит в write-черновик, ошибочныйNavTarget— в навигацию по несуществующей сущности; на бэкенде не отсекается.
Приоритет 2 — незавершённая функциональность (заглушки/задел):
/commandintentDOCUMENTиCALL— заглушки (not_implemented=True, Фаза 2/4).- Аудио/STT для разбора звонков не реализовано (только текст).
- Генерация документов не сохраняет файл в S3 (только inline base64,
file_pathвсегда NULL);signatory_idв потоке не заполняется. - Postgres-checkpointer без TTL-чистки брошенных act-черновиков.
router/model_router.select_modelиrouter/classifier.classifyопределены, но нигде не вызываются (мёртвый смежный слой).
Приоритет 3 — целостность данных и валидация:
- Кросс-БД поля без FK:
manager_user_id(business_entities),counterparty_id(ai_call_records),contract_id(ai_generated_documents) — целостность на уровне БД не гарантируется;delete_call_recordнаходит запись по глобальному id без скоупа на counterparty. - Enum-поля хранятся строками без DB-ограничения (
operation String(20),authority_basis String(30),invoice_delivery_method String(10),category String(40)) — корректность держится только Pydantic на входе. compute_valueне проверяетperiod_start <= period_end; операнды не проверяются на принадлежность одному ресурсу (смешение ресурсов допускается).- Импорт выписки: CSV не детектится по сигнатуре (нужен явный
fmt); ключ синтетического дедупа commit (date+value+purpose) не совпадает с ключомpossible_duplicateматчера (date+value+payer_account); commit не фильтрует по confidence. - Инвойсы: некорректная ISO-строка
sent_to_email_time_*→ необработанныйValueError(нет try/except); часовой пояс не нормализуется; media-category=пустой строкой фильтрует поcategory == ''.
Приоритет 4 — покрытие тестами (перекрывается с разделом выше):
- Без выделенных тестов:
classify_intent, ядро разбора звонков, LLM-оркестрация инбокса, ветки генерации документов needs_input/error, HTTP-слой расширенных атрибутов контрагентов, фильтр media-category, флагhas_floor_plans, vision на не-anthropic провайдерах, postgres-checkpointer. estimate_usd— оценка, не факт биллинга; погрешность округления копится на каждомUsage.add(6 знаков); потолок одной записи ~999 999.999999 USD (Numeric(12,6)).- Инвариант «реквизиты доверенности только при POWER_OF_ATTORNEY» держится двумя независимыми реализациями (write-side normalize + read-side
_signatory); write-side отдельным тестом не покрыт. - Нет backfill
image_width/heightдля планов до миграции 0018 (NULL до перезагрузки файла);has_floor_plansучитывает только планы этажей. - Модульный docstring
agent.pyустарел (расходится с актуальными Pydantic-моделями/askdata[]и/confirm) — обновить документацию.