"""Преподавательский цикл: шаблон → черновик → проверка → утверждение.""" from pathlib import Path import pytest from fastapi.testclient import TestClient from app.api.http import scenarios as scenarios_api from app.config import get_settings from app.dialog.llm import LlmUnavailable from app.main import app from app.scenarios import store from app.scenarios.editor import merge_patch, template_copy, validate from app.scenarios import generation from app.scenarios.generation import (GenerationError, correction_target, full_proposal_body, parse_full_proposal, parse_proposal, proposal_body, style_fallback) from app.scenarios.loader import ScenarioError, load_file LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" class FakeSession: """Минимальная транзакционная замена БД для проверки HTTP без Postgres.""" def __init__(self): self.rows = {} def add(self, row): self.rows[row.id] = row async def get(self, model, key): return self.rows.get(key) async def commit(self): pass async def scalars(self, query): expression = query.column_descriptions[0]["expr"] if getattr(expression, "key", None) == "id": owner_login = query.compile().params.get("owner_login_1") return [ row.id for row in self.rows.values() if row.status == "published" and row.owner_login == owner_login ] return [row for row in self.rows.values() if row.status == "published"] @pytest.fixture def client(monkeypatch): db = FakeSession() async def no_audit(*args, **kwargs): pass async def no_restore(*args, **kwargs): return 0 app.dependency_overrides[scenarios_api.scenario_session] = lambda: db monkeypatch.setattr(scenarios_api, "audit", no_audit) monkeypatch.setattr(store, "restore_published", no_restore) with TestClient(app) as test_client: yield test_client app.dependency_overrides.clear() def test_template_copy_is_local_independent_and_valid(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) body = template_copy(source, "draft-example") assert body["id"] == "draft-example" assert body["extends"] is None assert body["ticket"] is None assert body["ground_truth"].keys() == {"address", "victims"} assert validate(body).ground_truth.dds == source.ground_truth.dds assert source.id != body["id"] # оригинал не меняется def test_editor_rejects_derived_truth_and_missing_fact(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) body = template_copy(source, "draft-example") with pytest.raises(ScenarioError, match="ground_truth"): merge_patch(body, {"ground_truth": {"dds": "02"}}) with pytest.raises(ScenarioError, match="id сценария"): merge_patch(body, {"id": "other"}) broken = merge_patch(body, {"facts": []}) with pytest.raises(ScenarioError, match="нужны факты"): validate(broken) def test_ai_proposal_changes_only_story_and_keeps_reference(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) proposal = parse_proposal('''```json {"title":"Ночной пожар на балконе","first_line":"Алло, помогите, балкон горит и всё в дыму!", "facts":{"f_what_burns":"на балконе открытое пламя","f_smoke":"дым заполнил подъезд"}} ```''', source) body = proposal_body(source, "ai-example", proposal) generated = validate(body) assert generated.facts[0].value == source.facts[0].value assert generated.ground_truth.address == source.ground_truth.address assert generated.ground_truth.victims == source.ground_truth.victims assert generated.ground_truth.incident_code == source.ground_truth.incident_code assert generated.facts[1].value == "на балконе открытое пламя" def test_ai_proposal_rejects_address_change_and_premature_number(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) with pytest.raises(GenerationError, match="защищённые"): parse_proposal('{"title":"Другой пожар","first_line":"Алло, помогите, у нас горит балкон!",' '"facts":{"f_address":"другой адрес"}}', source) with pytest.raises(GenerationError, match="числовые"): parse_proposal('{"title":"Другой пожар","first_line":"Горит дом 99, срочно приезжайте!",' '"facts":{}}', source) with pytest.raises(GenerationError, match="числовые"): parse_proposal('{"title":"Другой пожар","first_line":"Один человек пострадал, помогите!",' '"facts":{}}', source) with pytest.raises(GenerationError, match="вопрос вместо ответа"): parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",' '"facts":{"f_smoke":"Куда идёт дым?"}}', source) with pytest.raises(GenerationError, match="плейсхолдер"): parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",' '"facts":{"f_smoke":"{f_smoke}"}}', source) with pytest.raises(GenerationError, match="новых обстоятельств"): parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",' '"facts":{}}', source) revised = parse_proposal( '{"title":"Пожар — короткая реплика","first_line":"Пожар! Помогите!","facts":{}}', source, require_fact_change=False, instruction="Сделай реплику короче", restrict_first_line_facts=True, ) assert revised["first_line"] == "Пожар! Помогите!" with pytest.raises(GenerationError, match="не запрошенные"): parse_proposal( '{"title":"Пожар — короткая реплика",' '"first_line":"Горим! Не успел перекрыть газ!","facts":{}}', source, require_fact_change=False, instruction="Сделай реплику короче", restrict_first_line_facts=True, ) def test_revision_target_is_selected_only_when_comment_names_a_fact(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) assert correction_target(source, "Сделай дым чёрным и плотным") == "f_smoke" assert correction_target(source, "Сделай первую реплику короче и тревожнее") is None shortened = style_fallback(source, "Сделай первую реплику короче") assert shortened["first_line"] == "Помогите! Горим!" assert style_fallback(source, "Сделай тон спокойнее") is None def test_full_ai_proposal_replaces_all_facts_and_derives_soft_truth(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) raw = '''{ "title":"Пожар на балконе жилого дома", "first_line":"Помогите, с балкона валит чёрный дым!", "facts":{ "f_address":"улица Новая, 8, корпус 2, первый этаж", "f_what_burns":"на балконе жилого дома видно открытое пламя", "f_people":"все вышли; Пострадавших: 0", "f_smoke":"густой чёрный дым идёт в лестничный холл", "f_gas":"газового оборудования в квартире нет" }, "ground_truth":{"address":"улица Новая, 8","victims":0} }''' proposal = parse_full_proposal(raw, source) body = full_proposal_body(source, "ai-full-example", proposal) generated = validate(body) assert generated.ground_truth.address == "улица Новая, 8" assert generated.ground_truth.victims == 0 assert generated.ground_truth.incident_code == source.ground_truth.incident_code assert generated.facts[0].value.startswith("улица Новая") assert all(fact.refined is None and fact.refine_on is None for fact in generated.facts) assert parse_full_proposal(raw.replace("Пострадавших: 0", "пострадавших нет"), source) inconsistent = raw.replace("Пожар на балконе жилого дома", "Пожар в мастерской") \ .replace("с балкона", "из мастерской") \ .replace("на балконе жилого дома видно открытое пламя", "горит оборудование мастерской") with pytest.raises(ScenarioError, match="сюжет не подтверждает путь ЕКП"): full_proposal_body(source, "ai-full-inconsistent", parse_full_proposal(inconsistent, source)) with pytest.raises(GenerationError, match="числа пострадавших"): parse_full_proposal(raw.replace("Пострадавших: 0", "Пострадавших не видно"), source) with pytest.raises(GenerationError, match="адрес не совпадает"): parse_full_proposal(raw.replace('"address":"улица Новая, 8"', '"address":"улица Другая, 9"'), source) @pytest.mark.asyncio async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) requests = [] class FakeClient: def __init__(self, **kwargs): self.answers = iter([ '{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",' '"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}', '{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",' '"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}', ]) async def complete(self, request, **kwargs): requests.append(request) return next(self.answers) async def aclose(self): pass monkeypatch.setattr(generation, "LlmClient", FakeClient) proposal = await generation.generate( source, "Ночной пожар на балконе, густой дым в подъезде, заявитель паникует" ) assert proposal["facts"]["f_smoke"] == "лестница уже заполнена густым дымом" assert len(requests) == 2 assert requests[0].response_format["type"] == "json_object" assert set(requests[0].response_format["schema"]["properties"]["facts"]["properties"]) == {"f_smoke"} assert "f_smoke" in requests[1].messages[-1]["content"] @pytest.mark.asyncio async def test_ai_generation_retries_one_qwen_control_token_failure(monkeypatch): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) class FakeClient: calls = 0 def __init__(self, **kwargs): pass async def complete(self, request, **kwargs): self.calls += 1 if self.calls == 1: raise LlmUnavailable("ответ содержит служебные токены модели") return ('{"title":"Ночной дым","first_line":"Алло, всё в дыму, помогите!",' '"facts":{"f_smoke":"лестница заполнена густым дымом"}}') async def aclose(self): pass monkeypatch.setattr(generation, "LlmClient", FakeClient) proposal = await generation.generate(source, "Сделай дым густым и чёрным") assert proposal["facts"]["f_smoke"].startswith("лестница") @pytest.mark.asyncio async def test_description_generation_asks_small_model_one_fact_at_a_time(monkeypatch): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) answers = iter([ '{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}', '{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}', '{"value":"на балконе жилого дома видно открытое пламя"}', '{"value":"все люди успели выйти","victims":0}', '{"value":"густой дым вышел в подъезд"}', '{"value":"газового оборудования нет, но нужно всё проверить"}', ]) requests = [] class FakeClient: def __init__(self, **kwargs): pass async def complete(self, request, **kwargs): requests.append(request) return next(answers) async def aclose(self): pass monkeypatch.setattr(generation, "LlmClient", FakeClient) proposal = await generation.generate_from_description( source, "Пожар в мастерской, все вышли, густой дым в подъезде" ) assert len(requests) == 6 assert requests[0].messages[-1]["content"].find("classifier_signs") >= 0 assert all( "classifier_signs" in request.messages[1]["content"] for request in requests[1:] ) assert proposal["first_line"].endswith("!") assert proposal["ground_truth"] == { "address": "ул. Маршака, д. 12, этаж 1", "victims": 0, } assert proposal["facts"]["f_people"].endswith("Пострадавших: 0") assert proposal["facts"]["f_gas"] == "газового оборудования нет" @pytest.mark.asyncio async def test_description_generation_retries_fact_that_is_a_question(monkeypatch): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) answers = iter([ '{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}', '{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}', '{"value":"на балконе жилого дома видно открытое пламя"}', '{"value":"Есть ли пострадавшие?","victims":1}', '{"value":"один человек получил ожог; Пострадавших: 1","victims":1}', '{"value":"густой дым вышел в подъезд"}', '{"value":"газового оборудования нет"}', ]) requests = [] class FakeClient: def __init__(self, **kwargs): pass async def complete(self, request, **kwargs): requests.append(request) return next(answers) async def aclose(self): pass monkeypatch.setattr(generation, "LlmClient", FakeClient) proposal = await generation.generate_from_description( source, "Пожар в мастерской, один человек получил ожог, густой дым в подъезде" ) assert len(requests) == 7 assert "Ответ отклонён" in requests[4].messages[-1]["content"] assert proposal["ground_truth"]["victims"] == 1 assert proposal["facts"]["f_people"].endswith("Пострадавших: 1") def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client): source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2") assert client.post("/api/auth/dev-token").status_code == 200 response = client.post( "/api/scenarios/drafts/from-template", json={"source_id": source.id, "title": "Учебная копия"}, ) assert response.status_code == 201, response.text draft = response.json() draft_id = draft["id"] assert draft["generation"] == "template_copy" assert draft["status"] == "draft" assert store.get(draft_id) is None assert client.get(f"/api/scenarios/{draft_id}").status_code == 404 changed = client.patch( f"/api/scenarios/drafts/{draft_id}", json={"first_line": "Соседи! В доме дым!"}, ) assert changed.status_code == 200, changed.text assert changed.json()["body"]["first_line"] == "Соседи! В доме дым!" check = client.post(f"/api/scenarios/drafts/{draft_id}/validate") assert check.status_code == 200 and check.json()["valid"] approved = client.post(f"/api/scenarios/drafts/{draft_id}/approve") assert approved.status_code == 200, approved.text assert approved.json()["status"] == "published" assert store.get(draft_id).first_line == "Соседи! В доме дым!" assert client.get(f"/api/scenarios/{draft_id}").status_code == 200 assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404 def test_ai_draft_requires_instructor_review_before_publication(client, monkeypatch): async def fake_generate(source, instruction, **kwargs): assert source.id == "fire-apartment-l2" assert "ночью" in instruction return {"title": "Ночной пожар на балконе", "first_line": "Помогите, у нас горит балкон!", "facts": {"f_smoke": "густой дым уже в подъезде"}} monkeypatch.setattr(scenarios_api, "generate", fake_generate) client.post("/api/auth/dev-token") response = client.post("/api/scenarios/drafts/generate", json={ "source_id": "fire-apartment-l2", "instruction": "Пожар начался ночью", }) assert response.status_code == 201, response.text draft = response.json() assert draft["generation"] == "ai_variant" assert draft["id"].startswith("ai-") assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!" assert store.get(draft["id"]) is None assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404 assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"] assert client.post(f"/api/scenarios/drafts/{draft['id']}/approve").status_code == 200 assert store.get(draft["id"]).first_line == "Помогите, у нас горит балкон!" def test_description_generates_new_story_and_reference_answers(client, monkeypatch): async def fake_generate(source, description): assert source.id == "fire-apartment-l2" assert "мастерской" in description return { "title": "Пожар на балконе жилого дома", "first_line": "Помогите, с балкона валит чёрный дым!", "facts": { "f_address": "улица Новая, 8, корпус 2, первый этаж", "f_what_burns": "на балконе жилого дома видно открытое пламя", "f_people": "все вышли; Пострадавших: 0", "f_smoke": "густой чёрный дым идёт в лестничный холл", "f_gas": "газового оборудования в квартире нет", }, "ground_truth": {"address": "улица Новая, 8", "victims": 0}, } monkeypatch.setattr(scenarios_api, "generate_from_description", fake_generate) client.post("/api/auth/dev-token") response = client.post("/api/scenarios/drafts/generate-from-description", json={ "source_id": "fire-apartment-l2", "description": "Пожар в мастерской жилого дома, все успели выйти", }) assert response.status_code == 201, response.text draft = response.json() assert draft["generation"] == "ai_full" assert draft["id"].startswith("ai-full-") assert draft["body"]["ground_truth"] == {"address": "улица Новая, 8", "victims": 0} assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"] assert store.get(draft["id"]) is None def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch): answers = iter([ {"title": "Ночной пожар на балконе", "first_line": "Помогите, на балконе горит!", "facts": {"f_smoke": "густой дым уже в подъезде"}}, {"title": "Ночной пожар с сильным дымом", "first_line": "Алло, лестница вся в дыму, помогите!", "facts": {"f_smoke": "чёрный дым полностью закрыл лестничную площадку"}}, ]) comments = [] async def fake_generate(source, instruction, **kwargs): comments.append(instruction) if len(comments) > 1: assert kwargs["require_fact_change"] is False return next(answers) monkeypatch.setattr(scenarios_api, "generate", fake_generate) client.post("/api/auth/dev-token") created = client.post("/api/scenarios/drafts/generate", json={ "source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым", }).json() draft_id = created["id"] revised = client.post(f"/api/scenarios/drafts/{draft_id}/revise", json={ "comment": "Сделай дым чёрным и закрой им площадку", }) assert revised.status_code == 200, revised.text body = revised.json()["body"] assert revised.json()["id"] == draft_id assert body["title"] == "Ночной пожар с сильным дымом" assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж" assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный") assert comments[-1] == "Сделай дым чёрным и закрой им площадку" assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"] def test_ai_editor_works_in_demo_lite_without_database(monkeypatch): monkeypatch.setenv("DEMO_NO_DB", "true") monkeypatch.setenv("DEV_AUTH_BYPASS", "true") get_settings.cache_clear() async def fake_generate(source, instruction, **kwargs): return {"title": "Ночной дым на балконе", "first_line": "Алло, помогите, на балконе горит!", "facts": {"f_smoke": "лестница уже заполнена густым дымом"}} monkeypatch.setattr(scenarios_api, "generate", fake_generate) try: with TestClient(app) as demo: assert demo.post("/api/auth/dev-token").status_code == 200 created = demo.post("/api/scenarios/drafts/generate", json={ "source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым", }) assert created.status_code == 201, created.text draft_id = created.json()["id"] assert demo.get(f"/api/scenarios/drafts/{draft_id}").status_code == 200 assert demo.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"] assert demo.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200 assert demo.get(f"/api/scenarios/{draft_id}").status_code == 200 finally: get_settings.cache_clear() def test_invalid_draft_cannot_be_approved(client): client.post("/api/auth/dev-token") response = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}, ) draft_id = response.json()["id"] assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"facts": []}).status_code == 200 check = client.post(f"/api/scenarios/drafts/{draft_id}/validate") assert check.json()["valid"] is False assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422 assert store.get(draft_id) is None def test_unresolved_fact_placeholder_cannot_be_validated_or_approved(client): client.post("/api/auth/dev-token") created = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}, ).json() draft_id = created["id"] facts = created["body"]["facts"] facts[1]["value"] = "{f_what_burns}" assert client.patch( f"/api/scenarios/drafts/{draft_id}", json={"facts": facts} ).status_code == 200 check = client.post(f"/api/scenarios/drafts/{draft_id}/validate") assert check.json()["valid"] is False assert "плейсхолдер" in check.json()["errors"][0] assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422 def test_unauthenticated_user_cannot_manage_drafts(client): response = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"} ) assert response.status_code == 401 def test_instructor_archives_and_restores_scenario_without_deleting_history(client): client.post("/api/auth/dev-token") created = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2", "title": "Сценарий для проверки архива"}, ) assert created.status_code == 201, created.text scenario_id = created.json()["id"] approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve") assert approved.status_code == 200, approved.text original = store.get(scenario_id) assert original is not None archived = client.delete(f"/api/scenarios/{scenario_id}") assert archived.status_code == 200, archived.text assert archived.json()["status"] == "archived" assert store.get(scenario_id) is None assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()} assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404 restored = client.post(f"/api/scenarios/{scenario_id}/restore") assert restored.status_code == 200, restored.text assert restored.json()["status"] == "published" assert store.get(scenario_id).title == original.title assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()} def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkeypatch): from app.api.auth import Principal from app.domain.roles import Role identity = {"login": "teacher-one"} def instructor(_request, *_roles): return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR) monkeypatch.setattr(scenarios_api, "require", instructor) monkeypatch.setattr(scenarios_api, "current", instructor) created = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2", "title": "Личный черновик"}, ) assert created.status_code == 201, created.text scenario_id = created.json()["id"] identity["login"] = "teacher-two" assert client.get(f"/api/scenarios/drafts/{scenario_id}").status_code == 404 assert client.patch( f"/api/scenarios/drafts/{scenario_id}", json={"title": "Чужая правка"} ).status_code == 404 assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 404 def test_instructor_cannot_archive_another_instructors_published_scenario(client, monkeypatch): from app.api.auth import Principal from app.domain.roles import Role identity = {"login": "teacher-one"} def instructor(_request, *_roles): return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR) monkeypatch.setattr(scenarios_api, "require", instructor) monkeypatch.setattr(scenarios_api, "current", instructor) created = client.post( "/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2", "title": "Публикация автора"}, ) assert created.status_code == 201, created.text scenario_id = created.json()["id"] assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 200 scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id) assert scenario["can_manage"] is True identity["login"] = "teacher-two" scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id) assert scenario["can_manage"] is False assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404 def test_scenario_in_active_lesson_cannot_be_archived(client, monkeypatch): client.post("/api/auth/dev-token") monkeypatch.setattr(scenarios_api.hub, "has_active_scenario", lambda _scenario_id: True) response = client.delete("/api/scenarios/fire-apartment-l2") assert response.status_code == 409 assert response.json()["detail"] == "scenario_is_used_by_active_session" @pytest.mark.asyncio async def test_approved_scenario_restores_from_local_database(): source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) db = FakeSession() row = await store.create_draft(db, source=source) approved = await store.approve_draft(db, row) store.set_library([source]) # имитация нового процесса после чтения YAML assert store.get(approved.id) is None assert await store.restore_published(db) == 1 assert store.get(approved.id).title == approved.title assert await store.restore_published(db) == 0