2026-09-24 01:10:49 +03:00
|
|
|
|
"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем.
|
|
|
|
|
|
|
|
|
|
|
|
Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию,
|
|
|
|
|
|
список оповещения и протокол оценки она менять не может. Публикация — отдельное
|
|
|
|
|
|
действие преподавателя после просмотра черновика и эталона.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
|
|
|
|
|
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
|
|
|
|
|
|
from app.scenarios.editor import classifier_sign_gaps, template_copy, validate
|
|
|
|
|
|
from app.scenarios.loader import ScenarioError
|
|
|
|
|
|
from app.scenarios.schema import Scenario
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GenerationError(ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PROTECTED_IDS = ("address", "people", "victim", "floor", "location", "region")
|
|
|
|
|
|
PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
|
|
|
|
|
|
NUMBER_WORD = re.compile(
|
|
|
|
|
|
r"\b(?:ноль|один|одна|одно|одного|одну|два|две|двое|двух|три|трое|тр[её]х|"
|
|
|
|
|
|
r"четыре|четверо|четыр[её]х|пять|пятеро|пяти|шесть|шестеро|шести|семь|"
|
|
|
|
|
|
r"семеро|семи|восемь|восьмеро|восьми|девять|девятеро|девяти|десять|"
|
|
|
|
|
|
r"десятеро|десяти)\b",
|
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reveals_number(value: str) -> bool:
|
|
|
|
|
|
return bool(re.search(r"\d", value) or NUMBER_WORD.search(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def editable_fact_ids(source: Scenario) -> list[str]:
|
|
|
|
|
|
return [fact.id for fact in source.facts
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if not fact.refined and not fact.refine_on
|
2026-09-24 01:10:49 +03:00
|
|
|
|
and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def correction_target(source: Scenario, instruction: str) -> str | None:
|
|
|
|
|
|
"""Указать маленькой модели конкретный факт для правки при повторе."""
|
|
|
|
|
|
eligible = [fact for fact in source.facts if fact.id in editable_fact_ids(source)]
|
|
|
|
|
|
if not eligible:
|
|
|
|
|
|
return None
|
|
|
|
|
|
def stems(text: str) -> set[str]:
|
|
|
|
|
|
return {word[:4] for word in re.findall(r"[а-яё]{3,}", text.casefold())}
|
|
|
|
|
|
|
|
|
|
|
|
words = stems(instruction)
|
|
|
|
|
|
questions = {item.fact: item.question or "" for item in source.checklist if item.fact}
|
|
|
|
|
|
|
|
|
|
|
|
def relevance(fact) -> int:
|
|
|
|
|
|
haystack = stems(f"{fact.value} {questions.get(fact.id, '')}")
|
|
|
|
|
|
return len(words & haystack)
|
|
|
|
|
|
|
|
|
|
|
|
_, best = max(enumerate(eligible), key=lambda pair: (relevance(pair[1]), pair[0]))
|
|
|
|
|
|
return best.id if relevance(best) > 0 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def style_fallback(source: Scenario, instruction: str) -> dict | None:
|
|
|
|
|
|
"""Безопасно сократить реплику, если 1.7B дважды вернула копию."""
|
|
|
|
|
|
if not re.search(r"\b(?:короч|кратк)", instruction.casefold()):
|
|
|
|
|
|
return None
|
|
|
|
|
|
chunks = re.findall(r"[^.!?…]+[.!?…]+|[^.!?…]+$", source.first_line)
|
|
|
|
|
|
useful: list[str] = []
|
|
|
|
|
|
for chunk in chunks:
|
|
|
|
|
|
value = chunk.strip()
|
|
|
|
|
|
letters = re.sub(r"[^а-яё]", "", value.casefold())
|
|
|
|
|
|
if not letters or letters in {"алло", "ааа", "аа", "а"}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
useful.append(value)
|
|
|
|
|
|
if not useful:
|
|
|
|
|
|
return None
|
|
|
|
|
|
shortened = " ".join(useful[:2])[:300].strip()
|
|
|
|
|
|
if shortened == source.first_line.strip():
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {"title": source.title, "first_line": shortened, "facts": {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_proposal(
|
|
|
|
|
|
raw: str, source: Scenario, *, require_fact_change: bool = True,
|
|
|
|
|
|
instruction: str = "", restrict_first_line_facts: bool = False,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Никакой произвольный JSON модели не попадает в схему сценария."""
|
|
|
|
|
|
text = raw.strip()
|
|
|
|
|
|
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
|
|
|
|
|
if fenced:
|
|
|
|
|
|
text = fenced.group(1)
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal = json.loads(text)
|
|
|
|
|
|
except (ValueError, TypeError) as exc:
|
|
|
|
|
|
raise GenerationError("модель не вернула корректный JSON") from exc
|
|
|
|
|
|
if not isinstance(proposal, dict) or set(proposal) != {"title", "first_line", "facts"}:
|
|
|
|
|
|
raise GenerationError("модель вернула поля вне разрешённого формата")
|
|
|
|
|
|
title, first_line, facts = (proposal[key] for key in ("title", "first_line", "facts"))
|
|
|
|
|
|
if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
|
|
|
|
|
|
raise GenerationError("название варианта пустое или слишком длинное")
|
|
|
|
|
|
if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
|
|
|
|
|
|
raise GenerationError("первая реплика пустая или слишком длинная")
|
|
|
|
|
|
if reveals_number(first_line):
|
|
|
|
|
|
raise GenerationError("первая реплика раскрывает числовые детали до опроса")
|
|
|
|
|
|
if instruction and restrict_first_line_facts:
|
|
|
|
|
|
stop = {"есть", "если", "меня", "очень", "этот", "только", "сейчас", "помогите"}
|
|
|
|
|
|
|
|
|
|
|
|
def stems(value: str) -> set[str]:
|
|
|
|
|
|
return {word[:3] for word in re.findall(r"[а-яё]{3,}", value.casefold())
|
|
|
|
|
|
if word not in stop}
|
|
|
|
|
|
|
|
|
|
|
|
fact_stems = set().union(*(stems(fact.value) for fact in source.facts))
|
|
|
|
|
|
allowed_stems = stems(f"{source.first_line} {instruction}")
|
|
|
|
|
|
leaked = (stems(first_line) & fact_stems) - allowed_stems
|
|
|
|
|
|
if leaked:
|
|
|
|
|
|
raise GenerationError(
|
|
|
|
|
|
"первая реплика добавляет не запрошенные обстоятельства карточки"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not isinstance(facts, dict):
|
|
|
|
|
|
raise GenerationError("факты должны быть объектом")
|
|
|
|
|
|
allowed = set(editable_fact_ids(source))
|
|
|
|
|
|
if set(facts) - allowed:
|
|
|
|
|
|
raise GenerationError("модель попыталась изменить защищённые или неизвестные факты")
|
|
|
|
|
|
for fact_id, value in facts.items():
|
|
|
|
|
|
if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
|
|
|
|
|
|
raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
|
|
|
|
|
|
if PLACEHOLDER.search(value):
|
|
|
|
|
|
raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
|
|
|
|
|
|
if "?" in value:
|
|
|
|
|
|
raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
|
|
|
|
|
|
source_values = {fact.id: fact.value for fact in source.facts}
|
|
|
|
|
|
fact_changed = any(value.strip() != source_values[fact_id] for fact_id, value in facts.items())
|
|
|
|
|
|
story_changed = title.strip() != source.title or first_line.strip() != source.first_line
|
|
|
|
|
|
if require_fact_change and not fact_changed:
|
|
|
|
|
|
raise GenerationError("модель не предложила новых обстоятельств происшествия")
|
|
|
|
|
|
if not fact_changed and not story_changed:
|
|
|
|
|
|
raise GenerationError("модель не изменила черновик по комментарию")
|
|
|
|
|
|
return {"title": title.strip(), "first_line": first_line.strip(), "facts": facts}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
|
|
|
|
|
|
body = template_copy(source, draft_id, proposal["title"])
|
|
|
|
|
|
by_id = proposal["facts"]
|
|
|
|
|
|
for fact in body["facts"]:
|
|
|
|
|
|
if fact["id"] in by_id:
|
|
|
|
|
|
fact["value"] = by_id[fact["id"]].strip()
|
|
|
|
|
|
body["first_line"] = proposal["first_line"]
|
|
|
|
|
|
# Схема, связи фактов и эталон проверяются до сохранения черновика.
|
|
|
|
|
|
validate(body)
|
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_full_proposal(raw: str, source: Scenario) -> dict:
|
|
|
|
|
|
"""Проверить новый сюжет, где модель заполняет все ответы и мягкий эталон.
|
|
|
|
|
|
|
|
|
|
|
|
Тип, признаки ЕКП, маршрутизация и чек-лист остаются структурным каркасом,
|
|
|
|
|
|
но сами обстоятельства, адрес и число пострадавших создаются заново.
|
|
|
|
|
|
"""
|
|
|
|
|
|
text = raw.strip()
|
|
|
|
|
|
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
|
|
|
|
|
if fenced:
|
|
|
|
|
|
text = fenced.group(1)
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal = json.loads(text)
|
|
|
|
|
|
except (ValueError, TypeError) as exc:
|
|
|
|
|
|
raise GenerationError("модель не вернула корректный JSON нового сюжета") from exc
|
|
|
|
|
|
if not isinstance(proposal, dict) or set(proposal) != {
|
|
|
|
|
|
"title", "first_line", "facts", "ground_truth"
|
|
|
|
|
|
}:
|
|
|
|
|
|
raise GenerationError("новый сюжет содержит поля вне разрешённого формата")
|
|
|
|
|
|
title = proposal["title"]
|
|
|
|
|
|
first_line = proposal["first_line"]
|
|
|
|
|
|
facts = proposal["facts"]
|
|
|
|
|
|
truth = proposal["ground_truth"]
|
|
|
|
|
|
if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
|
|
|
|
|
|
raise GenerationError("название нового сюжета пустое или слишком длинное")
|
|
|
|
|
|
if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
|
|
|
|
|
|
raise GenerationError("первая реплика нового сюжета пустая или слишком длинная")
|
|
|
|
|
|
if reveals_number(first_line):
|
|
|
|
|
|
raise GenerationError("первая реплика раскрывает числовые детали до опроса")
|
|
|
|
|
|
expected = {fact.id for fact in source.facts}
|
|
|
|
|
|
if not isinstance(facts, dict) or set(facts) != expected:
|
|
|
|
|
|
raise GenerationError("новый сюжет должен заполнить каждый факт каркаса ровно один раз")
|
|
|
|
|
|
for fact_id, value in facts.items():
|
|
|
|
|
|
if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
|
|
|
|
|
|
raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
|
|
|
|
|
|
if PLACEHOLDER.search(value):
|
|
|
|
|
|
raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
|
|
|
|
|
|
if "?" in value:
|
|
|
|
|
|
raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
|
|
|
|
|
|
if not isinstance(truth, dict) or set(truth) != {"address", "victims"}:
|
|
|
|
|
|
raise GenerationError("ground_truth нового сюжета должен содержать address и victims")
|
|
|
|
|
|
address, victims = truth["address"], truth["victims"]
|
|
|
|
|
|
if not isinstance(address, str) or not 5 <= len(address.strip()) <= 200:
|
|
|
|
|
|
raise GenerationError("эталонный адрес пустой или слишком длинный")
|
|
|
|
|
|
if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
|
|
|
|
|
|
raise GenerationError("число пострадавших должно быть целым от 0 до 20")
|
|
|
|
|
|
address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
|
|
|
|
|
|
if address_id is None:
|
|
|
|
|
|
raise GenerationError("в выбранном каркасе нет адресного факта")
|
|
|
|
|
|
|
|
|
|
|
|
def normalized(value: str) -> str:
|
|
|
|
|
|
return re.sub(r"\s+", " ", value.casefold().replace("ё", "е")).strip(" .,")
|
|
|
|
|
|
|
|
|
|
|
|
if not normalized(facts[address_id]).startswith(normalized(address)):
|
|
|
|
|
|
raise GenerationError("эталонный адрес не совпадает с началом адресного факта")
|
|
|
|
|
|
evidence = " ".join(facts.values()).casefold().replace("ё", "е")
|
|
|
|
|
|
count_words = {
|
|
|
|
|
|
0: ("ноль",), 1: ("один", "одна", "одного"),
|
|
|
|
|
|
2: ("два", "двое", "двух"), 3: ("три", "трое", "трех"),
|
|
|
|
|
|
4: ("четыре", "четверо", "четырех"), 5: ("пять", "пятеро", "пяти"),
|
|
|
|
|
|
6: ("шесть", "шестеро", "шести"), 7: ("семь", "семеро", "семи"),
|
|
|
|
|
|
8: ("восемь", "восьмеро", "восьми"), 9: ("девять", "девятеро", "девяти"),
|
|
|
|
|
|
10: ("десять", "десятеро", "десяти"),
|
|
|
|
|
|
}
|
|
|
|
|
|
numeric = re.search(rf"пострадавш\w*\s*[:—-]?\s*{victims}(?:\D|$)", evidence)
|
|
|
|
|
|
worded = any(re.search(
|
|
|
|
|
|
rf"(?:пострадавш\w*.{{0,25}}\b{word}\b|\b{word}\b.{{0,25}}пострада\w*)", evidence
|
|
|
|
|
|
) for word in count_words.get(victims, ()))
|
|
|
|
|
|
none_hurt = victims == 0 and re.search(
|
|
|
|
|
|
r"пострадавш(?:их|ие|их лиц)\s+нет|никто\s+не\s+пострадал|"
|
|
|
|
|
|
r"все\s+(?:люди\s+)?(?:успели\s+)?(?:выйти|эвакуироваться)", evidence
|
|
|
|
|
|
)
|
|
|
|
|
|
if not (numeric or worded or none_hurt):
|
|
|
|
|
|
raise GenerationError("в фактах нет явного подтверждения числа пострадавших")
|
|
|
|
|
|
old_values = {fact.id: fact.value.strip() for fact in source.facts}
|
|
|
|
|
|
changed = sum(value.strip() != old_values[fact_id] for fact_id, value in facts.items())
|
|
|
|
|
|
if changed < max(1, (len(facts) + 1) // 2):
|
|
|
|
|
|
raise GenerationError("новый сюжет слишком близко копирует исходный каркас")
|
|
|
|
|
|
if normalized(address) in normalized(first_line):
|
|
|
|
|
|
raise GenerationError("первая реплика раскрывает адрес до опроса")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"title": title.strip(), "first_line": first_line.strip(),
|
|
|
|
|
|
"facts": {key: value.strip() for key, value in facts.items()},
|
|
|
|
|
|
"ground_truth": {"address": address.strip(), "victims": victims},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def full_proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
|
|
|
|
|
|
"""Собрать самостоятельный сценарий из проверенного нового сюжета."""
|
|
|
|
|
|
body = template_copy(source, draft_id, proposal["title"])
|
|
|
|
|
|
for fact in body["facts"]:
|
|
|
|
|
|
fact["value"] = proposal["facts"][fact["id"]]
|
|
|
|
|
|
# Старое уточнение относится к старому сюжету и не переносится.
|
|
|
|
|
|
fact["refined"] = None
|
|
|
|
|
|
fact["refine_on"] = None
|
|
|
|
|
|
body["first_line"] = proposal["first_line"]
|
|
|
|
|
|
body["ground_truth"] = dict(proposal["ground_truth"])
|
|
|
|
|
|
validate(body)
|
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def generate_from_description(source: Scenario, description: str) -> dict:
|
|
|
|
|
|
"""Создать все обстоятельства и мягкий эталон внутри выбранной категории ЕКП.
|
|
|
|
|
|
|
|
|
|
|
|
Qwen 1.7B заметно надёжнее отвечает на один вопрос за раз. Поэтому вместо
|
|
|
|
|
|
одного длинного JSON, где малая модель копировала вопросы и старую карточку,
|
|
|
|
|
|
используются короткие строгие запросы. Итог всё равно проходит единую
|
|
|
|
|
|
сквозную проверку непротиворечивости.
|
|
|
|
|
|
"""
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
if (not is_loopback_url(
|
|
|
|
|
|
settings.llm_base_url,
|
|
|
|
|
|
allow_docker_host=settings.allow_docker_host_models,
|
|
|
|
|
|
) or not settings.llm_model_caller):
|
|
|
|
|
|
raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
|
|
|
|
|
|
questions = {item.fact: item.question for item in source.checklist if item.fact}
|
|
|
|
|
|
address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
|
|
|
|
|
|
if address_id is None:
|
|
|
|
|
|
raise GenerationError("в выбранном каркасе нет адресного факта")
|
|
|
|
|
|
|
|
|
|
|
|
def victim_relevance(fact_id: str) -> int:
|
|
|
|
|
|
text = f"{fact_id} {questions.get(fact_id, '')}".casefold()
|
|
|
|
|
|
return sum(word in text for word in (
|
|
|
|
|
|
"people", "victim", "injur", "who", "люд", "пострад", "кто", "кому"
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
victim_id = max((fact.id for fact in source.facts), key=victim_relevance)
|
|
|
|
|
|
client = LlmClient(base_url=settings.llm_base_url, timeout=45)
|
|
|
|
|
|
|
|
|
|
|
|
def clean_answer(value: object) -> str:
|
|
|
|
|
|
"""Нормализовать пробелы и ограничить ответ без порчи `ул.`/`д.`."""
|
|
|
|
|
|
text = re.sub(r"\s+", " ", str(value)).strip()
|
|
|
|
|
|
for marker in (", но", ". В данном", ". Однако", ". Необходимо",
|
|
|
|
|
|
". Рекомендуется", ". Следует"):
|
|
|
|
|
|
text = text.split(marker, 1)[0]
|
|
|
|
|
|
return text[:300].strip()
|
|
|
|
|
|
|
|
|
|
|
|
def validate_value(data: dict, *, extra_key: str | None = None) -> None:
|
|
|
|
|
|
value = clean_answer(data.get("value", ""))
|
|
|
|
|
|
if not 3 <= len(value) <= 300:
|
|
|
|
|
|
raise GenerationError("значение пустое или слишком длинное")
|
|
|
|
|
|
if PLACEHOLDER.search(value):
|
|
|
|
|
|
raise GenerationError("модель вернула плейсхолдер вместо ответа")
|
|
|
|
|
|
if "?" in value:
|
|
|
|
|
|
raise GenerationError("модель вернула вопрос вместо ответа")
|
|
|
|
|
|
if extra_key is not None and extra_key not in data:
|
|
|
|
|
|
raise GenerationError(f"нет обязательного поля {extra_key}")
|
|
|
|
|
|
|
|
|
|
|
|
def validate_headline(data: dict) -> None:
|
|
|
|
|
|
title = clean_answer(data.get("title", ""))
|
|
|
|
|
|
first_line = clean_answer(data.get("first_line", ""))
|
|
|
|
|
|
if not 5 <= len(title) <= 200:
|
|
|
|
|
|
raise GenerationError("название пустое или слишком длинное")
|
|
|
|
|
|
if not 8 <= len(first_line) <= 300:
|
|
|
|
|
|
raise GenerationError("первая реплика пустая или слишком длинная")
|
|
|
|
|
|
if reveals_number(first_line):
|
|
|
|
|
|
raise GenerationError("первая реплика раскрывает числовые детали")
|
|
|
|
|
|
|
|
|
|
|
|
def validate_victims(data: dict) -> None:
|
|
|
|
|
|
validate_value(data, extra_key="victims")
|
|
|
|
|
|
victims = data["victims"]
|
|
|
|
|
|
if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
|
|
|
|
|
|
raise GenerationError("victims должен быть целым от 0 до 20")
|
|
|
|
|
|
|
|
|
|
|
|
def victim_answer(count: int) -> str:
|
|
|
|
|
|
"""Число модели сохраняем, формулировку делаем воспроизводимой и грамотной."""
|
|
|
|
|
|
if count == 0:
|
|
|
|
|
|
return "Никто не пострадал. Пострадавших: 0"
|
|
|
|
|
|
if count == 1:
|
|
|
|
|
|
return "Пострадал один человек. Пострадавших: 1"
|
|
|
|
|
|
noun = "человека" if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14) else "человек"
|
|
|
|
|
|
return f"Пострадали {count} {noun}. Пострадавших: {count}"
|
|
|
|
|
|
|
|
|
|
|
|
async def ask(
|
|
|
|
|
|
system: str, payload: dict, schema: dict, *, max_tokens: int = 180,
|
|
|
|
|
|
validator=None,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
last_error: Exception | None = None
|
|
|
|
|
|
raw = ""
|
|
|
|
|
|
for attempt in range(3):
|
|
|
|
|
|
messages = [
|
|
|
|
|
|
{"role": "system", "content": system},
|
|
|
|
|
|
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
|
|
|
|
|
]
|
|
|
|
|
|
if attempt:
|
|
|
|
|
|
messages += [
|
|
|
|
|
|
{"role": "assistant", "content": raw},
|
|
|
|
|
|
{"role": "user", "content": (
|
|
|
|
|
|
f"Ответ отклонён: {last_error}. Верни исправленный JSON. "
|
|
|
|
|
|
"Нужен конкретный ответ, не повторяй вопрос."
|
|
|
|
|
|
)},
|
|
|
|
|
|
]
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw = await client.complete(LlmRequest(
|
|
|
|
|
|
messages=messages, model=settings.llm_model_caller,
|
|
|
|
|
|
temperature=0.25, max_tokens=max_tokens,
|
|
|
|
|
|
response_format={"type": "json_object", "schema": schema},
|
|
|
|
|
|
strip_reasoning=True,
|
|
|
|
|
|
), use_cache=False)
|
|
|
|
|
|
data = json.loads(raw)
|
|
|
|
|
|
if not isinstance(data, dict) or set(data) != set(schema["required"]):
|
|
|
|
|
|
raise GenerationError("неверный набор полей")
|
|
|
|
|
|
if validator is not None:
|
|
|
|
|
|
validator(data)
|
|
|
|
|
|
return data
|
|
|
|
|
|
except (ValueError, TypeError, GenerationError, LlmUnavailable) as exc:
|
|
|
|
|
|
last_error = exc
|
|
|
|
|
|
if isinstance(exc, LlmUnavailable) and not any(marker in str(exc) for marker in (
|
|
|
|
|
|
"служебные токены", "пустой ответ модели"
|
|
|
|
|
|
)):
|
|
|
|
|
|
raise
|
|
|
|
|
|
raise GenerationError(f"локальная модель не заполнила часть сценария: {last_error}")
|
|
|
|
|
|
|
|
|
|
|
|
value_schema = {
|
|
|
|
|
|
"type": "object", "properties": {"value": {"type": "string"}},
|
|
|
|
|
|
"required": ["value"], "additionalProperties": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Заголовочный запрос не получает числовые детали даже словами: малая
|
|
|
|
|
|
# модель склонна повторять их в первой реплике, раскрывая ответ до
|
|
|
|
|
|
# обязательного вопроса оператора. Полное описание остаётся у запросов
|
|
|
|
|
|
# фактов ниже, поэтому эталон числа пострадавших не теряется.
|
|
|
|
|
|
headline_description = re.sub(r"\d+", "", description)
|
|
|
|
|
|
headline_description = re.sub(r"\s+", " ", NUMBER_WORD.sub("", headline_description)).strip()
|
|
|
|
|
|
headline = await ask(
|
|
|
|
|
|
"Создай название и первую эмоциональную реплику заявителя для учебного вызова 112. "
|
|
|
|
|
|
"Верни JSON title и first_line. first_line без цифр, адреса и служебных пояснений. "
|
|
|
|
|
|
"classifier_signs — обязательный путь классификатора ЕКП: сюжет не должен ему "
|
|
|
|
|
|
"противоречить. Если свободное description конфликтует с classifier_signs, "
|
|
|
|
|
|
"приоритет всегда у classifier_signs.",
|
|
|
|
|
|
{"incident_type": source.type.value, "classifier_signs": source.signs,
|
|
|
|
|
|
"level": source.level.value, "description": headline_description},
|
|
|
|
|
|
{"type": "object", "properties": {
|
|
|
|
|
|
"title": {"type": "string"}, "first_line": {"type": "string"},
|
|
|
|
|
|
}, "required": ["title", "first_line"], "additionalProperties": False},
|
|
|
|
|
|
max_tokens=220,
|
|
|
|
|
|
validator=validate_headline,
|
|
|
|
|
|
)
|
|
|
|
|
|
facts: dict[str, str] = {}
|
|
|
|
|
|
address = ""
|
|
|
|
|
|
victims: int | None = None
|
|
|
|
|
|
for fact in source.facts:
|
|
|
|
|
|
question = questions.get(fact.id, fact.id)
|
|
|
|
|
|
base_payload = {
|
|
|
|
|
|
"description": description, "incident_type": source.type.value,
|
|
|
|
|
|
"classifier_signs": source.signs,
|
|
|
|
|
|
"question": question, "already_known_answers": facts,
|
|
|
|
|
|
}
|
|
|
|
|
|
if fact.id == address_id:
|
|
|
|
|
|
data = await ask(
|
|
|
|
|
|
"Придумай конкретный вымышленный московский адрес для учебного сценария. "
|
|
|
|
|
|
"Верни JSON value и address. value должно начинаться дословно с address, "
|
|
|
|
|
|
"после него можно добавить квартиру, этаж или ориентир. Это ответ, не вопрос. "
|
|
|
|
|
|
"Адрес и тип объекта не должны противоречить classifier_signs.",
|
|
|
|
|
|
base_payload,
|
|
|
|
|
|
{"type": "object", "properties": {
|
|
|
|
|
|
"value": {"type": "string"}, "address": {"type": "string"},
|
|
|
|
|
|
}, "required": ["value", "address"], "additionalProperties": False},
|
|
|
|
|
|
validator=lambda data: validate_value(data, extra_key="address"),
|
|
|
|
|
|
)
|
|
|
|
|
|
facts[fact.id] = clean_answer(data["value"])
|
|
|
|
|
|
# Эталон выводится из факта, а не из второго пересказа модели:
|
|
|
|
|
|
# малая Qwen иногда меняет пунктуацию или номер между полями.
|
|
|
|
|
|
address = facts[fact.id][:200].rstrip(" ,.;")
|
|
|
|
|
|
elif fact.id == victim_id:
|
|
|
|
|
|
data = await ask(
|
|
|
|
|
|
"Ответь на вопрос заявителя конкретным фактом по описанию. Верни JSON value "
|
|
|
|
|
|
"и victims. victims — число пострадавших от 0 до 20. value обязательно "
|
|
|
|
|
|
"заканчивается точной фразой `Пострадавших: N`, где N равно victims. "
|
|
|
|
|
|
"Не пиши вопрос и не считай всех присутствующих пострадавшими. "
|
|
|
|
|
|
"Ответ не должен противоречить classifier_signs.",
|
|
|
|
|
|
base_payload,
|
|
|
|
|
|
{"type": "object", "properties": {
|
|
|
|
|
|
"value": {"type": "string"},
|
|
|
|
|
|
"victims": {"type": "integer", "minimum": 0, "maximum": 20},
|
|
|
|
|
|
}, "required": ["value", "victims"], "additionalProperties": False},
|
|
|
|
|
|
validator=validate_victims,
|
|
|
|
|
|
)
|
|
|
|
|
|
victims = data["victims"] if isinstance(data["victims"], int) else None
|
|
|
|
|
|
if victims is not None:
|
|
|
|
|
|
facts[fact.id] = victim_answer(victims)
|
|
|
|
|
|
else:
|
|
|
|
|
|
data = await ask(
|
|
|
|
|
|
"Ответь по-русски конкретным обстоятельством заявителя на question, используя "
|
|
|
|
|
|
"description, classifier_signs и already_known_answers. classifier_signs — "
|
|
|
|
|
|
"обязательный путь ЕКП и важнее конфликтующих деталей description. Верни JSON "
|
|
|
|
|
|
"только с ключом value. value — ответ, не вопрос; не используй знак вопроса "
|
|
|
|
|
|
"и плейсхолдеры.",
|
|
|
|
|
|
base_payload, value_schema, validator=validate_value,
|
|
|
|
|
|
)
|
|
|
|
|
|
facts[fact.id] = clean_answer(data["value"])
|
|
|
|
|
|
if victims is None:
|
|
|
|
|
|
raise GenerationError("модель не указала число пострадавших")
|
|
|
|
|
|
gaps = classifier_sign_gaps(
|
|
|
|
|
|
source.signs,
|
|
|
|
|
|
[str(headline["title"]), str(headline["first_line"]), *facts.values()],
|
|
|
|
|
|
)
|
|
|
|
|
|
if gaps:
|
|
|
|
|
|
target = next(
|
|
|
|
|
|
fact for fact in source.facts
|
|
|
|
|
|
if fact.id not in {address_id, victim_id}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def validate_alignment(data: dict) -> None:
|
|
|
|
|
|
validate_value(data)
|
|
|
|
|
|
candidate = dict(facts)
|
|
|
|
|
|
candidate[target.id] = clean_answer(data["value"])
|
|
|
|
|
|
missing = classifier_sign_gaps(
|
|
|
|
|
|
source.signs,
|
|
|
|
|
|
[str(headline["title"]), str(headline["first_line"]), *candidate.values()],
|
|
|
|
|
|
)
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise GenerationError(
|
|
|
|
|
|
"ответ не подтверждает признаки ЕКП: " + ", ".join(missing)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
aligned = await ask(
|
|
|
|
|
|
"Перепиши один ответ заявителя так, чтобы он оставался конкретным ответом на "
|
|
|
|
|
|
"question и дословно, осмысленно подтверждал все missing_classifier_signs. "
|
|
|
|
|
|
"Это обязательные признаки ЕКП, их нельзя заменять другим объектом или событием. "
|
|
|
|
|
|
"Верни JSON только с ключом value, без вопроса и служебных пояснений.",
|
|
|
|
|
|
{
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
"question": questions.get(target.id, target.id),
|
|
|
|
|
|
"current_answer": facts[target.id],
|
|
|
|
|
|
"missing_classifier_signs": gaps,
|
|
|
|
|
|
"all_classifier_signs": source.signs,
|
|
|
|
|
|
"already_known_answers": facts,
|
|
|
|
|
|
},
|
|
|
|
|
|
value_schema,
|
|
|
|
|
|
max_tokens=220,
|
|
|
|
|
|
validator=validate_alignment,
|
|
|
|
|
|
)
|
|
|
|
|
|
facts[target.id] = clean_answer(aligned["value"])
|
|
|
|
|
|
first_line = str(headline["first_line"]).strip()
|
|
|
|
|
|
if first_line and first_line[-1] not in ".!?…":
|
|
|
|
|
|
first_line += "!"
|
|
|
|
|
|
proposal = {
|
|
|
|
|
|
"title": headline["title"], "first_line": first_line,
|
|
|
|
|
|
"facts": facts, "ground_truth": {"address": address, "victims": victims},
|
|
|
|
|
|
}
|
|
|
|
|
|
proposal = parse_full_proposal(json.dumps(proposal, ensure_ascii=False), source)
|
|
|
|
|
|
full_proposal_body(source, "ai-full-validation", proposal)
|
|
|
|
|
|
return proposal
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def generate(
|
|
|
|
|
|
source: Scenario, instruction: str, *, require_fact_change: bool = True
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
if (not is_loopback_url(
|
|
|
|
|
|
settings.llm_base_url,
|
|
|
|
|
|
allow_docker_host=settings.allow_docker_host_models,
|
|
|
|
|
|
) or not settings.llm_model_caller):
|
|
|
|
|
|
raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
|
|
|
|
|
|
all_editable = editable_fact_ids(source)
|
|
|
|
|
|
target = correction_target(source, instruction)
|
|
|
|
|
|
style_only = target is None and not require_fact_change
|
|
|
|
|
|
# Для 1.7B один комментарий = один изменяемый факт. Это не даёт модели
|
|
|
|
|
|
# «заодно» испортить газ, пока преподаватель просит сделать плотнее дым.
|
|
|
|
|
|
editable = [] if style_only else ([target] if target else all_editable)
|
|
|
|
|
|
fact_questions = {item.fact: item.question for item in source.checklist
|
|
|
|
|
|
if item.fact and item.question}
|
|
|
|
|
|
context = {
|
|
|
|
|
|
"incident_type": source.type.value,
|
|
|
|
|
|
"source_title": source.title,
|
|
|
|
|
|
"editable_facts": {
|
|
|
|
|
|
fact.id: {"question": fact_questions.get(fact.id, fact.id), "old_answer": fact.value}
|
|
|
|
|
|
for fact in source.facts if fact.id in editable
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
if not require_fact_change:
|
|
|
|
|
|
context["current_first_line"] = source.first_line
|
|
|
|
|
|
change_rule = (
|
|
|
|
|
|
"Измени обстоятельства хотя бы одного факта: дословная копия недопустима. "
|
|
|
|
|
|
if require_fact_change else
|
|
|
|
|
|
"Измени черновик по комментарию: допустимо менять только заголовок или first_line; "
|
|
|
|
|
|
"полностью прежняя версия недопустима. "
|
|
|
|
|
|
)
|
|
|
|
|
|
if style_only:
|
|
|
|
|
|
change_rule += (
|
|
|
|
|
|
"Это стилистическая правка: не добавляй в first_line новые обстоятельства, "
|
|
|
|
|
|
"которых нет в current_first_line или комментарии. facts оставь пустым. "
|
|
|
|
|
|
)
|
|
|
|
|
|
system = (
|
|
|
|
|
|
"Сочини НОВУЮ вариацию учебного вызова 112 на русском языке. "
|
|
|
|
|
|
"Верни ровно JSON с тремя ключами: title, first_line, facts. "
|
|
|
|
|
|
"facts содержит только id из editable_facts. " + change_rule +
|
|
|
|
|
|
"Значения facts — НОВЫЕ ответы "
|
|
|
|
|
|
"заявителя на question, не повторение вопроса. Не добавляй ключи. "
|
|
|
|
|
|
"first_line — прямая речь заявителя без цифр и адреса. "
|
|
|
|
|
|
"Тип происшествия, адрес и люди не меняются; их не включай в facts. "
|
|
|
|
|
|
"Не придумывай службы, коды и регламенты."
|
|
|
|
|
|
)
|
|
|
|
|
|
request = LlmRequest(
|
|
|
|
|
|
messages=[{"role": "system", "content": system},
|
|
|
|
|
|
{"role": "user", "content": json.dumps(
|
|
|
|
|
|
{"source": context, "request": instruction}, ensure_ascii=False)}],
|
|
|
|
|
|
model=settings.llm_model_caller, temperature=0.7, max_tokens=500,
|
|
|
|
|
|
response_format={"type": "json_object", "schema": {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"title": {"type": "string"},
|
|
|
|
|
|
"first_line": {"type": "string"},
|
|
|
|
|
|
"facts": {"type": "object", "properties": {
|
|
|
|
|
|
fact_id: {"type": "string"} for fact_id in editable
|
|
|
|
|
|
}, "additionalProperties": False},
|
|
|
|
|
|
},
|
|
|
|
|
|
"required": ["title", "first_line", "facts"],
|
|
|
|
|
|
"additionalProperties": False,
|
|
|
|
|
|
}},
|
|
|
|
|
|
strip_reasoning=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
client = LlmClient(base_url=settings.llm_base_url, timeout=45)
|
|
|
|
|
|
|
|
|
|
|
|
async def complete_safely(candidate: LlmRequest) -> str:
|
|
|
|
|
|
last_error: LlmUnavailable | None = None
|
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await client.complete(candidate, use_cache=False)
|
|
|
|
|
|
except LlmUnavailable as exc:
|
|
|
|
|
|
last_error = exc
|
|
|
|
|
|
if not any(marker in str(exc) for marker in (
|
|
|
|
|
|
"служебные токены", "пустой ответ модели"
|
|
|
|
|
|
)):
|
|
|
|
|
|
raise
|
|
|
|
|
|
assert last_error is not None
|
|
|
|
|
|
raise last_error
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw = await complete_safely(request)
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal = parse_proposal(
|
|
|
|
|
|
raw, source, require_fact_change=require_fact_change, instruction=instruction,
|
|
|
|
|
|
restrict_first_line_facts=style_only,
|
|
|
|
|
|
)
|
|
|
|
|
|
except GenerationError as first_error:
|
|
|
|
|
|
feedback = (
|
|
|
|
|
|
f"Предыдущий ответ отклонён: {first_error}. "
|
|
|
|
|
|
"Верни исправленный JSON той же схемы. "
|
|
|
|
|
|
"Значения facts должны быть ответами заявителя, не вопросами. "
|
|
|
|
|
|
"Запрещены плейсхолдеры вида {fact_id}: пиши конкретный русский текст. "
|
|
|
|
|
|
)
|
|
|
|
|
|
if target:
|
|
|
|
|
|
old = next(fact.value for fact in source.facts if fact.id == target)
|
|
|
|
|
|
feedback += (
|
|
|
|
|
|
f"Обязательно замени факт {target}. Старый ответ: {old}. "
|
|
|
|
|
|
f"Новый ответ должен учитывать запрос: {instruction}. "
|
|
|
|
|
|
"Не повторяй старый ответ дословно."
|
|
|
|
|
|
)
|
|
|
|
|
|
retry = LlmRequest(
|
|
|
|
|
|
messages=[*request.messages, {"role": "assistant", "content": raw},
|
|
|
|
|
|
{"role": "user", "content": feedback}],
|
|
|
|
|
|
model=request.model, temperature=0.8, max_tokens=request.max_tokens,
|
|
|
|
|
|
response_format=request.response_format,
|
|
|
|
|
|
strip_reasoning=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
raw = await complete_safely(retry)
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal = parse_proposal(
|
|
|
|
|
|
raw, source, require_fact_change=require_fact_change, instruction=instruction,
|
|
|
|
|
|
restrict_first_line_facts=style_only,
|
|
|
|
|
|
)
|
|
|
|
|
|
except GenerationError:
|
|
|
|
|
|
fallback = style_fallback(source, instruction) if not require_fact_change else None
|
|
|
|
|
|
if fallback is None:
|
|
|
|
|
|
raise
|
|
|
|
|
|
# Даже детерминированный fallback проходит те же запреты утечек.
|
|
|
|
|
|
proposal = parse_proposal(
|
|
|
|
|
|
json.dumps(fallback, ensure_ascii=False), source,
|
|
|
|
|
|
require_fact_change=False, instruction=instruction,
|
|
|
|
|
|
restrict_first_line_facts=style_only,
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await client.aclose()
|
|
|
|
|
|
# Проверка на фиксированном ID гарантирует, что генерация не выдаст
|
|
|
|
|
|
# черновик, который потом невозможно утвердить из-за сломанной схемы.
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal_body(source, "ai-validation", proposal)
|
|
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
raise GenerationError(f"сгенерированный сценарий не прошёл проверку: {exc}") from exc
|
|
|
|
|
|
return proposal
|