接续 88dd570,保存 W20–W24 已实现的共享接口、业务入口、迁移、工作台、测试与文档。 W20/W22/W23 保持 in_progress,W21/W24 保持 verified;此提交不宣称方法或规则正式启用、多轮返修、真实角色评测完成。 W25 新增实验、标定、逐调用交付与角色执行及其迁移/测试/索引留在实施工作树,原有私人和旧实现保留项不纳入。 验证:离线 571、前端 33 通过;PG 469 项通过、2 项浏览器未启用,2 项误带入的 W25 用例已移出本提交;最终任务与交付边界 37 项通过。make 检查、最终类型、84 项资源及 diff 检查通过。未重跑浏览器或 Pi 宿主,不以合成调用认证外部模型效果。 独立整体审查四维通过;证据保存在 R2-20260909/提交W20-W24。
240 lines
9.5 KiB
Python
240 lines
9.5 KiB
Python
"""审校、规则与声音的真实作者浏览器旅程,使用隔离PG和合成模型。"""
|
||
|
||
import json
|
||
from dataclasses import replace
|
||
|
||
import pytest
|
||
import test_完整文学审校 as 文学测试
|
||
|
||
from muse.任务运行.接口 import 任务状态
|
||
|
||
pytestmark = pytest.mark.数据库
|
||
文学环境 = 文学测试.文学环境
|
||
生成环境 = 文学测试.生成环境
|
||
|
||
|
||
@pytest.mark.浏览器
|
||
@pytest.mark.timeout(240)
|
||
def test_作者审校与声音浏览器往返__229004(文学环境, tmp_path):
|
||
import os
|
||
import secrets
|
||
import socket
|
||
import subprocess
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from threading import Event, Thread
|
||
|
||
import uvicorn
|
||
|
||
from muse.启动 import 装配审校修订
|
||
from muse.接入.http.应用 import 创建应用
|
||
from muse.配置 import 服务配置
|
||
|
||
if os.environ.get("MUSE_RUN_BROWSER") != "1":
|
||
pytest.skip("浏览器驱动需显式启用;不将未执行记为通过")
|
||
环境 = 文学环境
|
||
task_id, pieces = 文学测试.发起(环境)
|
||
环境["剧本"].extend({"类型": "文本", "文本": 文学测试.片结果(p, 有声音=False)} for p in pieces)
|
||
assert 文学测试.跑(环境, task_id).状态 == 任务状态.已完成
|
||
from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
|
||
|
||
环境["正文"].保存人工(
|
||
环境["作者"],
|
||
"review-patch-seed",
|
||
"ch-4",
|
||
0,
|
||
正文草稿((段落("patch-p1", (文本节点("值得注意的是,雨已经停了。"),)),)),
|
||
)
|
||
口令 = tmp_path / "browser-password"
|
||
口令.write_text(secrets.token_hex(24))
|
||
口令.chmod(0o600)
|
||
sock = socket.socket()
|
||
sock.bind(("127.0.0.1", 0))
|
||
port = sock.getsockname()[1]
|
||
base = f"http://127.0.0.1:{port}"
|
||
配置 = replace(
|
||
环境["装配"].配置,
|
||
HTTP=服务配置(
|
||
str(口令),
|
||
作者ID="gen-author",
|
||
端口=port,
|
||
公开地址=base,
|
||
允许来源=(base,),
|
||
),
|
||
)
|
||
app = 创建应用(配置)
|
||
原生命周期 = app.router.lifespan_context
|
||
就绪 = Event()
|
||
|
||
@asynccontextmanager
|
||
async def 生命周期(app_):
|
||
async with 原生命周期(app_):
|
||
app_.state.装配 = 环境["装配"]
|
||
就绪.set()
|
||
yield
|
||
|
||
app.router.lifespan_context = 生命周期
|
||
server = uvicorn.Server(
|
||
uvicorn.Config(app, log_level="warning", access_log=False, timeout_graceful_shutdown=3)
|
||
)
|
||
thread = Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
|
||
root = Path(__file__).resolve().parents[2]
|
||
装配审校修订(环境["装配"], 文学测试.写手测试.合成计价(), root / "配置/流程模板/审校修订.yaml")
|
||
worker_stop = Event()
|
||
worker_errors = []
|
||
|
||
def 运行合成修订():
|
||
from datetime import UTC, datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
from muse.任务运行.接口 import 任务预算计划, 角色预算, 预算管理, 额度策略
|
||
|
||
run = 环境["装配"].任务运行
|
||
done = set()
|
||
try:
|
||
while not worker_stop.wait(0.1):
|
||
for task in run.列出任务(环境["作者"].作者, 作品ID="gen-work"):
|
||
if task.流程.流程ID != "审校修订" or task.任务ID in done:
|
||
continue
|
||
done.add(task.任务ID)
|
||
budget = 预算管理(环境["库"], "synthetic")
|
||
budget.登记策略(额度策略("synthetic", "1", Decimal("20"), 40))
|
||
budget.登记任务预算(
|
||
task.任务ID,
|
||
任务预算计划(
|
||
Decimal("4"),
|
||
(角色预算("writer", 2, 2, Decimal("1")),),
|
||
"synthetic-browser-approval",
|
||
datetime.now(UTC) + timedelta(minutes=5),
|
||
),
|
||
)
|
||
auth = task.冻结输入["输入"]["修订授权"]
|
||
if task.冻结输入["输入"].get("source_kind") == "selection":
|
||
from pydantic import TypeAdapter
|
||
|
||
from muse.正文写作.接口 import 选段写作授权
|
||
|
||
selected = TypeAdapter(选段写作授权).validate_python(auth)
|
||
edits = [
|
||
{
|
||
"selection_id": s.selection_id,
|
||
"action": "replace",
|
||
"replacement": "细雨已经停了。",
|
||
"reason": "合成通用润色",
|
||
}
|
||
for s in selected.selections
|
||
]
|
||
else:
|
||
edits = [
|
||
{
|
||
"finding_id": auth["approved_findings"][0],
|
||
"action": "replace",
|
||
"replacement": "",
|
||
"reason": "合成删除元话语",
|
||
}
|
||
]
|
||
环境["剧本"].append(
|
||
{
|
||
"类型": "文本",
|
||
"文本": {"edits": edits},
|
||
}
|
||
)
|
||
while run.读取任务(task.任务ID).状态 != 任务状态.已完成:
|
||
claim = run.领取步骤(
|
||
"browser-revision", ["revision.freeze", "revision.generate"]
|
||
)
|
||
assert claim is not None
|
||
run.执行一步(claim)
|
||
except Exception as exc:
|
||
worker_errors.append(exc)
|
||
|
||
worker = Thread(target=运行合成修订, daemon=True)
|
||
输出 = Path(os.environ.get("MUSE_BROWSER_ARTIFACTS", str(tmp_path / "browser"))).resolve()
|
||
输出.mkdir(parents=True, exist_ok=True)
|
||
env = {
|
||
**os.environ,
|
||
"MUSE_WORKBENCH_URL": base,
|
||
"MUSE_AUTHOR_PASSWORD_FILE": str(口令),
|
||
"MUSE_REVIEW_TASK_ID": task_id,
|
||
}
|
||
chrome = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
||
if chrome.exists():
|
||
env.setdefault("MUSE_BROWSER_EXECUTABLE", str(chrome))
|
||
thread.start()
|
||
worker.start()
|
||
try:
|
||
assert 就绪.wait(10), "隔离 HTTP 服务未就绪"
|
||
报告路径 = 输出.parent / "作者审校-浏览器执行.json"
|
||
with 报告路径.open("w") as 报告:
|
||
过程 = subprocess.run(
|
||
[
|
||
"pnpm",
|
||
"exec",
|
||
"playwright",
|
||
"test",
|
||
"作者审校.spec.ts",
|
||
"--reporter=json",
|
||
"--output",
|
||
str(输出),
|
||
],
|
||
cwd=root / "web",
|
||
env=env,
|
||
stdout=报告,
|
||
stderr=subprocess.STDOUT,
|
||
timeout=90,
|
||
)
|
||
assert 过程.returncode == 0, f"浏览器失败,见 {报告路径}"
|
||
文件 = list(输出.rglob("审校回执.json"))
|
||
assert len(文件) == 1
|
||
新任务 = json.loads(文件[0].read_text())["created_task_id"]
|
||
assert 新任务 != task_id
|
||
with 环境["库"].连接(只读=True) as 连:
|
||
assert (
|
||
连.execute("SELECT COUNT(*) FROM muse_task WHERE task_id=%s", (新任务,)).fetchone()[
|
||
0
|
||
]
|
||
== 1
|
||
)
|
||
assert (
|
||
连.execute(
|
||
"SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (新任务,)
|
||
).fetchone()[0]
|
||
== 0
|
||
)
|
||
声音 = 环境["装配"].要求审校().当前声音(环境["作者"], "gen-work")
|
||
assert 声音["revision"] == 1
|
||
assert 声音["narrator"]["句式画像"] == "短句推进,保留动作间停顿"
|
||
assert 环境["正文"].读取正文(环境["审校作者"], "ch-2") == 环境["审校正文"]
|
||
修订候选 = 环境["正文"].列出候选(环境["作者"], "ch-4")
|
||
assert not worker_errors
|
||
assert len(修订候选) == 4 and {c["origin"] for c in 修订候选} == {"author", "model"}
|
||
assert all(c["decision"] == "undecided" for c in 修订候选)
|
||
assert sorted(c["visible_text"] for c in 修订候选) == sorted(
|
||
[
|
||
"雨已经停了。",
|
||
"雨已经停了。",
|
||
"值得注意的是,雨已经停了。屋檐开始滴水。",
|
||
"值得注意的是,细雨已经停了。",
|
||
]
|
||
)
|
||
assert 环境["正文"].读取正文(环境["作者"], "ch-4", 分支="雨后试写")["revision"] == 2
|
||
assert (
|
||
环境["正文"].读取正文(环境["作者"], "ch-4")["visible_text"]
|
||
== "值得注意的是,雨已经停了。"
|
||
)
|
||
if os.environ.get("MUSE_BROWSER_INSPECT") == "1":
|
||
(输出 / "检查入口.json").write_text(json.dumps({"origin": base, "task_id": task_id}))
|
||
import time
|
||
|
||
deadline = time.monotonic() + 150
|
||
while not (输出 / "检查结束").exists() and time.monotonic() < deadline:
|
||
Event().wait(0.25)
|
||
finally:
|
||
worker_stop.set()
|
||
worker.join(10)
|
||
assert not worker.is_alive(), "测试修订worker未停止"
|
||
server.should_exit = True
|
||
thread.join(10)
|
||
sock.close()
|
||
assert not thread.is_alive(), "测试 HTTP 服务未关闭"
|