Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -5,6 +5,7 @@
"""
from copy import deepcopy
import re
from pydantic import ValidationError
@ -12,6 +13,31 @@ from app.scenarios.loader import ScenarioError, _derive_ground_truth
from app.scenarios.schema import Scenario
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
def classifier_sign_gaps(signs: list[str], narrative: list[str]) -> list[str]:
"""Найти признаки ЕКП, которые не подтверждены текстом сценария.
Сравниваются трёхбуквенные основы, поэтому русские окончания
(``жилой дом`` / ``жилого дома``) не создают ложного расхождения. Это
намеренно строгая, детерминированная граница: модель не может заявить
один объект, а в карточку незаметно положить код другого.
"""
def stems(value: str) -> set[str]:
return {
word[:3]
for word in re.findall(r"[0-9a-zа-яё]+", value.casefold())
if len(word) >= 3
}
present = stems(" ".join(narrative))
gaps: list[str] = []
for sign in signs:
required = stems(sign)
if required and not required <= present:
gaps.append(sign)
return gaps
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
@ -92,9 +118,26 @@ def validate(body: dict) -> Scenario:
if item.fact and item.fact not in known:
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
for fact in scenario.facts:
if PLACEHOLDER.search(fact.value) or (fact.refined and PLACEHOLDER.search(fact.refined)):
raise ScenarioError(
f"факт {fact.id}: остался плейсхолдер вместо конкретного ответа заявителя"
)
question_id = fact.reveal_on.question if fact.reveal_on else None
if question_id and question_id not in question_ids:
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
if fact.refine_on and fact.refine_on not in question_ids:
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
# Для старых карточек и безопасных вариаций классификатор уже утверждён
# заказчиком. Новый свободный сюжет обязан доказать свой путь ЕКП текстом.
if scenario.id.startswith("ai-full-"):
gaps = classifier_sign_gaps(
scenario.signs,
[scenario.title, scenario.first_line]
+ [fact.value for fact in scenario.facts]
+ [fact.refined for fact in scenario.facts if fact.refined],
)
if gaps:
raise ScenarioError(
"signs: сюжет не подтверждает путь ЕКП: " + ", ".join(gaps)
)
return _derive_ground_truth(scenario)

View 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

View file

@ -16,6 +16,18 @@ from app.scenarios.schema import Scenario
from app.scenarios import editor
_library: dict[str, Scenario] = {}
_demo_drafts: dict[str, ScenarioRow] = {}
_demo_archived: dict[str, Scenario] = {}
_demo_scenario_owners: dict[str, str] = {}
_demo_archived_owners: dict[str, str] = {}
def reset_demo_drafts() -> None:
"""Черновики и архив demo-lite живут только в текущем процессе."""
_demo_drafts.clear()
_demo_archived.clear()
_demo_scenario_owners.clear()
_demo_archived_owners.clear()
def set_library(scenarios: list[Scenario]) -> None:
@ -56,6 +68,7 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
row.topics = scenario.topics
row.modes = scenario.modes
row.body = payload
row.owner_login = None
await db.commit()
return len(scenarios)
@ -66,21 +79,128 @@ async def restore_published(db: AsyncSession) -> int:
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
её источником остаются проверенные YAML из репозитория.
"""
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
restored = 0
rows = await db.scalars(select(ScenarioRow).where(
ScenarioRow.status.in_(["published", "archived"])
))
delta = 0
for row in rows:
if row.status == "archived":
if row.owner_login:
_demo_scenario_owners.pop(row.id, None)
_demo_archived_owners[row.id] = row.owner_login
if _library.pop(row.id, None) is not None:
delta -= 1
continue
if row.owner_login:
_demo_scenario_owners[row.id] = row.owner_login
if row.id in _library:
continue
_library[row.id] = Scenario.model_validate(row.body)
restored += 1
return restored
delta += 1
return delta
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
if db is None:
return {
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
if owner == owner_login and scenario_id in _library
}
rows = await db.scalars(select(ScenarioRow.id).where(
ScenarioRow.owner_login == owner_login,
ScenarioRow.status == "published",
))
values = set(rows)
# Test doubles and alternate SQLAlchemy result wrappers may return rows
# for a scalar selection; normalize them without leaking owner data.
return {value if isinstance(value, str) else value.id for value in values}
async def archive(
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
) -> Scenario | None:
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
scenario = _library.get(scenario_id)
if scenario is None:
return None
if db is None:
owner = _demo_scenario_owners.get(scenario_id)
if owner_login is not None and owner != owner_login:
return None
_demo_archived[scenario_id] = scenario
if owner is not None:
_demo_archived_owners[scenario_id] = owner
_demo_scenario_owners.pop(scenario_id, None)
else:
row = await db.get(ScenarioRow, scenario_id)
if owner_login is not None and (row is None or row.owner_login != owner_login):
return None
if row is None:
row = ScenarioRow(
id=scenario.id,
title=scenario.title,
incident_type=scenario.type.value,
level=scenario.level.value,
topics=scenario.topics,
modes=scenario.modes,
body=scenario.model_dump(mode="json"),
)
db.add(row)
row.status = "archived"
await db.commit()
_library.pop(scenario_id, None)
return scenario
async def restore_archived(
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
) -> Scenario | None:
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
if db is None:
owner = _demo_archived_owners.get(scenario_id)
if owner_login is not None and owner != owner_login:
return None
scenario = _demo_archived.pop(scenario_id, None)
if scenario is not None and owner is not None:
_demo_scenario_owners[scenario_id] = owner
_demo_archived_owners.pop(scenario_id, None)
else:
row = await db.get(ScenarioRow, scenario_id)
if (row is None or row.status != "archived"
or owner_login is not None and row.owner_login != owner_login):
return None
# В опубликованном body уже лежат вычисленные поля ground_truth (ЕКП,
# ДДС, список оповещения). Редакторская validate предназначена для
# черновика до вычисления и закономерно запрещает такие поля.
scenario = Scenario.model_validate(row.body)
row.status = "published"
await db.commit()
if scenario is None:
return None
publish(scenario)
return scenario
async def create_draft(
db: AsyncSession, *, source: Scenario, title: str | None = None
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
proposal: dict | None = None, full_proposal: dict | None = None,
owner_login: str | None = None,
) -> ScenarioRow:
draft_id = f"draft-{uuid4().hex}"
body = editor.template_copy(source, draft_id, title)
if proposal is not None and full_proposal is not None:
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
prefix = "ai-full" if full_proposal is not None else "ai" if proposal is not None else "draft"
draft_id = f"{prefix}-{uuid4().hex}"
if full_proposal is not None:
from app.scenarios.generation import full_proposal_body
body = full_proposal_body(source, draft_id, full_proposal)
elif proposal is None:
body = editor.template_copy(source, draft_id, title)
else:
from app.scenarios.generation import proposal_body
body = proposal_body(source, draft_id, proposal)
row = ScenarioRow(
id=draft_id,
title=body["title"],
@ -89,26 +209,53 @@ async def create_draft(
topics=body["topics"],
modes=body["modes"],
status="draft",
owner_login=owner_login,
body=body,
)
db.add(row)
await db.commit()
if db is None:
_demo_drafts[row.id] = row
else:
db.add(row)
await db.commit()
return row
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
row = await db.get(ScenarioRow, scenario_id)
return row if row is not None and row.status == "draft" else None
async def draft(
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
) -> ScenarioRow | None:
row = _demo_drafts.get(scenario_id) if db is None else await db.get(ScenarioRow, scenario_id)
if row is None or row.status != "draft":
return None
if owner_login is not None and row.owner_login != owner_login:
return None
return row
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
row.body = editor.merge_patch(row.body, patch)
row.title = str(row.body.get("title") or "")[:200]
await db.commit()
if db is not None:
await db.commit()
return row
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
from app.scenarios.generation import proposal_body
source = editor.validate(row.body)
row.body = proposal_body(source, row.id, proposal)
row.title = row.body["title"]
row.incident_type = row.body["type"]
row.level = row.body["level"]
row.topics = row.body["topics"]
row.modes = row.body["modes"]
if db is not None:
await db.commit()
return row
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
scenario = editor.validate(row.body)
row.title = scenario.title
row.incident_type = scenario.type.value
@ -117,6 +264,11 @@ async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
row.modes = scenario.modes
row.body = scenario.model_dump(mode="json")
row.status = "published"
await db.commit()
if db is None:
_demo_drafts.pop(row.id, None)
if row.owner_login is not None:
_demo_scenario_owners[row.id] = row.owner_login
else:
await db.commit()
publish(scenario)
return scenario