"""作者工作台按任务读取上下文快照的 HTTP 合同:材料清单、来源证据与失效说明。 真实隔离 PG + 真实 ASGI;只读消费回合1–3 的上下文服务,不 mock 后端。 清单只暴露取舍与绑定身份,不回传装配全文,也不泄漏连接串或内部异常。 """ from uuid import uuid4 import pytest from fastapi.testclient import TestClient from muse.上下文.接口 import 投影版本, 来源绑定, 装配材料 from muse.任务运行.接口 import 任务服务, 任务请求, 步骤处理器, 步骤结果, 步骤计划 from muse.作品规划.接口 import 档案保存 from muse.元数据.接口 import 元数据服务, 导入内置结构 from muse.共享.调用身份 import 内容用途, 用途, 调用身份 from muse.启动 import 构建 from muse.接入.http.应用 import 创建应用 from muse.正文写作.接口 import 文本节点, 正文草稿, 段落 from muse.编排.接口 import 流程定义, 流程服务, 流程登记 from muse.配置 import 应用配置, 服务配置 pytestmark = pytest.mark.数据库 @pytest.fixture def 工作台环境(应用测试库, tmp_path): """真实作品、任务与冻结快照;HTTP 层只读消费,不另造第二套数据。""" with 应用测试库[用途.维护].连接() as 连, 连.transaction(): 导入内置结构(元数据服务(连)) 装配 = 构建(应用配置(应用测试库[用途.生产].引用, "test")) 作者 = 调用身份("author-local", None, 用途.生产, 内容用途.生成) 作品 = 装配.要求作品() 作品.保存档案( 作者, "create-wb-work", 档案保存( "wb-work", 0, {"名称": "材料台账", "题材定位": "现实", "主题立意": "对账"}, 作品.新档案结构(作者, "wb-work", "work_core", 1), ), ) 作品.添加章节(作者, "add-ch-1", "wb-work", "ch-1", "第一章", 预期目录版本=0) 装配.要求正文().保存人工( 作者, "body-ch-1", "ch-1", 0, 正文草稿((段落("p-1", (文本节点("林深把伞收了起来。"),)),)) ) 范围 = 装配.要求上下文().授权任务范围( 作者, "wb-work", 1, ({"chapter_id": "ch-1", "revision": 1},) ) 登记 = 流程登记() 登记.登记处理器( 步骤处理器("workbench-freeze", "1", lambda _: 步骤结果({"ok": True}), "v1", "v1") ) 登记.登记类型("workbench-freeze", 必需保护=()) 运行 = 任务服务(应用测试库[用途.生产], 登记) 流程服务(运行, 登记).发布( 流程定义("workbench-freeze", "1", (步骤计划("冻结", "workbench-freeze", "1"),)) ) def 建任务(作者ID: str, 冻结范围=None) -> str: 冻结 = { "source_scope": 冻结范围 if 冻结范围 is not None else {"work_id": "wb-work"}, "schema_versions": {}, "authorization": "workbench-synthetic", "budget": {"context_bytes": 4096}, "stop_conditions": {}, } return 运行.创建任务( 任务请求( "workbench-freeze", f"create-{作者ID}-{uuid4().hex[:6]}", 作者ID, 用途.生产, 内容用途.生成, {}, "test", "test", 冻结, 作品ID="wb-work", ), "workbench-freeze", "1", ) task_id = 建任务("author-local", 范围) 他人任务 = 建任务("someone-else") 材料 = {"历史:ch-1": "林深把伞收了起来。", "参考:超长素材": "很" * 4000} 文稿 = 范围["documents"][0] 绑定 = ( 来源绑定( 文稿["document_id"], str(文稿["revision"]), 文稿["document_hash"], 投影版本("production", "generation"), ), ) 装配.要求上下文().冻结上下文(task_id, 装配材料(材料, ("历史:ch-1",), 字节上限=4096), 绑定) 口令文件 = tmp_path / "作者口令.txt" 口令文件.write_text("workbench-password") 配置 = 应用配置( 应用测试库[用途.生产].引用, "test", HTTP=服务配置(str(口令文件), 公开地址="http://testserver", 允许来源=("http://testserver",)), ) return { "装配": 装配, "task_id": task_id, "他人任务": 他人任务, "配置": 配置, "口令": 口令文件.read_text(), } def test_作者可见任务材料清单与来源证据__dd023f(工作台环境): 环境 = 工作台环境 task_id = 环境["task_id"] with TestClient(创建应用(环境["配置"])) as client: 未登录 = client.get(f"/api/v1/tasks/{task_id}/context-snapshots") assert 未登录.status_code == 401 client.headers["Origin"] = "http://testserver" assert client.post("/api/v1/session", json={"password": 环境["口令"]}).status_code == 200 快照集 = client.get(f"/api/v1/tasks/{task_id}/context-snapshots") assert 快照集.status_code == 200 assert len(快照集.json()) == 1 快照 = 快照集.json()[0] assert 快照["task_id"] == task_id and 快照["work_id"] == "wb-work" assert 快照["snapshot_id"] and len(快照["input_hash"]) == 64 assert 快照["state"] == "有效" assert 快照["invalidation"] is None and 快照["changes"] == [] 清单 = 快照["assembly"] assert 清单["included_sources"] == ["历史:ch-1"] assert 清单["omissions"] == [{"source": "参考:超长素材", "reason": "BUDGET_EXCEEDED"}] assert 清单["byte_size"] == len("林深把伞收了下来。".encode()) assert len(清单["text_hash"]) == 64 来源 = 快照["sources"] assert len(来源) == 1 文稿 = 范围文稿(环境) assert 来源[0] == { "source_id": 文稿["document_id"], "data_version": "1", "schema_hash": 文稿["document_hash"], "projection_version": "visible-text-codepoints/v1:production:generation", "current_state": "有效", } # 清单只给取舍与身份,不回传装配全文 assert "text" not in 清单 assert "林深把伞收了下来" not in 快照集.text def test_他人任务与不存在任务被拒绝__5f6302(工作台环境): 环境 = 工作台环境 with TestClient(创建应用(环境["配置"])) as client: client.headers["Origin"] = "http://testserver" assert client.post("/api/v1/session", json={"password": 环境["口令"]}).status_code == 200 他人 = client.get(f"/api/v1/tasks/{环境['他人任务']}/context-snapshots") assert 他人.status_code == 403 assert 他人.json()["code"] == "SCOPE_DENIED" 不存在 = client.get("/api/v1/tasks/00000000-0000-0000-0000-000000000000/context-snapshots") assert 不存在.status_code == 422 assert 不存在.json()["code"] == "MUSE_TASK" def test_来源漂移后失效说明可见且历史装配原样__18a1af(工作台环境): 环境 = 工作台环境 task_id = 环境["task_id"] with TestClient(创建应用(环境["配置"])) as client: client.headers["Origin"] = "http://testserver" assert client.post("/api/v1/session", json={"password": 环境["口令"]}).status_code == 200 冻结前 = client.get(f"/api/v1/tasks/{task_id}/context-snapshots").json()[0] 作者 = 调用身份("author-local", None, 用途.生产, 内容用途.生成) 环境["装配"].要求正文().保存人工( 作者, "wb-rev-2", "ch-1", 1, 正文草稿((段落("p-2", (文本节点("第二天清晨。"),)),)) ) 漂移后集 = client.get(f"/api/v1/tasks/{task_id}/context-snapshots").json() assert len(漂移后集) == 1 漂移后 = 漂移后集[0] assert 漂移后["snapshot_id"] == 冻结前["snapshot_id"] assert 漂移后["input_hash"] == 冻结前["input_hash"] assert 漂移后["state"] == "SOURCE_STALE" assert 漂移后["invalidation"] and "ch-1" in 漂移后["invalidation"] assert any("前进" in 变化 for 变化 in 漂移后["changes"]) assert 漂移后["assembly"] == 冻结前["assembly"] assert 漂移后["sources"][0]["source_id"] == 冻结前["sources"][0]["source_id"] assert 漂移后["sources"][0]["data_version"] == "1" assert 漂移后["sources"][0]["current_state"] == "SOURCE_STALE" def 范围文稿(环境) -> dict: 作者 = 调用身份("author-local", None, 用途.生产, 内容用途.生成) 记录 = 环境["装配"].要求正文().读取正文(作者, "ch-1") return 记录