lct-hack/backend/app/scenarios/generation.py
2026-09-26 17:13:45 +00:00

634 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем.
Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию,
список оповещения и протокол оценки она менять не может. Публикация — отдельное
действие преподавателя после просмотра черновика и эталона.
"""
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
if not fact.refined and not fact.refine_on
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