muse-agent-example/tests/集成/test_两阶段写手.py

643 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""两阶段写手全链路:真实任务、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 内容用途, 用途, 调用身份
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):
"""作品、细纲、事实、配置、流程与合成宿主一次装配;剧本由用例注入。"""
库 = 应用测试库[用途.生产]
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
导入内置结构(元数据服务(连))
from muse.任务运行.角色策略 import 角色策略目录
策略 = 角色策略目录.从发布包()
from muse.编排.接口 import 流程登记
登记 = 流程登记()
装配 = 构建(
应用配置(
库.引用,
策略.资源发布身份,
运行用途=库.用途,
原文暂存=str(tmp_path / "raw"),
),
流程=登记,
)
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": "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):
库, 装配 = 环境["库"], 环境["装配"]
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, "任务未完成却没有可领取步骤"
运行.执行一步(领取)
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 正文段]}},
]
def test_任意合法章位生成模型候选并落独立检查报告__b41e29(生成环境):
正文段 = [
"雨点敲着铁皮屋顶,林深把退回的信压在掌心。",
"他沿跳板走向仓库,门后传来一声应答。",
]
任务ID, 快照 = 发起并跑完(生成环境, 章位="ch-3", 剧本=正常剧本(正文段))
assert 快照.状态 == 任务状态.已完成, [s for s in 快照.步骤]
环境 = 生成环境
库, 作者 = 环境["库"], 环境["作者"]
# 写作回合禁工具:合成宿主收到的最后一次请求 tools 为空。
写作请求 = 环境["收到"][-1]
assert not 写作请求.get("tools"), "写作回合必须禁工具"
assert "无工具写作" in 写作请求["instructions"]
检查步 = 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(正文段)
# 第二个合法章位(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 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",
)
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"
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
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 终态.步骤)