Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
634
backend/app/scenarios/generation.py
Normal file
634
backend/app/scenarios/generation.py
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем.
|
||||
|
||||
Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию,
|
||||
список оповещения и протокол оценки она менять не может. Публикация — отдельное
|
||||
действие преподавателя после просмотра черновика и эталона.
|
||||
"""
|
||||
|
||||
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.hidden and 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
|
||||
Loading…
Reference in a new issue