953 lines
38 KiB
Python
953 lines
38 KiB
Python
"""两阶段写手全链路:真实任务、PG 账本与合成 HTTP 流贯通;不据此宣称真实模型或文学效果。"""
|
||
|
||
import json
|
||
from datetime import UTC, datetime, timedelta
|
||
from decimal import Decimal
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from threading import Thread
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from pydantic import TypeAdapter
|
||
|
||
from muse.上下文.接口 import 上下文错误
|
||
from muse.任务运行.接口 import 任务状态
|
||
from muse.任务运行.模型 import 内容哈希
|
||
from muse.任务运行.配置版本 import (
|
||
凭据引用,
|
||
提供方配置,
|
||
运行配置内容,
|
||
配置版本管理,
|
||
配置验证证据,
|
||
)
|
||
from muse.任务运行.预算管理 import (
|
||
任务预算计划,
|
||
角色预算,
|
||
预算管理,
|
||
额度策略,
|
||
)
|
||
from muse.作品规划.接口 import 档案保存, 规划保存, 规划定位
|
||
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
|
||
from muse.共享.错误 import Muse错误
|
||
from muse.启动 import 构建, 装配生成正文
|
||
from muse.故事世界.接口 import 事实定位, 事实提案请求, 定位字面引文, 正文依据
|
||
from muse.正式变更.接口 import 作者动作, 变更错误
|
||
from muse.正文写作.写作任务 import 写作任务错误
|
||
from muse.正文写作.接口 import 候选定位, 文本节点, 正文草稿, 段落
|
||
from muse.正文写作.正文格式 import 可见文本
|
||
from muse.编排.生成正文 import 发起生成正文, 处理器版本集, 探索工具集, 流程版本, 生成正文错误
|
||
from muse.配置 import 应用配置
|
||
|
||
处理器能力 = list(处理器版本集)
|
||
|
||
pytestmark = pytest.mark.数据库
|
||
|
||
|
||
class 合成计价:
|
||
版本 = "synthetic-price-1"
|
||
|
||
def 金额(self, 结果):
|
||
return Decimal("0.125") if 结果.用量 else None
|
||
|
||
|
||
def 细纲内容(parent_id: str, 目标章: int = 3, 钩子: str = "仓库的门后有人应声。") -> dict:
|
||
return {
|
||
"targetChapter": 目标章,
|
||
"sourceRef": {"sourceId": parent_id, "sourceVersion": 1},
|
||
"chapterGoal": "邮差在旧码头确认收信人。",
|
||
"hardConstraints": ["上一章结尾雨还没停。"],
|
||
"keyEvents": [
|
||
{
|
||
"id": "arrival",
|
||
"order": 1,
|
||
"event": "邮差到达旧码头",
|
||
"participants": ["lin-shen"],
|
||
"trigger": "收到退回的信",
|
||
"resultDirection": "确认码头还在使用",
|
||
}
|
||
],
|
||
"adjustableBeats": [],
|
||
"declaredNewFacts": [],
|
||
"mustAppearEntities": ["lin-shen"],
|
||
"chapterEndHook": 钩子,
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def 生成环境(内置结构测试库, tmp_path):
|
||
"""作品、细纲、事实、配置、流程与合成宿主一次装配;剧本由用例注入。"""
|
||
应用测试库 = 内置结构测试库
|
||
|
||
库 = 应用测试库[用途.生产]
|
||
|
||
from muse.任务运行.角色策略 import 角色策略目录
|
||
|
||
策略 = 角色策略目录.从发布包()
|
||
from muse.编排.接口 import 流程登记
|
||
|
||
登记 = 流程登记()
|
||
装配 = 构建(
|
||
应用配置(
|
||
库.引用,
|
||
策略.资源发布身份,
|
||
运行用途=库.用途,
|
||
原文暂存=str(tmp_path / "raw"),
|
||
),
|
||
流程=登记,
|
||
)
|
||
with 装配.生命周期():
|
||
from pathlib import Path
|
||
|
||
装配生成正文(
|
||
装配,
|
||
合成计价(),
|
||
Path(__file__).parents[2] / "配置" / "流程模板" / "生成正文.yaml",
|
||
)
|
||
作者 = 调用身份("gen-author", None, 库.用途, 内容用途.生成)
|
||
作品, 正文 = 装配.要求作品(), 装配.要求正文()
|
||
|
||
结构选择 = 作品.新档案结构(作者, "gen-work", "work_core", 1)
|
||
作品.保存档案(
|
||
作者,
|
||
"create-gen-work",
|
||
档案保存(
|
||
"gen-work", 0, {"名称": "雨码头", "题材定位": "现实", "主题立意": "守约"}, 结构选择
|
||
),
|
||
)
|
||
for i in range(1, 5):
|
||
作品.添加章节(
|
||
作者, f"add-ch-{i}", "gen-work", f"ch-{i}", f"第{i}章", 预期目录版本=i - 1
|
||
)
|
||
正文.保存人工(
|
||
作者,
|
||
"body-ch-1",
|
||
"ch-1",
|
||
0,
|
||
正文草稿((段落("p-1", (文本节点("林深停在码头,雨还没停。"),)),)),
|
||
)
|
||
外结构 = 作品.新档案结构(作者, "other-book", "work_core", 1)
|
||
作品.保存档案(
|
||
作者,
|
||
"create-other-book",
|
||
档案保存(
|
||
"other-book", 0, {"名称": "别的书", "题材定位": "现实", "主题立意": "无"}, 外结构
|
||
),
|
||
)
|
||
作品.添加章节(作者, "add-foreign", "other-book", "foreign", "外章", 预期目录版本=0)
|
||
|
||
规划 = 装配.要求规划()
|
||
|
||
def 保存并确认(请求, 命令: str, **关键字) -> dict:
|
||
回执 = 规划.保存人工候选(作者, 命令 + "-draft", 请求)
|
||
候选 = 规划.读取候选(作者, 回执["results"][0]["candidate_id"])
|
||
定位 = 规划定位(
|
||
候选["candidate_id"],
|
||
候选["revision"],
|
||
候选["candidate_hash"],
|
||
候选["plan_id"],
|
||
请求.expected_revision,
|
||
)
|
||
审阅 = 规划.打开审阅(作者, 定位)
|
||
规划.决定(
|
||
作者,
|
||
命令 + "-accept",
|
||
定位,
|
||
作者动作.采纳,
|
||
审阅["review_id"],
|
||
审阅["review_hash"],
|
||
("plan",),
|
||
)
|
||
return 规划.读取规划(作者, 候选["plan_id"])
|
||
|
||
世界 = 装配.要求故事世界()
|
||
|
||
def 正文源(章: str, 引文: str) -> 正文依据:
|
||
记录 = 正文.读取正文(作者, 章)
|
||
草稿 = TypeAdapter(正文草稿).validate_python(记录["document"])
|
||
return 正文依据(
|
||
章, "main", 记录["revision"], 记录["document_hash"], (定位字面引文(草稿, 引文),)
|
||
)
|
||
|
||
def 确认事实(对象, 类型, 内容, 来源=()) -> None:
|
||
选择 = 世界.新事实结构(作者, "gen-work", 对象, 类型, 1)
|
||
回执 = 世界.提出事实(
|
||
作者,
|
||
"propose-" + 对象,
|
||
事实提案请求("gen-work", 对象, 0, 选择, 内容, tuple(来源), "ch-1"),
|
||
)
|
||
提案 = 世界.读取提案(作者, 回执["results"][0]["proposal_id"])
|
||
定位 = 事实定位(
|
||
提案["proposal_id"], 提案["revision"], 提案["proposal_hash"], "world:" + 对象, 0
|
||
)
|
||
审阅 = 世界.打开事实审阅(作者, 定位)
|
||
世界.决定事实(
|
||
作者,
|
||
"confirm-" + 对象,
|
||
定位,
|
||
作者动作.采纳,
|
||
审阅["review_id"],
|
||
审阅["review_hash"],
|
||
("object",),
|
||
)
|
||
|
||
确认事实("lin-shen", "character", {"名称": "林深"}, (正文源("ch-1", "林深停在码头"),))
|
||
|
||
父 = 保存并确认(
|
||
规划保存(
|
||
"gen-work", 0, 规划.新规划结构(作者, "gen-work", "outline", 1), {"名称": "雨码头"}
|
||
),
|
||
"outline",
|
||
)
|
||
保存并确认(
|
||
规划保存(
|
||
"gen-work",
|
||
0,
|
||
规划.新规划结构(作者, "gen-work", "fine_outline", 1),
|
||
细纲内容(父["plan_id"], 目标章=3),
|
||
chapter_id="ch-3",
|
||
),
|
||
"fine-outline-ch3",
|
||
)
|
||
保存并确认(
|
||
规划保存(
|
||
"gen-work",
|
||
0,
|
||
规划.新规划结构(作者, "gen-work", "fine_outline", 1),
|
||
细纲内容(父["plan_id"], 目标章=4, 钩子="天亮前灯还亮着。"),
|
||
chapter_id="ch-4",
|
||
),
|
||
"fine-outline-ch4",
|
||
)
|
||
|
||
收到: list[dict] = []
|
||
剧本: list = []
|
||
|
||
def 回应(数据):
|
||
收到.append(数据)
|
||
动作 = 剧本.pop(0) if 剧本 else {"类型": "空对象"}
|
||
if 动作.get("类型") == "失败":
|
||
raise RuntimeError("合成宿主按剧本失败")
|
||
if 动作.get("类型") == "工具调用":
|
||
输出 = [
|
||
{
|
||
"type": "function_call",
|
||
"id": f"fc-{len(收到)}",
|
||
"call_id": 动作["调用ID"],
|
||
"name": 动作["名称"],
|
||
"arguments": json.dumps(动作.get("参数", {}), ensure_ascii=False),
|
||
}
|
||
]
|
||
else:
|
||
输出 = [
|
||
{
|
||
"type": "message",
|
||
"content": [
|
||
{
|
||
"type": "output_text",
|
||
"text": json.dumps(动作.get("文本", {}), ensure_ascii=False),
|
||
}
|
||
],
|
||
}
|
||
]
|
||
载荷 = {
|
||
"type": "response.completed",
|
||
"response": {
|
||
"id": f"gen-response-{len(收到)}",
|
||
# 合成宿主按请求回声模型身份;角色策略的实际模型白名单据此核对。
|
||
"model": 数据.get("model") or "claude-opus-4-8[1M]",
|
||
"status": "completed",
|
||
"output": 输出,
|
||
"usage": {"input_tokens": 5, "output_tokens": 7},
|
||
},
|
||
}
|
||
return ("data: " + json.dumps(载荷, ensure_ascii=False) + "\n\n").encode()
|
||
|
||
服务: list = []
|
||
|
||
def 启动(处理):
|
||
class 请求处理(BaseHTTPRequestHandler):
|
||
def do_POST(self):
|
||
try:
|
||
输入 = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||
输出 = 处理(输入)
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/event-stream")
|
||
self.send_header("Content-Length", str(len(输出)))
|
||
self.end_headers()
|
||
self.wfile.write(输出)
|
||
except Exception:
|
||
self.send_error(500)
|
||
服务.append("失败")
|
||
|
||
def log_message(self, *args):
|
||
pass
|
||
|
||
server = ThreadingHTTPServer(("127.0.0.1", 0), 请求处理)
|
||
thread = Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
服务.append((server, thread))
|
||
return f"http://127.0.0.1:{server.server_port}/v1/responses"
|
||
|
||
地址 = 启动(回应)
|
||
凭据 = tmp_path / "gen-key"
|
||
凭据.write_text("synthetic-generation-credential")
|
||
|
||
原配置 = 运行配置内容(
|
||
"direct",
|
||
"1",
|
||
策略.定义["version"],
|
||
策略.资源发布身份,
|
||
"synthetic",
|
||
{
|
||
"writer": {
|
||
"provider": "synthetic",
|
||
"model": "claude-opus-4-8[1M]",
|
||
"thinking": "high",
|
||
"stage_tools": {"探索": list(探索工具集), "生成": []},
|
||
}
|
||
},
|
||
(凭据引用("provider-key", "受控存储", str(凭据)),),
|
||
(提供方配置("synthetic", "responses", 地址, "provider-key"),),
|
||
合成计价().版本,
|
||
)
|
||
|
||
class 离线配置检查:
|
||
身份 = "synthetic-generation-validation"
|
||
|
||
def 验证(self, 内容, 执行用途):
|
||
return 配置验证证据(
|
||
内容哈希(内容.冻结()),
|
||
内容.角色策略版本,
|
||
内容.资源发布身份,
|
||
执行用途,
|
||
("synthetic-generation-contract",),
|
||
"runtime",
|
||
)
|
||
|
||
管理 = 配置版本管理(库, 离线配置检查())
|
||
管理.保存草案("gen-config", "1", 原配置)
|
||
回执 = 管理.验证版本("gen-config", "1")
|
||
管理.启用("gen-config", "1", 验证回执=回执, 批准引用="synthetic-approval", 预期代次=0)
|
||
|
||
环境 = {
|
||
"库": 库,
|
||
"装配": 装配,
|
||
"登记": 登记,
|
||
"作者": 作者,
|
||
"作品": 作品,
|
||
"正文": 正文,
|
||
"规划": 规划,
|
||
"剧本": 剧本,
|
||
"收到": 收到,
|
||
"管理": 管理,
|
||
"服务": 服务,
|
||
"raw": str(tmp_path / "raw"),
|
||
# 同库三用途工厂:依赖本环境的用例必须复用同一库,另借新库会读到空作品。
|
||
"库组": 应用测试库,
|
||
}
|
||
yield 环境
|
||
for 项 in 服务:
|
||
if isinstance(项, tuple):
|
||
项[0].shutdown()
|
||
项[0].server_close()
|
||
|
||
|
||
def 发起并跑完(环境, *, 章位="ch-3", 命令=None, 剧本=None, 冻结后=None):
|
||
库, 装配 = 环境["库"], 环境["装配"]
|
||
if 剧本:
|
||
环境["剧本"].extend(剧本)
|
||
回执 = 发起生成正文(
|
||
装配,
|
||
环境["作者"],
|
||
命令 or f"gen-{uuid4()}",
|
||
"new_chapter",
|
||
work_id="gen-work",
|
||
chapter_id=章位,
|
||
配置ID="gen-config",
|
||
)
|
||
任务ID = 回执["task_id"]
|
||
预算 = 预算管理(库, "synthetic")
|
||
预算.登记策略(额度策略("synthetic", "1", Decimal("20"), 40))
|
||
预算.登记任务预算(
|
||
任务ID,
|
||
任务预算计划(
|
||
Decimal("10"),
|
||
(角色预算("writer", 8, 8, Decimal("1")),),
|
||
"approval",
|
||
datetime.now(UTC) + timedelta(minutes=10),
|
||
),
|
||
)
|
||
运行 = 装配.任务运行
|
||
while True:
|
||
快照 = 运行.读取任务(任务ID)
|
||
if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}:
|
||
break
|
||
领取 = 运行.领取步骤("worker", 处理器能力)
|
||
assert 领取 is not None, "任务未完成却没有可领取步骤"
|
||
运行.执行一步(领取)
|
||
if 领取.步骤ID == "冻结回放":
|
||
with 库.连接(只读=True) as 连:
|
||
已消费 = 连.execute(
|
||
"SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s",
|
||
(任务ID,),
|
||
).fetchone()
|
||
assert 已消费[0] == 0, "冻结材料不能冒充已完成的模型消费"
|
||
if 冻结后 is not None:
|
||
冻结后(任务ID)
|
||
return 任务ID, 运行.读取任务(任务ID)
|
||
|
||
|
||
def 正常剧本(正文段):
|
||
return [
|
||
{"类型": "工具调用", "调用ID": "exp-plan", "名称": "query_work_plan", "参数": {}},
|
||
{"类型": "工具调用", "调用ID": "exp-facts", "名称": "query_facts_as_of", "参数": {}},
|
||
{
|
||
"类型": "工具调用",
|
||
"调用ID": "exp-hist",
|
||
"名称": "read_history_document",
|
||
"参数": {"chapter_id": "ch-1", "revision": 1},
|
||
},
|
||
{"类型": "空对象"},
|
||
{"类型": "文本", "文本": {"paragraphs": [{"text": 段} for 段 in 正文段]}},
|
||
]
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w15-two-phase-ok",
|
||
environment="真实隔离PostgreSQL",
|
||
given="两作品两章位各有已确认细纲与事实,配置已启用",
|
||
when="走完五步生成正文流程(合成宿主)",
|
||
then=[
|
||
"写作回合禁工具",
|
||
"候选 origin=model",
|
||
"检查报告 passed",
|
||
"事件含正文/候选/检查",
|
||
"两章位候选不同",
|
||
"真实消费校验任务/调用/实际输入哈希/快照/入选版本与片段;拒绝伪造及预算省略,真实回执重放幂等;带方法材料的模型交付失败不产生消费",
|
||
],
|
||
contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md",
|
||
)
|
||
def test_任意合法章位生成模型候选并落独立检查报告__b41e29(生成环境):
|
||
from test_上下文快照与索引写入 import 准备已确认消费方法
|
||
|
||
# 来源、结构与确认均真实;历史启用夹具不代表正式启用或方法效果。
|
||
_, 入选版本 = 准备已确认消费方法(
|
||
生成环境["装配"],
|
||
生成环境["作者"],
|
||
"gen-work",
|
||
标题="入选消费方法",
|
||
原理="用动作展现犹豫,避免替人物解释。",
|
||
)
|
||
_, 省略版本 = 准备已确认消费方法(
|
||
生成环境["装配"],
|
||
生成环境["作者"],
|
||
"gen-work",
|
||
标题="超预算消费方法",
|
||
原理="超出上下文预算的材料。" * 10_000,
|
||
)
|
||
正文段 = [
|
||
"雨点敲着铁皮屋顶,林深把退回的信压在掌心。",
|
||
"他沿跳板走向仓库,门后传来一声应答。",
|
||
]
|
||
任务ID, 快照 = 发起并跑完(生成环境, 章位="ch-3", 剧本=正常剧本(正文段))
|
||
assert 快照.状态 == 任务状态.已完成, [s for s in 快照.步骤]
|
||
环境 = 生成环境
|
||
库, 作者 = 环境["库"], 环境["作者"]
|
||
|
||
# 写作回合禁工具:合成宿主收到的最后一次请求 tools 为空。
|
||
写作请求 = 环境["收到"][-1]
|
||
assert not 写作请求.get("tools"), "写作回合必须禁工具"
|
||
assert "无工具写作" in 写作请求["instructions"]
|
||
assert "方法字段仅供规划定位,不应发给写手。" not in 写作请求["input"]
|
||
冻结步 = next(s for s in 快照.步骤 if s["step_id"] == "冻结回放")
|
||
assert {m["version_id"] for m in 冻结步["checkpoint"]["方法材料"]} == {入选版本}
|
||
材料快照 = 环境["装配"].要求上下文().读取快照(冻结步["checkpoint"]["快照ID"])
|
||
方法身份 = {条["binding_id"]: 条 for 条 in 材料快照.冻结范围["methods"]}
|
||
实绑 = [绑 for 绑 in 材料快照.来源绑定 if 绑.来源ID in 方法身份]
|
||
assert {方法身份[绑.来源ID]["version_id"] for 绑 in 实绑} == {入选版本}
|
||
assert all(绑.结构哈希 == 方法身份[绑.来源ID]["schema_hash"] for 绑 in 实绑)
|
||
assert all(绑.投影版本 == 方法身份[绑.来源ID]["projection_version"] for 绑 in 实绑)
|
||
from muse.上下文.接口 import 装配材料
|
||
|
||
原材料 = {键: 材料快照.装配["文本"][开始:结束] for 键, 开始, 结束 in 材料快照.装配["材料区间"]}
|
||
方法键 = 方法身份[实绑[0].来源ID]["材料键"]
|
||
# 方法全文仍在总文本中,但放在了错误材料键下,子串检查会误放行。
|
||
原材料["目标章细纲"], 原材料[方法键] = 原材料[方法键], 原材料["目标章细纲"]
|
||
错配 = 装配材料(原材料, ("目标章细纲",), 字节上限=材料快照.装配["字节数"])
|
||
with pytest.raises(上下文错误, match="正文与授权材料不一致"):
|
||
环境["装配"].要求上下文().冻结上下文(任务ID, 错配, 材料快照.来源绑定)
|
||
with 库.连接(只读=True) as 连:
|
||
消费版本 = 连.execute(
|
||
"SELECT method_version_id,result_ref FROM muse_method_usage "
|
||
"WHERE task_id=%s AND kind='generation'",
|
||
(任务ID,),
|
||
).fetchall()
|
||
assert {str(r[0]) for r in 消费版本} == {入选版本}
|
||
写作步 = next(s for s in 快照.步骤 if s["step_id"] == "无工具写作")
|
||
assert {r[1] for r in 消费版本} == {"model-call:" + 写作步["checkpoint"]["调用ID"]}
|
||
审阅消费 = 连.execute(
|
||
"SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s AND kind='review'",
|
||
(任务ID,),
|
||
).fetchone()
|
||
assert 审阅消费[0] == 0, "未使用方法的确定性报告不得生成审阅消费记录"
|
||
from dataclasses import replace
|
||
|
||
from muse.共享.调用身份 import 用途
|
||
from muse.共享.错误 import Muse错误
|
||
from muse.知识方法.接口 import 消费输入, 记录消费
|
||
|
||
方法服务 = 环境["装配"].要求知识方法()
|
||
实际消费 = 消费输入(
|
||
method_version_id=入选版本,
|
||
kind="generation",
|
||
context_ref=冻结步["checkpoint"]["快照ID"],
|
||
fragment_hash=冻结步["checkpoint"]["方法材料"][0]["材料哈希"],
|
||
result_ref="model-call:" + 写作步["checkpoint"]["调用ID"],
|
||
task_id=任务ID,
|
||
)
|
||
assert 方法服务.记录消费(作者, 实际消费, 模型输入=写作请求["input"])["幂等"]
|
||
with 库.连接() as 连, pytest.raises(Muse错误) as 跨用途:
|
||
记录消费(
|
||
连,
|
||
作者.作者,
|
||
实际消费,
|
||
模型输入=写作请求["input"],
|
||
执行用途=用途.评测,
|
||
)
|
||
assert 跨用途.value.错误码 == "USAGE_SCOPE_UNSUPPORTED"
|
||
for 假回执, 假输入 in (
|
||
(replace(实际消费, task_id=str(uuid4())), 写作请求["input"]),
|
||
(replace(实际消费, result_ref="model-call:invented"), 写作请求["input"]),
|
||
(replace(实际消费, kind="review"), 写作请求["input"]),
|
||
(replace(实际消费, method_version_id=省略版本), 写作请求["input"]),
|
||
(replace(实际消费, context_ref=str(uuid4())), 写作请求["input"]),
|
||
(replace(实际消费, fragment_hash="0" * 64), 写作请求["input"]),
|
||
(实际消费, 写作请求["input"] + "\\n"),
|
||
):
|
||
with pytest.raises(Muse错误):
|
||
方法服务.记录消费(作者, 假回执, 模型输入=假输入)
|
||
assert len(方法服务.列出消费(作者, 入选版本)) == 1, "伪造回执不能追加消费"
|
||
assert "用动作展现犹豫" in json.dumps(写作请求, ensure_ascii=False)
|
||
assert "超出上下文预算的材料" not in json.dumps(写作请求, ensure_ascii=False)
|
||
|
||
检查步 = next(s for s in 快照.步骤 if s["step_id"] == "检查与候选")
|
||
候选ID = 检查步["result"]["candidate_id"]
|
||
assert 检查步["result"]["verdict"] == "passed"
|
||
|
||
with 库.连接(只读=True) as 连:
|
||
行 = 连.execute(
|
||
"SELECT origin,current_revision FROM muse_writing_candidate WHERE candidate_id=%s",
|
||
(候选ID,),
|
||
).fetchone()
|
||
assert 行[0] == "model" and 行[1] == 1
|
||
报告 = 连.execute(
|
||
"SELECT verdict,basic,continuity FROM muse_review_report "
|
||
"WHERE candidate_id=%s AND candidate_revision=1",
|
||
(候选ID,),
|
||
).fetchone()
|
||
assert 报告[0] == "passed"
|
||
assert all(项["判定"] == "passed" for 项 in 报告[1]["checks"])
|
||
来源 = 连.execute(
|
||
"SELECT task_id,flow_version FROM muse_model_candidate WHERE candidate_id=%s",
|
||
(候选ID,),
|
||
).fetchone()
|
||
assert str(来源[0]) == 任务ID and 来源[1] == 流程版本
|
||
|
||
事件 = 环境["装配"].任务运行.续接事件(任务ID, 0, 数量=100)
|
||
类型序 = [e.类型.value for e in 事件.事件]
|
||
assert "text.delta" in 类型序 and "candidate.saved" in 类型序
|
||
assert "check.completed" in 类型序
|
||
候选 = 环境["正文"].读取候选(作者, 候选ID)
|
||
assert 可见文本(TypeAdapter(正文草稿).validate_python(候选["document"])) == "\n".join(正文段)
|
||
|
||
定位 = 候选定位(
|
||
候选ID,
|
||
候选["revision"],
|
||
候选["candidate_hash"],
|
||
候选["document_id"],
|
||
候选["current_document_revision"],
|
||
)
|
||
审阅 = 环境["正文"].打开候选审阅(作者, 定位)
|
||
|
||
# 第二个合法章位(ch-4,另一细纲)同样可生成。
|
||
任务2, 快照2 = 发起并跑完(
|
||
生成环境,
|
||
章位="ch-4",
|
||
剧本=正常剧本(["天快亮了,林深数着灯。", "最后一盏还亮着。"]),
|
||
)
|
||
assert 快照2.状态 == 任务状态.已完成
|
||
检查2 = next(s for s in 快照2.步骤 if s["step_id"] == "检查与候选")
|
||
assert 检查2["result"]["candidate_id"] != 候选ID
|
||
|
||
失效任务集 = []
|
||
|
||
def 停用入选方法(任务ID):
|
||
失效任务集.append(任务ID)
|
||
with 库.连接() as 连:
|
||
连.execute(
|
||
"UPDATE muse_method SET state='disabled' WHERE method_id="
|
||
"(SELECT method_id FROM muse_method_version WHERE version_id=%s)",
|
||
(入选版本,),
|
||
)
|
||
|
||
# 冻结之后、模型发送之前停用:阻断写作,不能凭旧快照继续消费。
|
||
# 读路径先看快照失效说明,方法失效现在由依赖校验一并给出,措辞更具体。
|
||
with pytest.raises(生成正文错误, match="方法授权或绑定已失效"):
|
||
发起并跑完(
|
||
生成环境,
|
||
剧本=正常剧本(["不应发送给模型的写作结果。"]),
|
||
冻结后=停用入选方法,
|
||
)
|
||
失败任务 = 失效任务集[0]
|
||
assert 环境["装配"].任务运行.读取任务(失败任务).状态 == 任务状态.已失败
|
||
assert len(环境["剧本"]) == 1, "模型写作请求必须在发送前被阻断"
|
||
with pytest.raises(上下文错误, match="采纳时方法依据已失效"):
|
||
环境["正文"].决定候选(
|
||
作者,
|
||
f"adopt-stale-{uuid4()}",
|
||
定位,
|
||
作者动作.采纳,
|
||
审阅["review_id"],
|
||
审阅["review_hash"],
|
||
("document",),
|
||
)
|
||
assert 环境["正文"].读取候选(作者, 候选ID)["decision"] == "undecided"
|
||
# 恢复测试夹具的历史启用状态,不走也不证明正式启用资格。
|
||
with 库.连接() as 连:
|
||
连.execute(
|
||
"UPDATE muse_method SET state='enabled' WHERE method_id="
|
||
"(SELECT method_id FROM muse_method_version WHERE version_id=%s)",
|
||
(入选版本,),
|
||
)
|
||
环境["正文"].决定候选(
|
||
作者,
|
||
f"adopt-valid-{uuid4()}",
|
||
定位,
|
||
作者动作.采纳,
|
||
审阅["review_id"],
|
||
审阅["review_hash"],
|
||
("document",),
|
||
)
|
||
assert 环境["正文"].读取候选(作者, 候选ID)["decision"] == "adopt"
|
||
with 库.连接(只读=True) as 连:
|
||
assert (
|
||
连.execute(
|
||
"SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (失败任务,)
|
||
).fetchone()[0]
|
||
== 0
|
||
)
|
||
|
||
# 带真实方法材料的任务在模型交付失败时不能产生消费。
|
||
环境["剧本"].clear()
|
||
断流任务 = []
|
||
with pytest.raises((Muse错误, RuntimeError, OSError)):
|
||
发起并跑完(
|
||
生成环境,
|
||
章位="ch-4",
|
||
冻结后=断流任务.append,
|
||
剧本=[
|
||
{
|
||
"类型": "工具调用",
|
||
"调用ID": "exp-plan-fail",
|
||
"名称": "query_work_plan",
|
||
"参数": {},
|
||
},
|
||
{"类型": "空对象"},
|
||
{"类型": "失败"},
|
||
],
|
||
)
|
||
assert len(断流任务) == 1
|
||
with 库.连接(只读=True) as 连:
|
||
assert (
|
||
连.execute(
|
||
"SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (断流任务[0],)
|
||
).fetchone()[0]
|
||
== 0
|
||
)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w15-cross-scope-denied",
|
||
environment="真实隔离PostgreSQL",
|
||
given="无细纲章位与跨作品章位",
|
||
when="发起生成正文",
|
||
then=["无细纲授权拒绝", "章位归属不一致拒绝"],
|
||
contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md",
|
||
)
|
||
def test_跨作品与无细纲章位拒绝发起__c73d0f(生成环境):
|
||
环境 = 生成环境
|
||
# 无细纲章位:授权细纲失败(作品内合法章位但无已确认细纲)。
|
||
with pytest.raises(上下文错误):
|
||
发起生成正文(
|
||
环境["装配"],
|
||
环境["作者"],
|
||
f"gen-{uuid4()}",
|
||
"new_chapter",
|
||
work_id="gen-work",
|
||
chapter_id="ch-2",
|
||
配置ID="gen-config",
|
||
)
|
||
# 跨作品章位:章位不属于目标作品。
|
||
with pytest.raises(写作任务错误, match="不属于目标作品"):
|
||
发起生成正文(
|
||
环境["装配"],
|
||
环境["作者"],
|
||
f"gen-{uuid4()}",
|
||
"new_chapter",
|
||
work_id="gen-work",
|
||
chapter_id="foreign",
|
||
配置ID="gen-config",
|
||
)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w15-check-gate",
|
||
environment="真实隔离PostgreSQL",
|
||
given="候选复读细纲硬约束措辞",
|
||
when="独立检查后作者尝试采纳再拒绝",
|
||
then=["verdict=failed", "采纳 CHECK_FAILED 拒绝", "拒绝决定可落库"],
|
||
contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md",
|
||
)
|
||
def test_必需检查失败不能采纳且保留失败候选证据__d52a8e(生成环境):
|
||
# 复读细纲硬约束措辞 → 连续性冲突 → verdict failed。
|
||
剧本 = 正常剧本(
|
||
[
|
||
"上一章结尾雨还没停,林深攥着信封走进雨幕。",
|
||
"仓库门后有人应声,他把信递了过去。",
|
||
]
|
||
)
|
||
任务ID, 快照 = 发起并跑完(生成环境, 章位="ch-3", 剧本=剧本)
|
||
assert 快照.状态 == 任务状态.已完成
|
||
环境 = 生成环境
|
||
检查步 = next(s for s in 快照.步骤 if s["step_id"] == "检查与候选")
|
||
候选ID = 检查步["result"]["candidate_id"]
|
||
assert 检查步["result"]["verdict"] == "failed"
|
||
|
||
正文服务 = 环境["正文"]
|
||
作者 = 环境["作者"]
|
||
候选 = 正文服务.读取候选(作者, 候选ID)
|
||
定位 = 候选定位(
|
||
候选ID,
|
||
候选["revision"],
|
||
候选["candidate_hash"],
|
||
候选["document_id"],
|
||
候选["current_document_revision"],
|
||
)
|
||
审阅 = 正文服务.打开候选审阅(作者, 定位)
|
||
|
||
with pytest.raises(变更错误) as 异常:
|
||
正文服务.决定候选(
|
||
作者,
|
||
f"adopt-{uuid4()}",
|
||
定位,
|
||
作者动作.采纳,
|
||
审阅["review_id"],
|
||
审阅["review_hash"],
|
||
("document",),
|
||
)
|
||
assert 异常.value.错误码 == "CHECK_FAILED"
|
||
# 失败候选保留为证据:仍可读取与拒绝。
|
||
正文服务.决定候选(
|
||
作者,
|
||
f"reject-{uuid4()}",
|
||
定位,
|
||
作者动作.拒绝,
|
||
审阅["review_id"],
|
||
审阅["review_hash"],
|
||
(),
|
||
)
|
||
assert 正文服务.读取候选(作者, 候选ID)["decision"] == "reject"
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w15-stream-locatable",
|
||
environment="真实隔离PostgreSQL",
|
||
given="写作回合宿主 500",
|
||
when="任务执行后重读任务与事件",
|
||
then=["任务未无声消失", "写作步骤未完成", "事件可回放", "无残留候选"],
|
||
contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md",
|
||
)
|
||
def test_无终态断流可定位且重开后任务与事件仍可找回__e6f317(生成环境):
|
||
环境 = 生成环境
|
||
# 探索后写作回合宿主失败:步骤失败、任务可定位、事件保留、无候选。
|
||
剧本 = [
|
||
{"类型": "工具调用", "调用ID": "exp-plan", "名称": "query_work_plan", "参数": {}},
|
||
{"类型": "空对象"},
|
||
{"类型": "失败"},
|
||
]
|
||
回执 = 发起生成正文(
|
||
环境["装配"],
|
||
环境["作者"],
|
||
f"gen-{uuid4()}",
|
||
"new_chapter",
|
||
work_id="gen-work",
|
||
chapter_id="ch-3",
|
||
配置ID="gen-config",
|
||
)
|
||
任务ID = 回执["task_id"]
|
||
预算 = 预算管理(环境["库"], "synthetic")
|
||
预算.登记策略(额度策略("synthetic", "1", Decimal("20"), 40))
|
||
预算.登记任务预算(
|
||
任务ID,
|
||
任务预算计划(
|
||
Decimal("10"),
|
||
(角色预算("writer", 8, 8, Decimal("1")),),
|
||
"approval",
|
||
datetime.now(UTC) + timedelta(minutes=10),
|
||
),
|
||
)
|
||
环境["剧本"].extend(剧本)
|
||
运行 = 环境["装配"].任务运行
|
||
with pytest.raises((Muse错误, RuntimeError, OSError)):
|
||
while True:
|
||
快照 = 运行.读取任务(任务ID)
|
||
if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}:
|
||
break
|
||
领取 = 运行.领取步骤("worker", 处理器能力)
|
||
assert 领取 is not None
|
||
运行.执行一步(领取)
|
||
终态 = 运行.读取任务(任务ID)
|
||
# 断流定位:任务未无声消失,写作步骤未完成,后续领取把发出未回的调用标记待对账。
|
||
assert 终态.状态 != 任务状态.已完成
|
||
写作步 = next(s for s in 终态.步骤 if s["step_id"] == "无工具写作")
|
||
assert 写作步["state"] != "completed", 终态.步骤
|
||
运行.领取步骤("worker", 处理器能力)
|
||
对账态 = 运行.读取任务(任务ID)
|
||
assert 对账态.状态 in {
|
||
任务状态.待对账,
|
||
任务状态.已失败,
|
||
任务状态.运行中,
|
||
}, 对账态.状态
|
||
事件 = 运行.续接事件(任务ID, 0, 数量=100)
|
||
assert 事件.事件, "断流后事件必须可回放定位"
|
||
with 环境["库"].连接(只读=True) as 连:
|
||
数 = 连.execute(
|
||
"SELECT count(*) FROM muse_writing_candidate c "
|
||
"JOIN muse_model_candidate m USING(candidate_id) WHERE m.task_id=%s",
|
||
(任务ID,),
|
||
).fetchone()[0]
|
||
assert 数 == 0
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w15-model-self-fill",
|
||
environment="真实隔离PostgreSQL",
|
||
given="模型输出夹带 checks_passed 等额外字段",
|
||
when="走生成流程",
|
||
then=["输出合同 additionalProperties=false 拒绝", "步骤失败可定位"],
|
||
contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md",
|
||
)
|
||
def test_模型自填无效输出被输出合同拒绝__93c5b7(生成环境):
|
||
# 模型试图自填检查结论与额外字段:输出合同 additionalProperties=false 直接拒绝。
|
||
剧本 = [
|
||
{"类型": "工具调用", "调用ID": "exp-plan", "名称": "query_work_plan", "参数": {}},
|
||
{"类型": "空对象"},
|
||
{
|
||
"类型": "文本",
|
||
"文本": {
|
||
"paragraphs": [{"text": "林深收好信。"}],
|
||
"checks_passed": True,
|
||
"author_confirmed": True,
|
||
},
|
||
},
|
||
]
|
||
环境 = 生成环境
|
||
回执 = 发起生成正文(
|
||
环境["装配"],
|
||
环境["作者"],
|
||
f"gen-{uuid4()}",
|
||
"new_chapter",
|
||
work_id="gen-work",
|
||
chapter_id="ch-3",
|
||
配置ID="gen-config",
|
||
)
|
||
任务ID = 回执["task_id"]
|
||
预算 = 预算管理(环境["库"], "synthetic")
|
||
预算.登记策略(额度策略("synthetic", "1", Decimal("20"), 40))
|
||
预算.登记任务预算(
|
||
任务ID,
|
||
任务预算计划(
|
||
Decimal("10"),
|
||
(角色预算("writer", 8, 8, Decimal("1")),),
|
||
"approval",
|
||
datetime.now(UTC) + timedelta(minutes=10),
|
||
),
|
||
)
|
||
环境["剧本"].extend(剧本)
|
||
运行 = 环境["装配"].任务运行
|
||
with pytest.raises((Muse错误, RuntimeError, OSError)):
|
||
while True:
|
||
快照 = 运行.读取任务(任务ID)
|
||
if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}:
|
||
break
|
||
领取 = 运行.领取步骤("worker", 处理器能力)
|
||
assert 领取 is not None
|
||
运行.执行一步(领取)
|
||
终态 = 运行.读取任务(任务ID)
|
||
assert any(s["state"] == "failed" for s in 终态.步骤)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w15-writer-blocked-exit",
|
||
environment="真实隔离PostgreSQL",
|
||
given="细纲与已确认事实存在不可化解死结的写作任务(合成剧本)",
|
||
when="写作回合输出 blocked 声明而非正文",
|
||
then=[
|
||
"blocked 载荷通过输出合同校验并随结构化交付留痕",
|
||
"任务失败于无工具写作步骤,错误说明含写手理由",
|
||
"不进入检查与候选步骤,不登记方法消费",
|
||
],
|
||
contract="docs/系统架构/新版设计/模块设计/B05-正文写作.md",
|
||
)
|
||
def test_写手声明写作死结时任务明确失败且不产候选__e72a10(生成环境):
|
||
剧本 = [
|
||
{"类型": "工具调用", "调用ID": "exp-plan", "名称": "query_work_plan", "参数": {}},
|
||
{"类型": "空对象"},
|
||
{
|
||
"类型": "文本",
|
||
"文本": {"blocked": {"reason": "细纲要求角色在已确认死亡后出场,任何写法都必然虚构。"}},
|
||
},
|
||
]
|
||
环境 = 生成环境
|
||
回执 = 发起生成正文(
|
||
环境["装配"],
|
||
环境["作者"],
|
||
f"gen-{uuid4()}",
|
||
"new_chapter",
|
||
work_id="gen-work",
|
||
chapter_id="ch-3",
|
||
配置ID="gen-config",
|
||
)
|
||
任务ID = 回执["task_id"]
|
||
预算 = 预算管理(环境["库"], "synthetic")
|
||
预算.登记策略(额度策略("synthetic", "1", Decimal("20"), 40))
|
||
预算.登记任务预算(
|
||
任务ID,
|
||
任务预算计划(
|
||
Decimal("10"),
|
||
(角色预算("writer", 8, 8, Decimal("1")),),
|
||
"approval",
|
||
datetime.now(UTC) + timedelta(minutes=10),
|
||
),
|
||
)
|
||
环境["剧本"].extend(剧本)
|
||
运行 = 环境["装配"].任务运行
|
||
with pytest.raises(生成正文错误, match="写手声明写作死结"):
|
||
while True:
|
||
快照 = 运行.读取任务(任务ID)
|
||
if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}:
|
||
break
|
||
领取 = 运行.领取步骤("worker", 处理器能力)
|
||
assert 领取 is not None
|
||
运行.执行一步(领取)
|
||
终态 = 运行.读取任务(任务ID)
|
||
assert 终态.状态 is 任务状态.已失败
|
||
失败步 = [s for s in 终态.步骤 if s["state"] == "failed"]
|
||
assert 失败步 and 失败步[0]["step_id"] == "无工具写作"
|
||
assert not any(s["step_id"] == "检查与候选" and s["state"] == "completed" for s in 终态.步骤)
|
||
with 环境["库"].连接(只读=True) as 连:
|
||
消费数 = 连.execute(
|
||
"SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (任务ID,)
|
||
).fetchone()
|
||
assert 消费数[0] == 0, "blocked 写作死结不得登记方法消费"
|