"""持久有限返修:真实 S02 轮任务、作者选择、恢复与失败停止。""" from concurrent.futures import ThreadPoolExecutor from dataclasses import replace from datetime import UTC, datetime, timedelta from decimal import Decimal import pytest import test_模型修订任务 as 修订测试 from muse.任务运行.接口 import 任务状态, 任务预算计划, 角色预算, 预算管理, 额度策略 from muse.共享.错误 import Muse错误 from muse.审校修订.接口 import 审校服务, 返修决定 from muse.正文写作.接口 import 文本节点, 正文草稿, 段落 from muse.编排.审校修订 import 决定有限返修, 发起有限返修, 读取有限返修 pytestmark = pytest.mark.数据库 生成环境 = 修订测试.生成环境 修订环境 = 修订测试.修订环境 def 批准轮预算(env, task_id: str) -> None: budget = 预算管理(env["库"], "synthetic") budget.登记策略(额度策略("synthetic", "1", Decimal("20"), 40)) budget.登记任务预算( task_id, 任务预算计划( Decimal("4"), (角色预算("writer", 2, 2, Decimal("1")),), "revision-session-approval", datetime.now(UTC) + timedelta(minutes=10), ), ) def 候选输出(env, replacement: str = "") -> dict: return { "edits": [ { "finding_id": env["发现"], "action": "replace", "replacement": replacement, "reason": "合成修改建议", } ] } def 发起并产出候选(env, *, command: str, max_rounds: int = 3) -> dict: face = 发起有限返修( env["装配"], env["作者"], command, env["授权"], 配置ID="gen-config", 最大轮数=max_rounds, ) task_id = face["rounds"][-1]["task_id"] 批准轮预算(env, task_id) env["剧本"].append({"类型": "文本", "文本": 候选输出(env)}) 修订测试.跑(env, task_id) return 读取有限返修(env["装配"], env["作者"], face["session_id"]) @pytest.mark.case_id( "NC-w23-236001", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["首轮关联真实任务候选且没有作者重试不创建下一轮"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_首轮关联真实任务候选且没有作者重试不创建下一轮__236001(修订环境): env = 修订环境 started = 发起有限返修( env["装配"], env["作者"], "revision-session", env["授权"], 配置ID="gen-config", 最大轮数=3, ) assert started["state"] == "running" assert started["current_round"] == 1 assert len(started["rounds"]) == 1 task_id = started["rounds"][0]["task_id"] assert task_id 批准轮预算(env, task_id) env["剧本"].append({"类型": "文本", "文本": 候选输出(env)}) assert 修订测试.跑(env, task_id).状态.value == "completed" face = 读取有限返修(env["装配"], env["作者"], started["session_id"]) assert face["state"] == "awaiting_author" assert face["stop_reason"] is None assert len(face["rounds"]) == 1 assert face["rounds"][0]["task_state"] == "completed" assert face["rounds"][0]["result"]["outcome"] == "candidate" assert face["rounds"][0]["candidate_id"] == face["rounds"][0]["result"]["candidate_id"] candidate = env["正文"].读取候选(env["作者"], face["rounds"][0]["candidate_id"]) assert candidate["origin"] == "model" and candidate["decision"] == "undecided" assert len(env["收到"]) == 1 assert len(env["装配"].任务运行.列出任务(env["作者"].作者, 作品ID="gen-work")) == 1 @pytest.mark.case_id( "NC-w23-236002", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["作者选择关闭后重装可恢复且候选不自动采纳"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) @pytest.mark.parametrize( "choice,reason,selected", [ ("original", "author_selected_original", False), ("candidate", "author_selected_candidate", True), ], ) def test_作者选择关闭后重装可恢复且候选不自动采纳__236002(修订环境, choice, reason, selected): env = 修订环境 before = 发起并产出候选(env, command="close-session") candidate_id = before["rounds"][0]["candidate_id"] closed = 决定有限返修( env["装配"], env["作者"], "close-choice", before["session_id"], 返修决定(1, candidate_id, choice), ) assert closed["state"] == "closed" and closed["stop_reason"] == reason assert closed["author_choice"] == choice assert closed["selected_candidate_id"] == (candidate_id if selected else None) reloaded = 审校服务(env["库"]).读取返修会话( env["作者"], env["装配"].任务运行, before["session_id"] ) assert reloaded == closed assert env["正文"].读取候选(env["作者"], candidate_id)["decision"] == "undecided" assert env["正文"].读取正文(env["作者"], "ch-4") == env["原稿"] @pytest.mark.case_id( "NC-w23-236003", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["作者明确重试才创建第二个真实任务且零修改停止"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_作者明确重试才创建第二个真实任务且零修改停止__236003(修订环境): env = 修订环境 first = 发起并产出候选(env, command="retry-session") candidate_id = first["rounds"][0]["candidate_id"] second = 决定有限返修( env["装配"], env["作者"], "retry-choice", first["session_id"], 返修决定(1, candidate_id, "retry"), ) assert second["state"] == "running" and second["current_round"] == 2 assert len(second["rounds"]) == 2 assert second["rounds"][0]["task_id"] != second["rounds"][1]["task_id"] task_id = second["rounds"][1]["task_id"] 批准轮预算(env, task_id) env["剧本"].append( { "类型": "文本", "文本": { "edits": [ { "finding_id": env["发现"], "action": "retain", "replacement": "", "reason": "本轮保留原文", } ] }, } ) 修订测试.跑(env, task_id) stopped = 读取有限返修(env["装配"], env["作者"], first["session_id"]) assert stopped["state"] == "closed" and stopped["stop_reason"] == "zero_change" assert stopped["rounds"][1]["outcome"] == "original" assert len(env["收到"]) == 2 @pytest.mark.case_id( "NC-w23-236004", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["轮任务登记中断以确定性命令恢复且不重复创建"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_轮任务登记中断以确定性命令恢复且不重复创建__236004(修订环境, monkeypatch): env = 修订环境 svc = env["装配"].要求审校() register = svc.登记返修轮任务 calls = 0 def interrupt_once(identity, session_id, task): nonlocal calls calls += 1 if calls == 1: raise RuntimeError("模拟任务创建后轮登记中断") return register(identity, session_id, task) monkeypatch.setattr(svc, "登记返修轮任务", interrupt_once) with pytest.raises(RuntimeError, match="轮登记中断"): 发起有限返修( env["装配"], env["作者"], "interrupted-session", env["授权"], 配置ID="gen-config", 最大轮数=3, ) before = env["装配"].任务运行.列出任务(env["作者"].作者, 作品ID="gen-work") assert len(before) == 1 recovered = 发起有限返修( env["装配"], env["作者"], "interrupted-session", env["授权"], 配置ID="gen-config", 最大轮数=3, ) assert recovered["state"] == "running" assert recovered["rounds"][0]["task_id"] == before[0].任务ID assert len(env["装配"].任务运行.列出任务(env["作者"].作者, 作品ID="gen-work")) == 1 @pytest.mark.case_id( "NC-w23-236005", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["同命令重放幂等异参数冲突且并发重试只增一轮"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_同命令重放幂等异参数冲突且并发重试只增一轮__236005(修订环境): env = 修订环境 first = 发起并产出候选(env, command="concurrent-session", max_rounds=4) candidate_id = first["rounds"][0]["candidate_id"] decision = 返修决定(1, candidate_id, "retry") def retry(command: str): try: return 决定有限返修(env["装配"], env["作者"], command, first["session_id"], decision) except Muse错误 as exc: return exc with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map(retry, ("retry-a", "retry-b"))) successes = [r for r in results if isinstance(r, dict)] failures = [r for r in results if isinstance(r, Muse错误)] assert len(successes) == len(failures) == 1 assert len(successes[0]["rounds"]) == 2 winning_command = "retry-a" if isinstance(results[0], dict) else "retry-b" replay = 决定有限返修(env["装配"], env["作者"], winning_command, first["session_id"], decision) assert replay["rounds"][1]["task_id"] == successes[0]["rounds"][1]["task_id"] with pytest.raises(Muse错误, match="不能改变"): 决定有限返修( env["装配"], env["作者"], winning_command, first["session_id"], 返修决定(1, candidate_id, "original"), ) assert len(env["装配"].任务运行.列出任务(env["作者"].作者, 作品ID="gen-work")) == 2 @pytest.mark.case_id( "NC-w23-236006", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["达到上限后停止且不能以新命令重试"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_达到上限后停止且不能以新命令重试__236006(修订环境): env = 修订环境 face = 发起并产出候选(env, command="one-round", max_rounds=1) assert face["state"] == "closed" and face["stop_reason"] == "round_limit" with pytest.raises(Muse错误, match="不等待作者选择"): 决定有限返修( env["装配"], env["作者"], "retry-over-limit", face["session_id"], 返修决定(1, face["rounds"][0]["candidate_id"], "retry"), ) selected = 决定有限返修( env["装配"], env["作者"], "select-at-limit", face["session_id"], 返修决定(1, face["rounds"][0]["candidate_id"], "candidate"), ) assert selected["stop_reason"] == "round_limit" assert selected["author_choice"] == "candidate" assert selected["selected_candidate_id"] == face["rounds"][0]["candidate_id"] assert ( env["正文"].读取候选(env["作者"], selected["selected_candidate_id"])["decision"] == "undecided" ) assert len(selected["rounds"]) == 1 @pytest.mark.case_id( "NC-w23-236009", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["硬门失败关闭并保留真实拒绝结果"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_硬门失败关闭并保留真实拒绝结果__236009(修订环境): env = 修订环境 face = 发起有限返修( env["装配"], env["作者"], "hard-gate-session", env["授权"], 配置ID="gen-config", 最大轮数=3, ) task_id = face["rounds"][0]["task_id"] 批准轮预算(env, task_id) env["剧本"].append({"类型": "文本", "文本": 候选输出(env, "4枚铜钱,")}) 修订测试.跑(env, task_id) stopped = 读取有限返修(env["装配"], env["作者"], face["session_id"]) assert stopped["state"] == "closed" and stopped["stop_reason"] == "hard_gate_failed" assert stopped["rounds"][0]["result"]["outcome"] == "rejected" assert stopped["rounds"][0]["candidate_id"] is None assert stopped["stop_detail"] @pytest.mark.case_id( "NC-w23-236007", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["原文漂移关闭会话且跨作者与改参不能绕过"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_原文漂移关闭会话且跨作者与改参不能绕过__236007(修订环境): env = 修订环境 face = 发起并产出候选(env, command="drift-session") env["正文"].保存人工( env["作者"], "new-original", "ch-4", 1, 正文草稿((段落("p1", (文本节点("作者保存了新版本。"),)),)), ) stopped = 决定有限返修( env["装配"], env["作者"], "retry-after-drift", face["session_id"], 返修决定(1, face["rounds"][0]["candidate_id"], "retry"), ) assert stopped["state"] == "closed" and stopped["stop_reason"] == "basis_drift" assert "过期" in stopped["stop_detail"] assert len(stopped["rounds"]) == 1 with pytest.raises(Muse错误): 读取有限返修(env["装配"], replace(env["作者"], 作者="other-author"), face["session_id"]) with pytest.raises(Muse错误, match="不能改变"): 发起有限返修( env["装配"], env["作者"], "drift-session", env["授权"], 配置ID="gen-config", 最大轮数=4, ) @pytest.mark.case_id( "NC-w23-236008", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["模型失败保留真实任务且不伪造候选或下一轮"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_模型失败保留真实任务且不伪造候选或下一轮__236008(修订环境): env = 修订环境 face = 发起有限返修( env["装配"], env["作者"], "failed-session", env["授权"], 配置ID="gen-config", 最大轮数=3, ) task_id = face["rounds"][0]["task_id"] 批准轮预算(env, task_id) env["剧本"].append({"类型": "失败"}) with pytest.raises(Muse错误): 修订测试.跑(env, task_id) failed = 读取有限返修(env["装配"], env["作者"], face["session_id"]) assert failed["state"] == "interrupted" assert failed["stop_reason"] in {"task_reconciling", "task_failed"} assert len(failed["rounds"]) == 1 assert failed["rounds"][0]["result"] is None assert env["正文"].列出候选(env["作者"], "ch-4") == [] @pytest.mark.case_id( "NC-w23-236010", environment="隔离PG", given="合成资料、真实S02配置与明确作者授权", when="通过实际返修会话和作者入口执行并重新读取", then=["结果已保存但步骤回执中断时按原结果恢复且不重发"], contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md", ) def test_结果已保存但步骤回执中断时按原结果恢复且不重发__236010(修订环境, monkeypatch): env = 修订环境 face = 发起有限返修( env["装配"], env["作者"], "result-interrupted-session", env["授权"], 配置ID="gen-config", 最大轮数=3, ) task_id = face["rounds"][0]["task_id"] 批准轮预算(env, task_id) env["剧本"].append({"类型": "文本", "文本": 候选输出(env)}) run = env["装配"].任务运行 finish = run.完成步骤 def interrupt(claim, result): if claim.处理器ID == "revision.generate": raise RuntimeError("模拟结果提交后步骤回执丢失") return finish(claim, result) monkeypatch.setattr(run, "完成步骤", interrupt) with pytest.raises(RuntimeError, match="步骤回执丢失"): 修订测试.跑(env, task_id) delivered = 读取有限返修(env["装配"], env["作者"], face["session_id"]) assert delivered["state"] == "awaiting_author" assert delivered["rounds"][0]["result"]["outcome"] == "candidate" before = delivered["rounds"][0]["candidate_id"] monkeypatch.setattr(run, "完成步骤", finish) run.控制任务( task_id, env["作者"].作者, 任务状态.已失败, "恢复", 命令ID="recover-session-result", ) assert 修订测试.跑(env, task_id).状态.value == "completed" recovered = 读取有限返修(env["装配"], env["作者"], face["session_id"]) assert recovered["rounds"][0]["candidate_id"] == before assert len(env["收到"]) == 1