feat(sprint6b-D): soft-insertion counter + message meta_json

- thread_state.soft_insertion_count: растёт при боковом ответе (soft_insertion=true
  в STATE_JSON без смены шага/слотов), сбрасывается при продвижении или handoff
- При soft_insertion_count >= 3 в системный промпт ветки добавляется SOFT_INSERTION_NUDGE
  — явная инструкция вернуть пациента к вопросу текущего шага
- state_machine.parse_branch_response читает флаг soft_insertion из STATE_JSON
- Новая колонка message.meta_json: {router_intent_code, served_intent_code, step_code, events}
  — хранит снимок маршрутизации каждой реплики ассистента
- «Песочница»: бейджи событий (sticky / soft_insertion / hard_handoff / resumed /
  routing_loop / validation_blocked) над каждым ответом ассистента

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-25 20:24:22 +05:00
parent 3c71372ec8
commit 85c3ec0222
12 changed files with 257 additions and 13 deletions
+72 -2
View File
@@ -18,11 +18,18 @@ HISTORY_LIMIT = 20
FALLBACK_INTENT_CODE = "general_info"
ESCALATE_INTENT_CODE = "escalate_human"
MAX_BOUNCES = 1
HANDOFF_CAP = 3 # столько hard-handoff'ов разрешено за диалог; четвёртое — авто-эскалация
HANDOFF_CAP = 3 # столько hard-handoff'ов разрешено за диалог; четвёртое — авто-перевод
SOFT_INSERTION_CAP = 3 # столько «боковых вопросов» подряд терпим, потом возвращаем к шагу
ROUTING_LOOP_REPLY = (
"Уточню детали с администратором клиники, свяжемся с вами "
"в течение ближайшего часа."
)
SOFT_INSERTION_NUDGE = (
"[ВОЗВРАТ К СЦЕНАРИЮ]\n"
"Пациент уже несколько реплик подряд задаёт боковые вопросы, не двигая сценарий. "
"На этой реплике уверенно верни его к вопросу текущего шага одной короткой фразой; "
"не давай развернутого ответа на стороннюю тему."
)
def _auto_thread_name(first_user_text: str) -> str:
@@ -52,6 +59,7 @@ def _format_state_context(
snapshot: dict,
current_step: IntentStep | None,
router_hint: str | None = None,
soft_nudge: bool = False,
) -> str:
"""Блок с текущим состоянием треда для дописывания в системный промпт."""
slots = snapshot.get("slots", {}) or {}
@@ -68,6 +76,9 @@ def _format_state_context(
lines.append("")
lines.append("[ПОДСКАЗКА РОУТЕРА]")
lines.append(router_hint)
if soft_nudge:
lines.append("")
lines.append(SOFT_INSERTION_NUDGE)
return "\n" + "\n".join(lines)
@@ -153,6 +164,7 @@ async def send_message(
snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = snapshot["current_intent_code"]
handoff_count = snapshot.get("handoff_count", 0)
soft_insertion_count = snapshot.get("soft_insertion_count", 0)
suspended_intent = snapshot.get("suspended_intent")
resumable_step_code = snapshot.get("resumable_step_code")
resumable_slots = snapshot.get("resumable_slots", {}) or {}
@@ -172,12 +184,14 @@ async def send_message(
"current_step_code": resumable_step_code,
"slots": dict(resumable_slots),
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
prev_intent_code = suspended_intent
handoff_count = 0
soft_insertion_count = 0
suspended_intent = None
resumable_step_code = None
resumable_slots = {}
@@ -205,12 +219,14 @@ async def send_message(
thread.id, prev_intent_code, router_code,
)
handoff_count += 1
soft_insertion_count = 0
snapshot = {
"current_intent_code": router_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": handoff_count,
"soft_insertion_count": 0,
# suspended_* не трогаем — там может лежать прерванная sm-ветка,
# к которой пациент ещё захочет вернуться.
"suspended_intent": suspended_intent,
@@ -234,11 +250,13 @@ async def send_message(
"current_step_code": None,
"slots": {},
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
handoff_count = 0
soft_insertion_count = 0
suspended_intent = None
resumable_step_code = None
resumable_slots = {}
@@ -253,10 +271,12 @@ async def send_message(
"current_step_code": None,
"slots": {},
"handoff_count": handoff_count,
"soft_insertion_count": 0,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
}
soft_insertion_count = 0
router_hint = None
# Финализируем snapshot.current_intent_code на served_code: для не-sm-веток
# (general_info / price_question / ...) state_update от LLM не приходит, и без
@@ -274,6 +294,7 @@ async def send_message(
visible_text = ""
parse_error: str | None = None
is_state_machine = False
parsed: dict | None = None # инициализируем заранее: routing_loop guard может пропустить for-цикл
# Если уже сработала защита от петли — не зовём LLM, формируем заглушку.
if routing_loop_triggered:
@@ -294,7 +315,8 @@ async def send_message(
base_prompt = config_service.compose_full_system_prompt(active_cfg)
step_prompt = f"\n\n{current_step.system_prompt}" if current_step else ""
state_context = _format_state_context(snapshot, current_step, router_hint)
soft_nudge = is_state_machine and soft_insertion_count >= SOFT_INSERTION_CAP
state_context = _format_state_context(snapshot, current_step, router_hint, soft_nudge)
system_prompt = base_prompt + step_prompt + state_context
llm_result = await llm.chat(
@@ -354,11 +376,13 @@ async def send_message(
"current_step_code": None,
"slots": {},
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
}
handoff_count = 0
soft_insertion_count = 0
suspended_intent = None
resumable_step_code = None
resumable_slots = {}
@@ -375,12 +399,14 @@ async def send_message(
break
served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, new_code)
soft_insertion_count = 0 # новая ветка — счётчик с нуля
snapshot = {
"current_intent_code": served_code,
"current_step": 0,
"current_step_code": None,
"slots": {},
"handoff_count": handoff_count,
"soft_insertion_count": 0,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
@@ -390,6 +416,7 @@ async def send_message(
if parsed["state_update"] is not None and current_step is not None:
requested = parsed["state_update"]["state_after"]
soft_insertion_flag = bool(parsed["state_update"].get("soft_insertion", False))
allowed = intent_step_service.parse_allowed_next(current_step)
ok, reason = validate_transition(
current_step=current_step.code,
@@ -398,10 +425,19 @@ async def send_message(
)
slots_updated = parsed["state_update"]["slots_updated"]
merged_slots = {**snapshot.get("slots", {}), **slots_updated}
# Решаем, как изменился soft_insertion_count.
# Soft-insertion засчитываем только если ветка явно отметила его и
# одновременно осталась на том же шаге без новых сценарных слотов.
stayed_on_step = ok and requested == current_step.code
if soft_insertion_flag and stayed_on_step and not slots_updated:
soft_insertion_count += 1
else:
soft_insertion_count = 0
base_state = {
"current_intent_code": served_code,
"slots": merged_slots,
"handoff_count": handoff_count,
"soft_insertion_count": soft_insertion_count,
"suspended_intent": suspended_intent,
"resumable_step_code": resumable_step_code,
"resumable_slots": resumable_slots,
@@ -442,6 +478,7 @@ async def send_message(
step_code=snapshot.get("current_step_code"),
slots=snapshot["slots"],
handoff_count=snapshot.get("handoff_count", handoff_count),
soft_insertion_count=snapshot.get("soft_insertion_count", soft_insertion_count),
suspended_intent=snapshot.get("suspended_intent"),
resumable_step_code=snapshot.get("resumable_step_code"),
resumable_slots=snapshot.get("resumable_slots"),
@@ -451,6 +488,29 @@ async def send_message(
if thread.agent_config_id is None:
thread.agent_config_id = active_cfg.id
# Собираем мета-снимок реплики: что увидит UI рядом с бейджем ветки.
events: list[str] = []
if routing_loop_triggered:
events.append("routing_loop")
if resumed_from_suspended:
events.append("resumed")
if bounce_log:
events.append("hard_handoff")
if router_hint and not routing_loop_triggered and not bounce_log:
events.append("sticky")
if validation_events:
events.append("validation_blocked")
# soft_insertion: ветка явно пометила ответ боковым (см. парсер state_update).
last_state_update = parsed.get("state_update") if isinstance(parsed, dict) else None
if last_state_update and last_state_update.get("soft_insertion"):
events.append("soft_insertion")
meta = {
"router_intent_code": router_code,
"served_intent_code": served_code,
"step_code": snapshot.get("current_step_code"),
"events": events,
}
assistant_msg = Message(
thread_id=thread.id,
role="assistant",
@@ -458,6 +518,7 @@ async def send_message(
sources_json=json.dumps(sources, ensure_ascii=False),
assembled_prompt=last_assembled_prompt,
intent_id=intent.id,
meta_json=json.dumps(meta, ensure_ascii=False),
)
session.add(assistant_msg)
@@ -498,6 +559,7 @@ async def send_message(
"current_step_code": snapshot.get("current_step_code"),
"slots": snapshot["slots"],
"handoff_count": snapshot.get("handoff_count", handoff_count),
"soft_insertion_count": snapshot.get("soft_insertion_count", soft_insertion_count),
"suspended_intent": snapshot.get("suspended_intent"),
"resumable_step_code": snapshot.get("resumable_step_code"),
"resumable_slots": snapshot.get("resumable_slots", {}),
@@ -507,6 +569,7 @@ async def send_message(
"parse_error": parse_error,
"routing_loop_triggered": routing_loop_triggered,
"resumed_from_suspended": resumed_from_suspended,
"message_meta": meta,
}
@@ -575,6 +638,12 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
sources = json.loads(m.sources_json)
except json.JSONDecodeError:
logger.warning("Bad sources_json for message %d", m.id)
meta = None
if m.meta_json:
try:
meta = json.loads(m.meta_json)
except json.JSONDecodeError:
logger.warning("Bad meta_json for message %d", m.id)
messages.append({
"id": m.id,
"role": m.role,
@@ -584,6 +653,7 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
"assembled_prompt": m.assembled_prompt or "",
"intent_code": intent_code or "",
"intent_name": intent_name or "",
"meta": meta,
})
state = await thread_state_service.load_snapshot(session, thread_id)
+6 -1
View File
@@ -79,6 +79,7 @@ def parse_branch_response(text: str) -> dict:
state_after = data.get("state_after")
slots_updated = data.get("slots_updated", {})
soft_insertion = bool(data.get("soft_insertion", False))
if not isinstance(state_after, str) or not state_after:
return {
"visible_text": text[:state_match.start()].rstrip(),
@@ -92,7 +93,11 @@ def parse_branch_response(text: str) -> dict:
return {
"visible_text": text[:state_match.start()].rstrip(),
"intent_change": None,
"state_update": {"state_after": state_after, "slots_updated": slots_updated},
"state_update": {
"state_after": state_after,
"slots_updated": slots_updated,
"soft_insertion": soft_insertion,
},
"parse_error": None,
}
+5
View File
@@ -39,6 +39,7 @@ async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"current_step_code": None,
"slots": {},
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": None,
"resumable_step_code": None,
"resumable_slots": {},
@@ -57,6 +58,7 @@ async def load_snapshot(session: AsyncSession, thread_id: int) -> dict:
"current_step_code": state.current_step_code,
"slots": _parse_slots(state.slots_json),
"handoff_count": state.handoff_count,
"soft_insertion_count": state.soft_insertion_count,
"suspended_intent": state.suspended_intent,
"resumable_step_code": state.resumable_step_code,
"resumable_slots": resumable_slots,
@@ -72,6 +74,7 @@ async def upsert(
slots: dict,
step_code: str | None = None,
handoff_count: int = 0,
soft_insertion_count: int = 0,
suspended_intent: str | None = None,
resumable_step_code: str | None = None,
resumable_slots: dict | None = None,
@@ -93,6 +96,7 @@ async def upsert(
current_step_code=step_code,
slots_json=slots_raw,
handoff_count=handoff_count,
soft_insertion_count=soft_insertion_count,
suspended_intent=suspended_intent,
resumable_step_code=resumable_step_code,
resumable_slots_json=resumable_raw,
@@ -105,6 +109,7 @@ async def upsert(
state.current_step_code = step_code
state.slots_json = slots_raw
state.handoff_count = handoff_count
state.soft_insertion_count = soft_insertion_count
state.suspended_intent = suspended_intent
state.resumable_step_code = resumable_step_code
state.resumable_slots_json = resumable_raw