От монолита к автономным агентам (Часть 3): супервайзер LangGraph в контуре 1С с паузой на подтверждение человека
Серия «От монолита к автономным агентам», часть 3 из 4. Часть 1: архитектура и граф состояний ИИ-агента в 1С:Предприятие. Часть 2: почему линейный граф состояний не масштабируется.
В этой части собран рабочий стенд из трёх звеньев: общий модуль 1С, шлюз на FastAPI и граф LangGraph, который перед записью в базу останавливается и ждёт решения человека. Код стенда приложен к статье. Модуль 1С проверен встроенной проверкой Конфигуратора платформы 8.3.27, а весь путь от вызова из 1С до записи в карточку номенклатуры пройден на этой платформе через внешнее соединение.
Что здесь заглушка. Агенты Auditor, Inspector и Remediator возвращают фиксированные ответы, а роль модели играет детерминированный мок. Стенд показывает каркас: маршрутизацию, состояние, паузу interrupt(), возобновление и обмен с 1С. Разбор реальных логов и метаданных в него не входит. Чего ещё нет, перечислено в разделе «Что стенд не умеет».
🔍 Что получится и на чём проверено
К концу статьи у вас будет стенд, в котором регламентное задание 1С передаёт инцидент внешнему графу, не блокируя сеанс. Граф собирает исправление и останавливается на паузе. Пока человек не ответит, адаптер 1С отказывается что-либо записывать. После одобрения пакет применяется в транзакции, а реквизит СтавкаНДС у карточки номенклатуры заполняется.
Стенд собран и проверен на одной машине:
| Компонент |
Версия |
| ОС |
Windows 11 Pro 10.0.22631 |
| Платформа 1С |
8.3.27.2130 (x86), файловая база, внешнее соединение |
| Python |
3.12.10 |
| LangGraph |
1.2.12 (langchain-core 1.6.6) |
| FastAPI / Uvicorn / Pydantic |
0.141.1 / 0.54.0 / 2.13.5 |
| pytest |
9.1.1 |
Версии закреплены в requirements.txt. От пустой папки до паузы графа и одобрения в консольном клиенте прошло 100 и 117 секунд в двух прогонах (evidence/fresh_run.py): окружение, pip install без кэша (77 и 92 с), тесты, запуск шлюза. Три минуты в названии статьи - это эти замеры, округлённые вверх. Платформа 1С в них не участвует.
Проверено и не проверено:
| Что |
Статус |
| Три теста pytest: полный цикл с паузой, отказ оператора, шлюз с дедупликацией |
прошли |
Консольный эмулятор 1С (app.mock_1c_service) против запущенного шлюза |
отработал |
Модуль НОП_АгентИнтеграция: проверка модулей платформой 8.3.27 |
ошибок нет |
| Вызов из 1С через внешнее соединение, пауза, решение, запись в справочник |
пройден |
docker compose, образ из Dockerfile |
не запускал |
PostgresSaver вместо MemorySaver |
не запускал |
| Настоящая модель вместо мока |
не запускал |
| Тесты YAxUnit для модуля 1С |
не писал, фреймворк на стенде не стоял |
| Уведомление оператора в Telegram |
не реализовано |
🔍 Ретроспектива: что мы решили в частях 1 и 2
В первой части агент был линейной цепочкой: принять запрос из формы 1С, классифицировать, выполнить одним узлом и вернуть команду в 1С.
Во второй части мы разобрали, почему с ростом числа сценариев узел plan_executor превращается в узкое место. Ему нужны описания всех инструментов сразу, ошибка внутри узла обрывает всю цепочку, а цикл уточнения («сходи в 1С, проверь метаданные, если реквизита нет, возьми лог ошибки, потом предложи исправление») в линейной схеме не выразить.
Выход: разделить монолитный узел на независимых исполнителей и поставить над ними распорядителя, супервайзера. В этой части мы делаем это на LangGraph.
🔍 Архитектура стенда
Мы взяли LangGraph по трём причинам, которые проверили на стенде. Граф допускает циклы: исполнитель возвращает управление распорядителю. Переход задаётся кодом (Command(goto=...)), свободного диалога между моделями нет. И в одной библиотеке есть остановка графа interrupt() с сохранением состояния. С другими библиотеками для агентов мы этот стенд не сравнивали.
Вычисления модели вынесены за пределы серверов 1С: граф живёт отдельным процессом и общается с базой только по HTTP. Часть 1 называла это Zero-Footprint Core. Стенд состоит из трёх звеньев.
Рис. 1. Три звена стенда. Жёлтая плашка внизу перечисляет, что в стенде заглушка и чего нет.
Граф в стенде плоский: один узел-распорядитель и три исполнителя, подграфов нет. Иерархию из части 2 (подграфы для каждого исполнителя) стенд не строит.
| Узел |
Что должен делать |
Что делает в стенде |
Запись в 1С |
| Supervisor |
выбирает следующего исполнителя, решает, когда закончить |
мок по правилам: нет причины - Auditor, нет метаданных - Inspector, нет одобренного исправления - Remediator, иначе завершение; после отказа оператора завершение |
нет |
| Inspector |
читает метаданные: реквизиты, типы, допустимые значения |
возвращает строку про перечисление СтавкиНДС |
нет, только чтение |
| Auditor |
разбирает журнал регистрации и технологический журнал |
возвращает фиксированную причину: не заполнена СтавкаНДС |
нет, только чтение |
| Remediator |
формирует исправление |
возвращает фиксированный пакет и вызывает interrupt() |
только через подтверждение человека |
Рабочие инструменты этих узлов (чтение журнала регистрации, запрос метаданных, разбор техжурнала) в стенде не написаны. Это следующий шаг, и в части 4 он пойдёт через MCP.
🔍 Состояние графа и контракты Pydantic
Состояние графа - типизированный словарь TypedDict. Вложенные структуры описаны моделями Pydantic v2: контекст инцидента, шаг аудиторского следа, пакет исправления, ответ распорядителя.
Листинг 1. Состояние и контракты (app/state.py)
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
# -*- coding: utf-8 -*-
"""
Схема типизированного состояния мультиагентного графа NOPik.
Pydantic v2 контракты для сопряжения с платформой «1С:Предприятие 8.3».
"""
from enum import Enum
from typing import Annotated, Sequence, Optional, Dict, Any, List, Literal
from typing_extensions import TypedDict
from pydantic import BaseModel, Field
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class ExecutionSeverity(str, Enum):
INFO = "INFO"
WARNING = "WARNING"
CRITICAL = "CRITICAL"
class OneCContext(BaseModel):
"""Контекст исполнения на стороне корпоративного контура 1С."""
infobase_id: str = Field(description="Уникальный идентификатор информационной базы 1С")
task_guid: str = Field(description="GUID задачи БСП / Длительной операции")
user_name: str = Field(default="RobotAgent", description="Пользователь, инициировавший операцию")
idempotency_key: str = Field(description="Токен защиты от повторного выполнения (X-Idempotency-Key)")
subsystem: str = Field(default="ИнтеграцияМаркетплейсы", description="Имя подсистемы 1С")
error_payload: Optional[str] = Field(default=None, description="Сырой текст ошибки из журнала регистрации")
class DecisionStep(BaseModel):
"""Шаг сквозного аудиторского следа (Audit Trail)."""
agent_name: str
action_taken: str
rationale: str
timestamp_utc: str
tool_inputs: Optional[Dict[str, Any]] = None
tool_output: Optional[Dict[str, Any]] = None
class RemediationAction(BaseModel):
"""Пакет корректирующих действий для контура 1С."""
action_type: Literal["SET_FIELD_VALUE", "RERUN_BACKGROUND_JOB", "NOTIFY_OPERATOR", "APPLY_HOTPATCH"] = "SET_FIELD_VALUE"
target_object_meta: str = Field(description="Имя объекта метаданных 1С (например, Справочник.Номенклатура)")
target_object_guid: Optional[str] = Field(None, description="Строковый GUID ссылки объекта в 1С")
parameters: Dict[str, Any] = Field(default_factory=dict, description="Параметры операции для 1С")
requires_human_approval: bool = Field(True, description="Флаг обязательного ручного подтверждения (HITL)")
is_approved: bool = Field(False, description="Флаг фактического одобрения оператором")
class SupervisorOutput(BaseModel):
"""Строго типизированный вывод супервайзера для роутинга."""
next_step: Literal["agent_inspector", "agent_auditor", "agent_remediator", "FINISH"] = Field(
description="Следующий узел для маршрутизации или завершение графа"
)
routing_reason: str = Field(description="Обоснование решения для логов и телеметрии")
requires_interrupt: bool = Field(default=False, description="Нужно ли прервать граф для подтверждения человеком")
class NopikAgentState(TypedDict):
"""Общее состояние мультиагентного графа NOPik."""
# Сообщения диалога с поддержкой append-only слияния
messages: Annotated[Sequence[BaseMessage], add_messages]
# Корпоративный контекст транзакции 1С
enterprise_context: OneCContext
# Текущий назначенный узел
next: str
# Диагностические факты
identified_error_signature: Optional[str]
root_cause_analysis: Optional[str]
# Сформированный пакет исправлений
remediation: Optional[RemediationAction]
# Статус подтверждения человеком (HITL)
is_approved_by_human: bool
# Решение оператора: None пока не спрашивали, "approved" или "rejected"
operator_decision: Optional[str]
# Сквозной аудит действий для SRE
audit_trail: List[DecisionStep]
|
Две детали, которые пригодятся при доработке. Поле operator_decision появилось в стенде после проверки: без него граф после отказа оператора возвращался к Remediator и спрашивал заново. Поле messages накапливает сообщения по правилу add_messages.
🔍 Шлюз FastAPI: асинхронный протокол 1С и графа
Синхронный вызов модели из пользовательского сеанса или из транзакции проведения занимает серверный вызов на всё время ожидания ответа. Поэтому 1С только отправляет инцидент и получает в ответ 202 Accepted. Дальше граф работает в фоне шлюза, а 1С опрашивает статус. На стенде вызов из 1С вернул управление за 35 и 74 миллисекунды в двух сохранённых прогонах (замер ниже).
Шлюз принимает инцидент, регистрирует его и запускает граф в фоне:
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
async def run_graph_task(task_guid: str, initial_state: NopikAgentState):
"""Асинхронный исполнитель графа LangGraph."""
config = {
"configurable": {"thread_id": task_guid},
"recursion_limit": settings.recursion_limit
}
TASKS_DB[task_guid]["status"] = "RUNNING"
TASKS_DB[task_guid]["updated_at"] = datetime.now(timezone.utc).isoformat()
try:
# Запуск до первой точки останова (interrupt) или до END
await compiled_app.ainvoke(initial_state, config=config)
# Проверяем, остановился ли граф на interrupt()
state_snapshot = await compiled_app.aget_state(config)
if state_snapshot.next and len(state_snapshot.next) > 0:
# Граф находится в точке прерывания
interrupt_val = state_snapshot.tasks[0].interrupts[0].value if state_snapshot.tasks and state_snapshot.tasks[0].interrupts else {}
TASKS_DB[task_guid]["status"] = "INTERRUPTED_AWAITING_APPROVAL"
TASKS_DB[task_guid]["interrupt_data"] = interrupt_val
TASKS_DB[task_guid]["audit_trail"] = state_snapshot.values.get("audit_trail", [])
else:
# Граф завершился (END)
TASKS_DB[task_guid]["status"] = "COMPLETED"
TASKS_DB[task_guid]["remediation"] = state_snapshot.values.get("remediation")
TASKS_DB[task_guid]["audit_trail"] = state_snapshot.values.get("audit_trail", [])
except Exception as e:
TASKS_DB[task_guid]["status"] = "FAILED"
TASKS_DB[task_guid]["error"] = str(e)
finally:
TASKS_DB[task_guid]["updated_at"] = datetime.now(timezone.utc).isoformat()
|
Ключ идемпотентности нужен, потому что клиент, не получивший ответ, повторит запрос и не узнает, дошёл ли первый. Шлюз хранит ключи в словаре и на повтор возвращает уже созданную задачу:
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
|
idemp_key = x_idempotency_key or req.idempotency_key
# 1. Проверка идемпотентности
if idemp_key in IDEMPOTENCY_STORE:
existing_task_guid = IDEMPOTENCY_STORE[idemp_key]
return {
"status": "DUPLICATE_ACCEPTED",
"task_guid": existing_task_guid,
"message": "Задача с данным ключом идемпотентности уже обрабатывается"
}
IDEMPOTENCY_STORE[idemp_key] = req.task_guid
|
В стенде этот словарь лежит в памяти процесса. Для рабочей базы нужна таблица с индексом по ключу, где вторая вставка с тем же ключом отклоняется самой базой. Такой таблицы в стенде нет.
Шлюз выставляет четыре метода. Интерфейс OpenAPI, который FastAPI строит сам, показывает их так:
Рис. 2. Снимок http://127.0.0.1:8000/docs запущенного стенда.
Обработчик run_graph_task объявлен async и вызывает ainvoke. В исходной редакции стенда там стоял синхронный invoke: внутри async-функции он занимает цикл событий FastAPI на всё время работы графа, и при работе с моделью шлюз не отвечал бы на опросы. Узлы графа у нас синхронные; ainvoke их выполняет, и тесты с прогоном это подтверждают.
🔍 Супервайзер и исполнители на LangGraph
Распорядитель выбирает следующий шаг. Когда включена модель (USE_MOCK_LLM=false, заданы ключ и имя модели), решает она со структурным ответом SupervisorOutput. Иначе работает мок с теми же правилами маршрутизации. С реальной моделью стенд не запускался, поэтому все прогоны в статье идут через мок.
Листинг 2. Распорядитель (app/graph.py)
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
def mock_supervisor_reasoning(state: NopikAgentState) -> SupervisorOutput:
"""Детерминированная модель рассуждений для демо-режима и тестов."""
if state.get("operator_decision") == "rejected":
return SupervisorOutput(
next_step="FINISH",
routing_reason="Оператор отклонил исправление. Завершаем граф без изменений в 1С."
)
if not state.get("root_cause_analysis"):
return SupervisorOutput(
next_step="agent_auditor",
routing_reason="Первопричина сбоя неизвестна. Направляем задачу агенту аудита логов 1С."
)
# Если аудитор отработал, но метаданные еще не сверены
audit_history = [s.agent_name for s in state.get("audit_trail", [])]
if "Inspector" not in audit_history:
return SupervisorOutput(
next_step="agent_inspector",
routing_reason="Локализована ошибка в поле СтавкаНДС. Направляем инспектору для проверки перечисления 1С."
)
# Если первопричина ясна и метаданные проверены, но патча еще нет
if not state.get("remediation") or not state.get("is_approved_by_human"):
return SupervisorOutput(
next_step="agent_remediator",
routing_reason="Факты собраны. Направляем задачу агенту исправления для синтеза патча и запроса HITL."
)
return SupervisorOutput(
next_step="FINISH",
routing_reason="Пакет исправлений успешно сформирован и подтвержден оператором. Завершение графа."
)
def supervisor_node(state: NopikAgentState) -> Command[Literal["agent_inspector", "agent_auditor", "agent_remediator", "__end__"]]:
"""Узел супервайзера: оценивает контекст и перенаправляет задачу."""
llm = get_llm()
if llm:
supervisor_structured = llm.with_structured_output(SupervisorOutput)
prompt = [
SystemMessage(content=SUPERVISOR_SYSTEM_PROMPT),
HumanMessage(content=f"Контекст 1С: {state['enterprise_context'].model_dump_json()}\n"
f"Сигнатура: {state.get('identified_error_signature')}\n"
f"Анализ: {state.get('root_cause_analysis')}\n"
f"Патч: {state.get('remediation')}\n"
f"Решение оператора: {state.get('operator_decision')}")
] + list(state.get("messages", []))
decision = supervisor_structured.invoke(prompt)
else:
decision = mock_supervisor_reasoning(state)
audit_entry = DecisionStep(
agent_name="Supervisor",
action_taken=f"Routing to: {decision.next_step}",
rationale=decision.routing_reason,
timestamp_utc=datetime.now(timezone.utc).isoformat()
)
current_trail = list(state.get("audit_trail", [])) + [audit_entry]
if decision.next_step == "FINISH":
return Command(
update={
"next": "FINISH",
"audit_trail": current_trail
},
goto=END
)
return Command(
update={
"next": decision.next_step,
"audit_trail": current_trail
},
goto=decision.next_step
)
|
Возвращая Command, узел сам говорит, куда идти дальше и что записать в состояние. Ребро в графе объявлено только одно, от START к распорядителю, остальные переходы задают команды.
Исполнители в стенде выглядят так. Auditor целиком:
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
def agent_auditor_node(state: NopikAgentState) -> Command[Literal["supervisor"]]:
"""Субагент аудита журнала регистрации и технологического журнала 1С."""
context = state["enterprise_context"]
error_raw = context.error_payload or "HTTP 422: field 'vat_rate' is required for category 'Electronics'"
detected_signature = "Поле не заполнено: СтавкаНДС при выгрузке в Wildberries API"
root_cause = "В карточке номенклатуры не указана ставка НДС, модуль обмена падает в исключение"
audit_entry = DecisionStep(
agent_name="Auditor",
action_taken="Inspect 1C Event Log & Exception Trace",
rationale=f"Анализ ошибки '{error_raw}'. Локализована незаполненность реквизита СтавкаНДС.",
timestamp_utc=datetime.now(timezone.utc).isoformat(),
tool_output={"detected_signature": detected_signature, "root_cause": root_cause}
)
msg = AIMessage(content=f"[Auditor]: Установлена первопричина: {root_cause}")
return Command(
update={
"messages": [msg],
"identified_error_signature": detected_signature,
"root_cause_analysis": root_cause,
"audit_trail": list(state.get("audit_trail", [])) + [audit_entry]
},
goto="supervisor"
)
|
Это заглушка: сигнатура и причина записаны в коде, а error_raw попадает только в текст обоснования. Чтение журнала регистрации на месте этой заглушки - задача, которую в стенде никто не решал.
🔍 Пауза на подтверждение: interrupt() и resume
Правило стенда: агенты свободно читают и никогда не пишут. Запись в 1С проходит через решение человека.
Remediator формирует пакет исправления и вызывает interrupt(). Граф останавливается, состояние сохраняется в чекпоинтере, а шлюз выставляет задаче статус INTERRUPTED_AWAITING_APPROVAL вместе с данными паузы (interrupt_data). Решение приходит запросом POST /tasks/{id}/resume и превращается в Command(resume=...).
Листинг 3. Исполнитель с паузой и сборка графа (app/graph.py)
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
def agent_remediator_node(state: NopikAgentState) -> Command[Literal["supervisor"]]:
"""Субагент исправления с точкой прерывания Human-in-the-Loop (interrupt)."""
remediation_proposal = RemediationAction(
action_type="SET_FIELD_VALUE",
target_object_meta="Справочник.Номенклатура",
target_object_guid="d0e6c4e0-5b58-11ef-93a8-005056b23d99",
parameters={
"Field": "СтавкаНДС",
"Value": "НДС20",
"Reason": "Автоматическое заполнение ставки НДС по умолчанию для электроники"
},
requires_human_approval=True,
is_approved=False
)
audit_entry = DecisionStep(
agent_name="Remediator",
action_taken="Prepare Remediation Action",
rationale="Сформирован пакет обновления реквизита карточки номенклатуры в 1С.",
timestamp_utc=datetime.now(timezone.utc).isoformat(),
tool_output=remediation_proposal.model_dump()
)
# КРИТИЧЕСКАЯ ТОЧКА: Прерывание графа для подтверждения человеком
operator_response = interrupt({
"question": "Требуется подтверждение применения изменений в 1С",
"action": remediation_proposal.model_dump(),
"severity": ExecutionSeverity.WARNING
})
# Обработка ответа после возобновления Command(resume=...)
is_approved = bool(operator_response.get("approved", False)) if isinstance(operator_response, dict) else False
if not is_approved:
reject_reason = operator_response.get("reason", "Отклонено оператором") if isinstance(operator_response, dict) else "Отклонено"
msg = AIMessage(content=f"[Remediator]: Оператор отклонил план: {reject_reason}")
return Command(
update={
"messages": [msg],
"is_approved_by_human": False,
"operator_decision": "rejected",
"audit_trail": list(state.get("audit_trail", [])) + [audit_entry]
},
goto="supervisor"
)
remediation_proposal.is_approved = True
msg = AIMessage(content="[Remediator]: Оператор одобрил исправление. Пакет готов к передаче в 1С.")
return Command(
update={
"messages": [msg],
"remediation": remediation_proposal,
"is_approved_by_human": True,
"operator_decision": "approved",
"audit_trail": list(state.get("audit_trail", [])) + [audit_entry]
},
goto="supervisor"
)
def create_nopik_workflow() -> StateGraph:
"""Создает граф оркестратора NOPik."""
workflow = StateGraph(NopikAgentState)
workflow.add_node("supervisor", supervisor_node)
workflow.add_node("agent_auditor", agent_auditor_node)
workflow.add_node("agent_inspector", agent_inspector_node)
workflow.add_node("agent_remediator", agent_remediator_node)
workflow.add_edge(START, "supervisor")
return workflow
|
Рис. 3. Путь одного инцидента. Числа на стрелках - порядок шагов, статусы взяты из ответов шлюза.
Три свойства interrupt() мы проверили отдельным скриптом (evidence/probe_langgraph.py), потому что от них зависит устройство узла:
1. recursion_limit=3: GraphRecursionError
2. создание RemediationAction в узле: до паузы 1, после resume 2
3. без чекпоинтера: исключения нет; ключи результата: ['__interrupt__', 'audit_trail', 'enterprise_context', 'identified_error_signature', 'is_approved_by_human', 'messages', 'next', 'remediation', 'root_cause_analysis'] | есть __interrupt__: True
get_state без чекпоинтера: ValueError No checkpointer set
resume без чекпоинтера: RuntimeError Cannot use Command(resume=...) without checkpointer
4. полный проход (пауза и одобрение) укладывается в recursion_limit=6
- Узел выполняется с начала. После
resume тело Remediator запускается заново: объект RemediationAction создан дважды, до паузы и после. Всё, что стоит в узле перед interrupt(), должно быть безопасно повторять. Запись в базу или отправка сообщения там недопустимы.
- Без чекпоинтера пауза не работает.
invoke возвращает результат с ключом __interrupt__, но get_state падает с ValueError, а resume с RuntimeError.
- Цикл ограничивает
recursion_limit. При лимите 3 граф падает с GraphRecursionError. Полный проход с паузой и одобрением укладывается в 6, в стенде стоит 15.
Кто нажимает «Одобрить». У шлюза стенда нет авторизации. Любой, кто достучится до /resume, сыграет роль человека. Пауза защищает от автономных действий агента, а от злоумышленника в сети защиты нет: для рабочей базы шлюз закрывают аутентификацией и TLS, а решение привязывают к пользователю.
🔍 Адаптер в 1С
Общий модуль НОП_АгентИнтеграция (серверный, с флагами «Вызов сервера» и «Внешнее соединение») делает четыре вещи: запускает задачу, опрашивает статус, передаёт решение оператора и применяет одобренный пакет.
Листинг 4. Общий модуль НОП_АгентИнтеграция
BSL UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
|
// Общий модуль: НОП_АгентИнтеграция
// Назначение: Серверный адаптер взаимодействия с LangGraph Supervisor Gateway
// Свойства модуля: Сервер, Внешнее соединение, Вызов сервера (ServerCall = True)
#Область ПрограммныйИнтерфейс
// Запускает анализ и исправление инцидента через внешний супервайзер LangGraph
//
// Параметры:
// КонтекстИнцидента - Структура:
// * ИдентификаторБазы - Строка - Уникальное имя базы в кластере
// * СигнатураОшибки - Строка - Текст ошибки или сырой лог сбоя
// * МетаданныеОбъекта - Строка - Имя метаданных (например, Справочник.Номенклатура)
// * СсылкаОбъекта - ЛюбаяСсылка - Ссылка на сбойный объект в 1С
// КлючИдемпотентности - Строка - Необязательный. Повторный вызов с тем же ключом не запускает вторую задачу;
// для повторов из своего кода ключ строят из инцидента (база, объект, ошибка) и передают сюда
//
// Возвращаемое значение:
// Строка - GUID запущенной задачи (Task GUID)
//
Функция ЗапуститьАнализИнцидента(КонтекстИнцидента, КлючИдемпотентности = Неопределено) Экспорт
Если КлючИдемпотентности = Неопределено Тогда
КлючИдемпотентности = Строка(Новый УникальныйИдентификатор);
КонецЕсли;
СтруктураЗапроса = Новый Структура;
СтруктураЗапроса.Вставить("infobase_id", КонтекстИнцидента.ИдентификаторБазы);
СтруктураЗапроса.Вставить("task_guid", КлючИдемпотентности);
СтруктураЗапроса.Вставить("user_name", ПользователиИнформационнойБазы.ТекущийПользователь().Имя);
СтруктураЗапроса.Вставить("idempotency_key", КлючИдемпотентности);
СтруктураЗапроса.Вставить("subsystem", "ИнтеграцияМаркетплейсы");
СтруктураЗапроса.Вставить("error_payload", КонтекстИнцидента.СигнатураОшибки);
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, СтруктураЗапроса);
ТелоJSON = ЗаписьJSON.Закрыть();
// Получаем безопасное соединение
HTTPСоединение = ПолучитьСоединениеСоШлюзом();
HTTPЗапрос = Новый HTTPЗапрос("/api/v1/supervisor/tasks");
HTTPЗапрос.Заголовки.Вставить("Content-Type", "application/json");
HTTPЗапрос.Заголовки.Вставить("X-Idempotency-Key", КлючИдемпотентности);
HTTPЗапрос.УстановитьТелоИзСтроки(ТелоJSON, КодировкаТекста.UTF8);
Попытка
HTTPОтвет = HTTPСоединение.ОтправитьДляОбработки(HTTPЗапрос);
Если HTTPОтвет.КодСостояния <> 202 Тогда
ВызватьИсключение СтрШаблон("Ошибка запуска супервайзера: HTTP %1 - %2",
HTTPОтвет.КодСостояния, HTTPОтвет.ПолучитьТелоКакСтроку());
КонецЕсли;
Исключение
ЗаписьЖурналаРегистрации("NOPik.Supervisor", УровеньЖурналаРегистрации.Ошибка,,,
ПодробноеПредставлениеОшибки(ИнформацияОбОшибке()));
ВызватьИсключение;
КонецПопытки;
Возврат КлючИдемпотентности;
КонецФункции
// Опрашивает шлюз о текущем статусе исполнения задачи (Polling)
//
// Параметры:
// TaskGUID - Строка - Идентификатор задачи
//
// Возвращаемое значение:
// Структура - Текущее состояние задачи, статус, пакет исправлений
//
Функция ПолучитьСтатусЗадачи(TaskGUID) Экспорт
HTTPСоединение = ПолучитьСоединениеСоШлюзом();
HTTPЗапрос = Новый HTTPЗапрос(СтрШаблон("/api/v1/supervisor/tasks/%1", TaskGUID));
HTTPОтвет = HTTPСоединение.Получить(HTTPЗапрос);
Если HTTPОтвет.КодСостояния <> 200 Тогда
ВызватьИсключение СтрШаблон("Ошибка опроса статуса: HTTP %1", HTTPОтвет.КодСостояния);
КонецЕсли;
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(HTTPОтвет.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
ЧтениеJSON.Закрыть();
Возврат Результат;
КонецФункции
// Передаёт решение оператора шлюзу: граф LangGraph продолжит работу с точки interrupt()
//
// Параметры:
// TaskGUID - Строка - Идентификатор задачи
// Одобрено - Булево - Решение оператора
// Причина - Строка - Комментарий оператора
//
Процедура ОтправитьРешениеОператора(TaskGUID, Одобрено, Причина = "") Экспорт
Тело = Новый Структура("approved, reason", Одобрено, Причина);
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Тело);
ТелоJSON = ЗаписьJSON.Закрыть();
HTTPЗапрос = Новый HTTPЗапрос(СтрШаблон("/api/v1/supervisor/tasks/%1/resume", TaskGUID));
HTTPЗапрос.Заголовки.Вставить("Content-Type", "application/json");
HTTPЗапрос.УстановитьТелоИзСтроки(ТелоJSON, КодировкаТекста.UTF8);
HTTPОтвет = ПолучитьСоединениеСоШлюзом().ОтправитьДляОбработки(HTTPЗапрос);
Если HTTPОтвет.КодСостояния <> 200 Тогда
ВызватьИсключение СтрШаблон("Ошибка передачи решения: HTTP %1", HTTPОтвет.КодСостояния);
КонецЕсли;
КонецПроцедуры
// Применяет согласованный пакет исправлений в локальной базе 1С
// с гарантией транзакционной целостности и аудита
//
// Параметры:
// ПакетИсправлений - Структура - Десериализованный JSON плана исправления
//
Процедура ПрименитьПакетИсправлений(ПакетИсправлений) Экспорт
// Проверка защитного барьера Human-in-the-Loop
Если ПакетИсправлений.requires_human_approval И Не ПакетИсправлений.is_approved Тогда
ВызватьИсключение "Попытка несанкционированного применения изменений без подтверждения HITL!";
КонецЕсли;
НачатьТранзакцию();
Попытка
Если ПакетИсправлений.action_type = "SET_FIELD_VALUE" Тогда
Ссылка = ПолучитьСсылкуПоGUID(ПакетИсправлений.target_object_meta, ПакетИсправлений.target_object_guid);
Объект = Ссылка.ПолучитьОбъект();
Поле = ПакетИсправлений.parameters.Field;
Значение = ПреобразоватьСтрокуВТип1С(ПакетИсправлений.parameters.Value, ПакетИсправлений.target_object_meta, Поле);
Объект[Поле] = Значение;
Объект.Записать();
ЗаписьЖурналаРегистрации("NOPik.Remediation", УровеньЖурналаРегистрации.Информация,,,
СтрШаблон("Агент NOPik успешно обновил реквизит %1 у объекта %2", Поле, Ссылка));
Иначе
ВызватьИсключение "Неподдерживаемый тип действия: " + ПакетИсправлений.action_type;
КонецЕсли;
ЗафиксироватьТранзакцию();
Исключение
ОтменитьТранзакцию();
ЗаписьЖурналаРегистрации("NOPik.Remediation", УровеньЖурналаРегистрации.Ошибка,,,
ПодробноеПредставлениеОшибки(ИнформацияОбОшибке()));
ВызватьИсключение;
КонецПопытки;
КонецПроцедуры
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
Функция ПолучитьСоединениеСоШлюзом()
// Параметры стенда. В рабочей базе - константа или регистр сведений, а перед шлюзом - обратный прокси с TLS
Хост = "127.0.0.1";
Порт = 8000;
Таймаут = 15;
Возврат Новый HTTPСоединение(Хост, Порт,,,, Таймаут);
КонецФункции
Функция ПолучитьСсылкуПоGUID(ИмяМетаданных, GUIDСтрока)
УИД = Новый УникальныйИдентификатор(GUIDСтрока);
Части = СтрРазделить(ИмяМетаданных, ".");
Если Части[0] <> "Справочник" Тогда
ВызватьИсключение "Стенд поддерживает только справочники: " + ИмяМетаданных;
КонецЕсли;
Возврат Справочники[Части[1]].ПолучитьСсылку(УИД);
КонецФункции
Функция ПреобразоватьСтрокуВТип1С(ЗначениеСтрока, ИмяМетаданных, ИмяРеквизита)
Если ИмяРеквизита = "СтавкаНДС" Тогда
Возврат Перечисления.СтавкиНДС[ЗначениеСтрока];
КонецЕсли;
Возврат ЗначениеСтрока;
КонецФункции
#КонецОбласти
|
Барьер в ПрименитьПакетИсправлений работает на стороне 1С и не зависит от шлюза: пакет без is_approved не применится, даже если шлюз отдаст его без одобрения.
Модуль я прогнал через проверку модулей Конфигуратора платформы 8.3.27.2130 (/CheckModules для тонкого клиента, сервера и внешнего соединения). Исходная редакция модуля не прошла её:
{Общий модуль.NOP_AgentIntegration.Модуль(146,12)}: Ожидается выражение
Возврат<<?>> [ТипОбъекта][ИмяОбъекта].ПолучитьСсылку(УИД);
Конструкции [ТипОбъекта][ИмяОбъекта] в языке нет. Функция ПолучитьСсылкуПоGUID теперь обращается к менеджеру справочников: Справочники[Части[1]]. Стенд поддерживает только справочники и говорит об этом исключением.
Исправлено ещё три места:
| Было |
Стало |
соединение через ЗащищенноеСоединениеOpenSSL к шлюзу, который слушает обычный HTTP |
соединение без TLS для стенда; для рабочей базы TLS ставят перед шлюзом |
условие барьера Не requires_human_approval Или Не is_approved бросало исключение при любом пакете, где подтверждение не требуется |
requires_human_approval И Не is_approved |
| ключ идемпотентности всегда новый: повтор из своего кода запускал вторую задачу |
необязательный параметр КлючИдемпотентности; для повторов ключ строят из инцидента |
Адаптер вызывается напрямую. В базе на БСП вызов оформляют длительной операцией (ДлительныеОперации), чтобы не держать сеанс. Этот вариант в стенде не проверялся.
🔍 Сквозной прогон: 1С, шлюз, граф, запись в карточку
Проверочная база создана скриптом evidence/1c-check/build_ib.py: справочник Номенклатура с реквизитом СтавкаНДС, перечисление СтавкиНДС, модуль адаптера. Скрипт e2e.ps1 подключается к ней через внешнее соединение и проходит весь путь при запущенном шлюзе. В стенде Remediator всегда указывает один и тот же GUID карточки, поэтому скрипт заводит карточку Арт-10492 с этим GUID и пустой ставкой.
карточка Арт-10492 создана, ставка НДС: не заполнена
t=35 мс: задача запущена (c24f2a15-545a-4182-8532-6f1501a9b357)
t=47 мс: опрос 1 -> INTERRUPTED_AWAITING_APPROVAL
применение без подтверждения оператора: Попытка несанкционированного применения изменений без подтверждения HITL!
t=73 мс: решение оператора отправлено
t=79 мс: опрос 1 -> RESUMING
t=193 мс: опрос 2 -> COMPLETED
применение пакета: []
t=219 мс: пакет применён в транзакции
ставка НДС в карточке после применения: НДС20
два вызова с одним ключом вернули один идентификатор: True (dup-key-1)
Построчно:
- карточка создана без ставки;
- задача запущена, граф дошёл до паузы (
INTERRUPTED_AWAITING_APPROVAL) с первого опроса;
- попытка применить пакет без решения оператора отклонена барьером;
- после
ОтправитьРешениеОператора шлюз ещё держит статус RESUMING, пока граф доходит до конца, следующий опрос видит COMPLETED; пакет применён в транзакции, у карточки стоит НДС20;
- два вызова с одним ключом вернули один идентификатор (то, что шлюз отвечает на такой повтор как на дубль, проверяет тест шлюза).
Рис. 4. Время от начала прогона до каждого события.
В двух сохранённых прогонах (e2e-run1.txt, e2e-run2.txt) весь путь занял 307 и 219 миллисекунд, в четырёх более ранних - от 114 до 197 (их вывод не сохранён). Это накладные расходы стенда: заглушки отвечают сразу, а время человека и модели в замер не входит. Сколько будет длиться разбор с моделью и сколько человек будет решать, стенд не показывает.
Сценарий отказа проверен тестом. Оператор отвечает approved: false, граф завершается, remediation остаётся пустым, повторного вопроса нет.
🔍 Тесты
Тестов три, обходятся без 1С и сети:
test_supervisor_full_cycle_with_hitl - полный цикл графа: пауза, Command(resume=...), завершение, проверка аудиторского следа;
test_remediation_rejected_by_operator - отказ: граф завершается, пакета нет;
test_api_end_to_end_gateway_flow - шлюз целиком: 202, повтор с тем же ключом возвращает DUPLICATE_ACCEPTED, пауза, resume, статус COMPLETED.
Тест отказа из стенда (после исправления он проверяет, что граф не возвращается к вопросу):
Python UTF-8 Открыть файл
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
def test_remediation_rejected_by_operator(initial_test_state):
"""Проверка ветки, когда оператор отклоняет исправление."""
workflow = create_nopik_workflow()
checkpointer = MemorySaver()
app = workflow.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "test-thread-rejected"}}
# Доходим до прерывания
app.invoke(initial_test_state, config=config)
# Оператор отклоняет
app.invoke(
Command(resume={"approved": False, "reason": "Неверная карточка"}),
config=config
)
state_final = app.get_state(config)
assert state_final.values["is_approved_by_human"] is False
assert state_final.values["remediation"] is None
assert len(state_final.next) == 0, "После отказа граф завершается и не спрашивает оператора повторно"
|
tests/test_api_gateway.py::test_api_end_to_end_gateway_flow PASSED [ 33%]
tests/test_supervisor.py::test_supervisor_full_cycle_with_hitl PASSED [ 66%]
tests/test_supervisor.py::test_remediation_rejected_by_operator PASSED [100%]
============================== 3 passed in 1.42s ==============================
Первая версия этого теста проверяла только is_approved_by_human is False и проходила, хотя граф после отказа снова вставал на паузу. Проверка not state_final.next эту ошибку ловит.
Тестов для модуля 1С в статье нет. Их удобно писать на YAxUnit, но фреймворк на стенде не стоял, и выдавать непроверенный набор за тесты я не стал. Проверка адаптера на платформе - скрипт e2e.ps1 выше.
🔍 Что стенд не умеет
- Рабочие инструменты агентов. Чтение журнала регистрации и техжурнала, запрос метаданных, разбор блокировок: везде заглушки.
- Модель вместо мока. Ветка с
ChatOpenAI и with_structured_output написана, но не запускалась.
- Постоянный чекпоинтер.
MemorySaver теряет состояние вместе с процессом, зависшие паузы пропадут при перезапуске. Для рабочей базы нужен постоянный чекпоинтер, например PostgresSaver из пакета langgraph-checkpoint-postgres. В стенде его нет, docker-compose.yml PostgreSQL не поднимает.
- Идемпотентность в базе. Ключи лежат в словаре в памяти.
- Защита шлюза. Нет аутентификации и TLS,
/resume открыт всем.
- Уведомление оператора. Стенд не отправляет карточку в Telegram или на форму 1С, оператор сам читает
interrupt_data из статуса.
- Ограничение нагрузки на шлюз. Circuit breaker не реализован.
- Опрос из БСП. Адаптер вызывается напрямую, длительные операции не подключены.
🔍 Как повторить
Стенд (Python 3.12, около трёх минут вместе с установкой):
Bash UTF-8 CLI
1
2
3
4
5
|
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
pytest tests -v
uvicorn app.main:app --port 8000
|
В соседнем терминале консольный эмулятор 1С пройдёт тот же путь с паузой и одобрением:
Bash UTF-8 CLI
1
|
python -m app.mock_1c_service
|
Проверка на платформе 1С: python evidence/1c-check/build_ib.py stand/1c/NOP_AgentIntegration.bsl создаёт файловую базу и проверяет модули, затем e2e.ps1 запускается 32-битным PowerShell (платформа x86) при работающем шлюзе.
Если что-то не заводится, напишите в комментариях версию Python и платформы и на каком шаге остановились.
🔍 Итоги и что дальше
Стенд показывает три вещи: 1С не ждёт ответа графа, запись в базу отделена от рассуждений агента паузой, а барьер в модуле 1С работает независимо от шлюза. На пути к рабочей базе остаются рабочие инструменты агентов, постоянный чекпоинтер и защита шлюза.
В части 4 мы заменим заглушки инструментами: агент сам обратится к метаданным и коду 1С через Model Context Protocol. Какая часть задуманного заработает, покажем замером, как здесь.
Вопросы к вам: где в вашей базе граница между «агент читает» и «агент пишет»? Какие действия вы бы поставили за подтверждение в первую очередь?