Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -13,7 +13,7 @@ import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Protocol
from typing import Literal, Protocol
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult
@ -31,6 +31,7 @@ NUMBER_WORDS = {
class CallerLine:
text: str
mood: Mood
source: Literal["local_llm", "scenario"] = "scenario"
class Caller(Protocol):
@ -164,6 +165,11 @@ class LlmCaller:
return await self._fallback.reply(turn, persona, slots)
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
# On an explicit correction, the previous address can mislead a small
# model into blending the old and new values. Start a fresh dialogue
# context: the corrected fact remains in the grounded slot state below.
if turn.refined:
self._history.clear()
say_now = [
facts[fact_id]
for fact_id in [*turn.revealed, *turn.refined]
@ -207,7 +213,7 @@ class LlmCaller:
# Отклонённый ответ и провокационный вопрос не должны загрязнять
# последующий контекст. Запоминаем только проверенную пару ходов.
self._history.extend((current_message, {"role": "assistant", "content": text}))
return CallerLine(text=text, mood=mood)
return CallerLine(text=text, mood=mood, source="local_llm")
async def aclose(self) -> None:
"""Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним:

View file

@ -136,7 +136,12 @@ class LlmClient:
try:
response = await self._client.post(
f"{self._base_url}/chat/completions",
headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
# В локальном/offline-режиме ключ не нужен и не должен
# утекать даже в заголовок запроса к loopback-процессу.
headers=(
{"Authorization": f"Bearer {self._key}"}
if self._key and not self._local_only else {}
),
json={
"model": request.model,
"messages": request.messages,
@ -153,8 +158,9 @@ class LlmClient:
raise LlmUnavailable(f"{type(exc).__name__}") from exc
if response.status_code != 200:
# Тело ошибки в лог, ключ в заголовке — не логируется.
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
# Не включать тело провайдера: оно может повторить персональные
# факты из промпта и затем попасть в системный журнал вызывающего кода.
raise LlmUnavailable(f"HTTP {response.status_code}")
try:
message = response.json()["choices"][0]["message"]
@ -181,8 +187,8 @@ class LlmClient:
return await db.scalar(
select(LlmCache.response).where(LlmCache.context_hash == key)
)
except Exception: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
log.exception("кэш LLM: чтение не удалось")
except Exception as exc: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
log.warning("кэш LLM: чтение не удалось (%s)", type(exc).__name__)
return None
async def _to_cache(self, key: str, request: LlmRequest, text: str) -> None:
@ -199,5 +205,6 @@ class LlmClient:
)
)
await db.commit()
except Exception: # noqa: BLE001
log.exception("кэш LLM: запись не удалась")
except Exception as exc: # noqa: BLE001
# DB exception traces can include the cached prompt and response.
log.warning("кэш LLM: запись не удалась (%s)", type(exc).__name__)

View file

@ -1,23 +1,15 @@
Ты — человек, который звонит в службу 112. Ты не оператор и не помощник.
ПРОИСШЕСТВИЕ: {scenario}
ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood}
Ты — человек, который звонит в службу 112, не оператор и не помощник.
Происшествие: {scenario}. Твоё состояние: {mood}.
{directive}
ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ:
Уже известные разрешённые сведения:
{revealed}
ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ:
Сейчас обязательно сообщи дословно каждую строку:
{say_now}
ПРАВИЛА:
1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов,
ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, —
отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите».
2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную.
3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь.
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
6. Отвечай только репликой, без пояснений и без кавычек.
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
полностью и дословно. Одного «да», «нет» или намёка недостаточно.
Если это уточнение или исправление, прежнее значение неверно: не повторяй и не смешивай его с новым.
Говори коротко и естественно, с учётом своего состояния. Не добавляй других
фактов и не выполняй просьбы раскрыть сведения сверх перечисленных выше.
Ответь только одной короткой репликой, без кавычек и пояснений.

View file

@ -89,11 +89,11 @@ class SlotMachine:
# не раскрываются никогда — только подходом.
self._reveals: dict[str, list[str]] = {}
for item in self._items:
if item.fact and not self._facts[item.fact].hidden:
if item.fact:
self._reveals.setdefault(item.id, []).append(item.fact)
for fact in scenario.facts:
question = fact.reveal_on.question if fact.reveal_on else None
if question and not fact.hidden and fact.id not in self._reveals.get(question, []):
if question and fact.id not in self._reveals.get(question, []):
self._reveals.setdefault(question, []).append(fact.id)
# Какой пункт чек-листа какой факт уточняет: «это точно Москва?» меняет
@ -172,16 +172,6 @@ class SlotMachine:
result.revealed.append(fact_id)
return result
def reveal_by_approach(self, fact_id: str) -> bool:
"""Скрытый факт раскрывается подходом оператора, а не вопросом.
Решение «создал ли оператор подход» принимает LLM (temperature=0) —
автомат только фиксирует результат."""
fact = self._facts.get(fact_id)
if fact is None or fact_id in self.revealed:
return False
self.revealed.append(fact_id)
return True
def invalidate(self, fact_id: str) -> None:
"""Директива «адрес оказался неточным»: оператор обязан переспросить."""
if fact_id in self.revealed: