From 8353d11d40624c4affd1bca75438061e618afbf4 Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 10 Sep 2026 19:25:41 +0800 Subject: [PATCH] =?UTF-8?q?W10=20=E4=BD=9C=E5=93=81=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E4=B8=8E=E4=BA=BA=E5=B7=A5=E6=AD=A3=E6=96=87=E7=89=88=E6=9C=AC?= =?UTF-8?q?=EF=BC=9A=E4=BD=9C=E5=93=81/=E7=AB=A0=E8=8A=82=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E3=80=81=E7=9B=AE=E5=BD=95=E7=89=88=E6=9C=AC=E4=B8=8E?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E6=AD=A3=E6=96=87=E7=89=88=E6=9C=AC=E9=93=BE?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。 --- src/muse/作品规划/__init__.py | 1 + src/muse/作品规划/作品档案.py | 141 ++++++++ src/muse/作品规划/存储.py | 362 +++++++++++++++++++ src/muse/作品规划/接口.py | 287 ++++++++++++++++ src/muse/作品规划/模型.py | 143 ++++++++ src/muse/作品规划/目录调整.py | 131 +++++++ src/muse/作品规划/进度查询.py | 54 +++ src/muse/接入/cli/作品命令.py | 171 +++++++++ src/muse/接入/http/路由/作品规划.py | 315 +++++++++++++++++ src/muse/接入/http/路由/正文写作.py | 75 ++++ src/muse/接入/创作操作.py | 478 ++++++++++++++++++++++++++ src/muse/正文写作/__init__.py | 1 + src/muse/正文写作/人工保存.py | 82 +++++ src/muse/正文写作/候选版本.py | 174 ++++++++++ src/muse/正文写作/存储.py | 191 ++++++++++ src/muse/正文写作/接口.py | 261 ++++++++++++++ src/muse/正文写作/模型.py | 124 +++++++ src/muse/正文写作/正式提交.py | 73 ++++ src/muse/正文写作/正文格式.py | 33 ++ tests/契约/test_正文与模型输出.py | 94 +++++ tests/集成/test_人工创作HTTP.py | 100 ++++++ tests/集成/test_作品与人工正文.py | 204 +++++++++++ tests/集成/test_正文候选与作者决定.py | 310 +++++++++++++++++ 数据库/迁移/V0005__作品规划.sql | 94 +++++ 数据库/迁移/V0009__正文写作.sql | 50 +++ 25 files changed, 3949 insertions(+) create mode 100644 src/muse/作品规划/__init__.py create mode 100644 src/muse/作品规划/作品档案.py create mode 100644 src/muse/作品规划/存储.py create mode 100644 src/muse/作品规划/接口.py create mode 100644 src/muse/作品规划/模型.py create mode 100644 src/muse/作品规划/目录调整.py create mode 100644 src/muse/作品规划/进度查询.py create mode 100644 src/muse/接入/cli/作品命令.py create mode 100644 src/muse/接入/http/路由/作品规划.py create mode 100644 src/muse/接入/http/路由/正文写作.py create mode 100644 src/muse/接入/创作操作.py create mode 100644 src/muse/正文写作/__init__.py create mode 100644 src/muse/正文写作/人工保存.py create mode 100644 src/muse/正文写作/候选版本.py create mode 100644 src/muse/正文写作/存储.py create mode 100644 src/muse/正文写作/接口.py create mode 100644 src/muse/正文写作/模型.py create mode 100644 src/muse/正文写作/正式提交.py create mode 100644 src/muse/正文写作/正文格式.py create mode 100644 tests/契约/test_正文与模型输出.py create mode 100644 tests/集成/test_人工创作HTTP.py create mode 100644 tests/集成/test_作品与人工正文.py create mode 100644 tests/集成/test_正文候选与作者决定.py create mode 100644 数据库/迁移/V0005__作品规划.sql create mode 100644 数据库/迁移/V0009__正文写作.sql diff --git a/src/muse/作品规划/__init__.py b/src/muse/作品规划/__init__.py new file mode 100644 index 0000000..a58054a --- /dev/null +++ b/src/muse/作品规划/__init__.py @@ -0,0 +1 @@ +"""作品规划模块;公开接口见接口.py。""" diff --git a/src/muse/作品规划/作品档案.py b/src/muse/作品规划/作品档案.py new file mode 100644 index 0000000..c727446 --- /dev/null +++ b/src/muse/作品规划/作品档案.py @@ -0,0 +1,141 @@ +"""作品档案经S04校验与S01提交;业务实例值仅由B01写入。""" + +from dataclasses import asdict, dataclass + +from muse.作品规划.存储 import 作品存储 +from muse.作品规划.模型 import 作品错误, 档案保存, 档案结构 +from muse.元数据.接口 import ( + 元数据服务, + 字段变更, + 投影字段, + 授权摘要, + 校验字段变更, + 选择实例维护者, +) +from muse.正式变更.接口 import ( + 依赖引用, + 修改影响, + 参与操作, + 固定哈希, + 审阅内容, + 提交计划, +) + + +def 核对作者(身份) -> None: + if not 身份.允许写正式内容: + raise 作品错误("SCOPE_DENIED", "作品档案只供当前已认证作者操作") + + +def 档案授权(作者: str, work_id: str) -> 授权摘要: + return 授权摘要(固定哈希({"author": 作者}), 固定哈希({"work": work_id, "owner": 作者})) + + +def 读取档案结构( + 元数据: 元数据服务, + 作者: str, + work_id: str, + 选择: 档案结构, + *, + 字段用途名: str = "userEditable", +): + 结构 = 元数据.读取有效结构( + 选择.schema_id, 选择.base_version, work_id=work_id, extension_version=选择.extension_version + ) + 类型 = 元数据.读取类型(结构.基础.type_id) + 家族 = next((f for f in ("work", "planning") if f in 类型.实例族), None) + if 类型.实例粒度 != "work" or 家族 is None or 选择实例维护者(类型, 家族) != "B01": + raise 作品错误("SCOPE_DENIED", "档案结构必须属于作品级的B01生命周期") + 投影 = 投影字段( + 结构, + 元数据.当前策略(类型.type_id), + 档案授权(作者, work_id), + 字段用途名=字段用途名, + 内容用途="planning", + 运行用途="production", + ) + return 结构, 投影 + + +class 档案入口: + 身份 = "B01.save_profile" + 请求类型 = 档案保存 + + def 查看(self, 连, 身份, 命令): + 核对作者(身份) + 请求 = 命令.业务请求 + if 请求.target_ref != 命令.目标: + raise 作品错误("SCOPE_DENIED", "作品请求与目标不一致") + 行 = 作品存储(连).读取(请求.work_id) + if 行 is not None and 行["author_id"] != 身份.作者: + raise 作品错误("SCOPE_DENIED", "作品不属于当前作者") + 结构, 投影 = 读取档案结构(元数据服务(连), 身份.作者, 请求.work_id, 请求.schema) + 版本 = 行["current_revision"] if 行 else 0 + return 审阅内容( + 请求.target_ref, + 版本, + None, + None, + None, + 固定哈希( + [行["content"] if 行 else {}, 请求.content, [asdict(v) for v in 请求.changes]] + ), + 结构.effective_schema_hash, + 投影.projection_version, + 固定哈希({"owner": 身份.作者, "work": 请求.work_id}), + ("profile",), + (依赖引用("B01.work", 请求.target_ref, str(版本)),), + ) + + def 准备(self, 连, 身份, 命令, 当前): + return 提交计划( + (参与操作("B01.write_profile", 档案写入(身份.作者, 命令.业务请求)),), + (修改影响(命令.目标, "profile_changed", str(当前.数据版本), str(当前.数据版本 + 1)),), + ) + + +@dataclass(frozen=True) +class 档案写入: + 作者: str + 请求: 档案保存 + + +class 档案参与者: + 身份 = "B01.write_profile" + 请求类型 = 档案写入 + + def 提交(self, 连, 操作): + 请求 = 操作.请求 + 元数据 = 元数据服务(连) + 原 = 作品存储(连).读取(请求.work_id) + 结构, 投影 = 读取档案结构(元数据, 操作.作者, 请求.work_id, 请求.schema) + if 原 is None: + 绑定 = 元数据.建立初始绑定(请求.target_ref, 请求.work_id, 结构) + else: + 绑定 = 元数据.读取绑定(请求.target_ref) + 结构, 投影 = 元数据.保护提交( + 请求.target_ref, + 请求.schema.effective_schema_hash, + 请求.schema.projection_version, + 档案授权(操作.作者, 请求.work_id), + 字段用途名="userEditable", + 内容用途="planning", + 运行用途="production", + ) + 修改 = (*tuple(字段变更((k,), "set", v) for k, v in 请求.content.items()), *请求.changes) + 完整 = 校验字段变更(结构, 投影, 原["content"] if 原 else {}, 修改) + if 完整.引用需求: + raise 作品错误("PRECONDITION_PENDING", "本档案引用需要对应业务维护者解析后才能保存") + 版本 = 作品存储(连).写档案( + 请求.work_id, 操作.作者, 请求.expected_revision, 完整.值, 绑定, 固定哈希(完整.值) + ) + return {"work_id": 请求.work_id, "revision": 版本, "schema_binding": asdict(绑定)} + + +class 作品依赖: + 种类 = "B01.work" + + def 锁定当前(self, 连, 对象ID): + if not 对象ID.startswith("work:"): + raise 作品错误("SCOPE_DENIED", "不是作品目标身份") + return str(作品存储(连).锁作品(对象ID.removeprefix("work:"))) diff --git a/src/muse/作品规划/存储.py b/src/muse/作品规划/存储.py new file mode 100644 index 0000000..9b7361b --- /dev/null +++ b/src/muse/作品规划/存储.py @@ -0,0 +1,362 @@ +"""B01所属SQL;当前指针、版本和目录写入均沿用调用方事务。""" + +import hashlib +import json +from dataclasses import asdict + +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from muse.作品规划.模型 import 作品错误 +from muse.元数据.接口 import 结构绑定 + + +def 解析规划载荷(值) -> dict: + """归一化规划载荷:文本投影的JSON字符串转回对象;非法载荷失败关闭,不猜测。""" + if isinstance(值, str): + try: + 值 = json.loads(值) + except json.JSONDecodeError as exc: + raise 作品错误("INVALID_OUTPUT", "规划载荷不是合法JSON") from exc + if not isinstance(值, dict): + raise 作品错误("INVALID_OUTPUT", "规划载荷必须是对象") + return 值 + + +class 作品存储: + def __init__(self, 连): + self.连 = 连 + + def 读取探索选定(self, work_id: str) -> dict | None: + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT v.work_id,v.revision,v.snapshot,v.selected_by,v.selected_at::text " + "FROM muse_exploration_selection s JOIN muse_exploration_selection_version v " + "ON v.work_id=s.work_id AND v.revision=s.current_revision WHERE s.work_id=%s", + (work_id,), + ) + .fetchone() + ) + + def 锁探索选定(self, work_id: str) -> int: + key = int.from_bytes( + hashlib.sha256(("B01:exploration-selection:" + work_id).encode()).digest()[:8], + signed=True, + ) + self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,)) + row = self.连.execute( + "SELECT current_revision FROM muse_exploration_selection WHERE work_id=%s FOR UPDATE", + (work_id,), + ).fetchone() + return row[0] if row else 0 + + def 写探索选定(self, 作者: str, 请求, 快照: dict) -> dict: + version = 请求.expected_revision + 1 + if 请求.expected_revision == 0: + self.连.execute( + "INSERT INTO muse_exploration_selection(work_id,current_revision) VALUES (%s,%s)", + (请求.work_id, version), + ) + else: + row = self.连.execute( + "UPDATE muse_exploration_selection SET current_revision=%s " + "WHERE work_id=%s AND current_revision=%s RETURNING work_id", + (version, 请求.work_id, 请求.expected_revision), + ).fetchone() + if row is None: + raise 作品错误("REVISION_CONFLICT", "作品已选定另一份探索版本") + self.连.execute( + "INSERT INTO muse_exploration_selection_version" + "(work_id,revision,snapshot,selected_by) VALUES (%s,%s,%s,%s)", + (请求.work_id, version, Jsonb(快照), 作者), + ) + return { + "work_id": 请求.work_id, + "revision": version, + "draft_id": 快照["draft_id"], + "draft_hash": 快照["draft_hash"], + } + + def 列出作品(self, 作者: str) -> list[str]: + return [ + 行[0] + for 行 in self.连.execute( + "SELECT work_id FROM muse_work WHERE author_id=%s ORDER BY work_id", (作者,) + ).fetchall() + ] + + def 读取(self, work_id: str) -> dict | None: + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT w.*,v.content,v.schema_binding,v.content_hash " + "FROM muse_work w JOIN muse_work_version v ON v.work_id=w.work_id " + "AND v.revision=w.current_revision WHERE w.work_id=%s", + (work_id,), + ) + .fetchone() + ) + + def 锁作品(self, work_id: str) -> int: + # 新作品尚无行;所有B01当前指针更新也使用此事务锁,关闭首次创建竞争窗口。 + key = int.from_bytes( + hashlib.sha256(("B01:work:" + work_id).encode()).digest()[:8], signed=True + ) + self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,)) + 行 = self.连.execute( + "SELECT current_revision FROM muse_work WHERE work_id=%s FOR UPDATE", (work_id,) + ).fetchone() + return 行[0] if 行 else 0 + + def 写档案( + self, work_id: str, 作者: str, 预期版本: int, 内容: dict, 绑定: 结构绑定, 哈希: str + ) -> int: + 新版 = 预期版本 + 1 + if 预期版本 == 0: + self.连.execute( + "INSERT INTO muse_work(work_id,author_id,current_revision) VALUES (%s,%s,%s)", + (work_id, 作者, 新版), + ) + self.连.execute( + "INSERT INTO muse_directory_version(work_id,revision,chapters) VALUES (%s,0,'[]')", + (work_id,), + ) + else: + 行 = self.连.execute( + "UPDATE muse_work SET current_revision=%s WHERE work_id=%s " + "AND author_id=%s AND current_revision=%s RETURNING work_id", + (新版, work_id, 作者, 预期版本), + ).fetchone() + if 行 is None: + raise 作品错误("REVISION_CONFLICT", "作品档案已被其他保存更新") + self.连.execute( + "INSERT INTO muse_work_version(work_id,revision,content,schema_binding,content_hash) " + "VALUES (%s,%s,%s,%s,%s)", + (work_id, 新版, Jsonb(内容), Jsonb(asdict(绑定)), 哈希), + ) + return 新版 + + def 读取目录(self, work_id: str) -> dict: + 行 = self.连.execute( + "SELECT v.revision,v.chapters FROM muse_work w JOIN muse_directory_version v " + "ON v.work_id=w.work_id AND v.revision=w.directory_revision WHERE w.work_id=%s", + (work_id,), + ).fetchone() + if 行 is None: + raise 作品错误("WORK_NOT_FOUND", "作品目录不存在") + return {"work_id": work_id, "revision": 行[0], "chapters": 行[1]} + + def 读取节点目录(self, work_id: str, 版本: int | None = None) -> dict: + row = self.连.execute( + "SELECT v.revision,v.nodes,v.chapters FROM muse_work w JOIN muse_directory_version v " + "ON v.work_id=w.work_id AND v.revision=COALESCE(%s,w.directory_revision) " + "WHERE w.work_id=%s", + (版本, work_id), + ).fetchone() + if row is None: + raise 作品错误("WORK_NOT_FOUND", "该作品目录版本不存在") + # 早期单层目录也解释为同一组稳定章节点;不把旧正文迁入规划。 + nodes = row[1] or [ + { + "node_id": c["chapter_id"], + "kind": "chapter", + "title": c["title"], + "parent_id": None, + "position": c["position"], + } + for c in row[2] + ] + return {"work_id": work_id, "revision": row[0], "nodes": nodes} + + def 写完整目录( + self, work_id: str, 预期版本: int, nodes: list[dict], chapters: list[dict] + ) -> dict: + for chapter in chapters: + updated = self.连.execute( + "INSERT INTO muse_chapter(chapter_id,work_id,title,position) VALUES (%s,%s,%s,%s) " + "ON CONFLICT(chapter_id) DO UPDATE " + "SET title=EXCLUDED.title,position=EXCLUDED.position " + "WHERE muse_chapter.work_id=EXCLUDED.work_id RETURNING chapter_id", + (chapter["chapter_id"], work_id, chapter["title"], chapter["position"]), + ).fetchone() + if updated is None: + raise 作品错误("SCOPE_DENIED", "章节身份已属于其他作品") + 新版 = 预期版本 + 1 + self.连.execute( + "INSERT INTO muse_directory_version(work_id,revision,chapters,nodes) " + "VALUES (%s,%s,%s,%s)", + (work_id, 新版, Jsonb(chapters), Jsonb(nodes)), + ) + row = self.连.execute( + "UPDATE muse_work SET directory_revision=%s WHERE work_id=%s AND directory_revision=%s " + "RETURNING work_id", + (新版, work_id, 预期版本), + ).fetchone() + if row is None: + raise 作品错误("REVISION_CONFLICT", "目录已改变") + return { + "work_id": work_id, + "revision": 新版, + "chapter_ids": [v["chapter_id"] for v in chapters], + } + + def 读取章节归属(self, chapter_id: str) -> dict | None: + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT c.chapter_id,c.work_id,c.title,c.position,w.author_id,w.directory_revision " + "FROM muse_chapter c JOIN muse_work w USING(work_id) WHERE c.chapter_id=%s", + (chapter_id,), + ) + .fetchone() + ) + + +class 规划存储: + def __init__(self, 连): + self.连 = 连 + + def 列出候选(self, work_id: str) -> list[str]: + return [ + str(r[0]) + for r in self.连.execute( + "SELECT candidate_id FROM muse_plan_candidate " + "WHERE work_id=%s ORDER BY candidate_id", + (work_id,), + ).fetchall() + ] + + def 查章规划(self, work_id: str, chapter_id: str, type_id: str): + row = self.连.execute( + "SELECT plan_id FROM muse_plan WHERE work_id=%s AND chapter_id=%s AND type_id=%s", + (work_id, chapter_id, type_id), + ).fetchone() + return self.读取(row[0]) if row else None + + def 读取(self, plan_id: str, 版本: int | None = None): + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT p.*,v.revision,v.request,v.content,v.content_hash,v.schema_binding," + "v.source_dependencies FROM muse_plan p JOIN muse_plan_version v " + "ON v.plan_id=p.plan_id AND v.revision=COALESCE(%s,p.current_revision) " + "WHERE p.plan_id=%s", + (版本, plan_id), + ) + .fetchone() + ) + + def 读取候选(self, candidate_id: str, 版本: int | None = None): + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT p.*,v.revision,v.request,v.content,v.source_dependencies,v.candidate_hash " + "FROM muse_plan_candidate p JOIN muse_plan_candidate_version v " + "ON v.candidate_id=p.candidate_id AND v.revision=COALESCE(%s,p.current_revision) " + "WHERE p.candidate_id=%s", + (版本, candidate_id), + ) + .fetchone() + ) + + def 列出(self, work_id: str) -> list[str]: + return [ + r[0] + for r in self.连.execute( + "SELECT plan_id FROM muse_plan WHERE work_id=%s ORDER BY plan_id", (work_id,) + ).fetchall() + ] + + def 锁规划(self, plan_id: str) -> int: + key = int.from_bytes( + hashlib.sha256(("B01:plan:" + plan_id).encode()).digest()[:8], signed=True + ) + self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,)) + r = self.连.execute( + "SELECT current_revision FROM muse_plan WHERE plan_id=%s FOR UPDATE", (plan_id,) + ).fetchone() + return r[0] if r else 0 + + def 锁候选(self, candidate_id: str) -> int: + key = int.from_bytes( + hashlib.sha256(("B01:plan-candidate:" + candidate_id).encode()).digest()[:8], + signed=True, + ) + self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,)) + r = self.连.execute( + "SELECT current_revision FROM muse_plan_candidate WHERE candidate_id=%s FOR UPDATE", + (candidate_id,), + ).fetchone() + return r[0] if r else 0 + + def 写候选(self, candidate_id: str, plan_id: str, 请求, 内容, 来源, 哈希, 预期版本: int = 0): + revision = 预期版本 + 1 + if 预期版本 == 0: + self.连.execute( + "INSERT INTO muse_plan_candidate(candidate_id,work_id,plan_id,current_revision) " + "VALUES (%s,%s,%s,%s)", + (candidate_id, 请求.work_id, plan_id, revision), + ) + else: + changed = self.连.execute( + "UPDATE muse_plan_candidate SET current_revision=%s WHERE candidate_id=%s " + "AND work_id=%s AND plan_id=%s AND current_revision=%s RETURNING candidate_id", + (revision, candidate_id, 请求.work_id, plan_id, 预期版本), + ).fetchone() + if changed is None: + raise 作品错误("REVISION_CONFLICT", "规划候选已被编辑") + self.连.execute( + "INSERT INTO muse_plan_candidate_version" + "(candidate_id,revision,request,content,source_dependencies,candidate_hash) " + "VALUES (%s,%s,%s,%s,%s,%s)", + ( + candidate_id, + revision, + Jsonb(asdict(请求)), + Jsonb(内容), + Jsonb([asdict(s) for s in 来源]), + 哈希, + ), + ) + return { + "candidate_id": candidate_id, + "plan_id": plan_id, + "revision": revision, + "candidate_hash": 哈希, + } + + def 写规划(self, plan_id, 请求, 类型ID, 内容, 绑定, 来源, 作者, 哈希, 章节ID=None): + revision = 请求.expected_revision + 1 + if 请求.expected_revision == 0: + self.连.execute( + "INSERT INTO muse_plan" + "(plan_id,work_id,type_id,chapter_id,node_id,current_revision) " + "VALUES (%s,%s,%s,%s,%s,%s)", + (plan_id, 请求.work_id, 类型ID, 章节ID, 请求.node_id or 请求.chapter_id, revision), + ) + else: + r = self.连.execute( + "UPDATE muse_plan SET current_revision=%s,chapter_id=%s WHERE plan_id=%s " + "AND current_revision=%s RETURNING plan_id", + (revision, 章节ID, plan_id, 请求.expected_revision), + ).fetchone() + if r is None: + raise 作品错误("REVISION_CONFLICT", "规划正式版本已改变") + self.连.execute( + "INSERT INTO muse_plan_version" + "(plan_id,revision,request,content,content_hash,schema_binding," + "source_dependencies,author_id) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", + ( + plan_id, + revision, + Jsonb(asdict(请求)), + Jsonb(内容), + 哈希, + Jsonb(asdict(绑定)), + Jsonb([asdict(s) for s in 来源]), + 作者, + ), + ) + return {"plan_id": plan_id, "revision": revision, "content_hash": 哈希} diff --git a/src/muse/作品规划/接口.py b/src/muse/作品规划/接口.py new file mode 100644 index 0000000..1f989ed --- /dev/null +++ b/src/muse/作品规划/接口.py @@ -0,0 +1,287 @@ +"""作品身份、动态档案及目录的唯一公开入口。""" + +from dataclasses import asdict + +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 元数据服务, 投影值, 投影字段, 生成结构合同 +from muse.基础设施.数据库.连接 import 数据库工厂 +from muse.正式变更.接口 import 作者动作, 参与者目录, 变更命令, 正式变更服务 + + +def 登记作品参与者(目录: 参与者目录, 探索: 探索服务 | None = None, 规划引用解析=None) -> None: + 目录.登记入口(档案入口()) + 目录.登记参与者(档案参与者()) + 目录.登记依赖(作品依赖()) + 目录.登记入口(目录入口()) + 目录.登记入口(节点目录入口()) + 目录.登记参与者(目录参与者()) + 目录.登记依赖(目录依赖()) + 目录.登记入口(探索选定入口(探索)) + 目录.登记参与者(探索选定参与者()) + 目录.登记依赖(探索选定依赖()) + 登记规划参与者(目录, 规划引用解析) + + +class 作品服务: + def __init__(self, 数据库: 数据库工厂, 正式: 正式变更服务): + self.数据库, self.正式 = 数据库, 正式 + + def 读取节点目录(self, 身份, work_id: str, *, 版本: int | None = None) -> dict: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + 读取作者目录(连, 身份.作者, work_id) + return 作品存储(连).读取节点目录(work_id, 版本) + + def 保存节点目录(self, 身份, 命令ID: str, 请求: 节点目录保存) -> dict: + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B01.change_plan_nodes", + 请求.target_ref, + 作者动作.人工保存, + 请求.expected_revision, + 请求, + ), + ) + + def 选定探索稿(self, 身份, 命令ID: str, 请求: 探索选择) -> dict: + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B01.select_exploration", + 请求.target_ref, + 作者动作.人工保存, + 请求.expected_revision, + 请求, + ), + ) + + def 读取探索选定(self, 身份, work_id: str) -> dict: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + 读取作者目录(连, 身份.作者, work_id) + 当前 = 作品存储(连).读取探索选定(work_id) + return { + "work_id": work_id, + "revision": 当前["revision"] if 当前 else 0, + "selection": 当前, + } + + def 列出作品(self, 身份) -> list[dict]: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + ids = 作品存储(连).列出作品(身份.作者) + return [self.读取作品(身份, id_) for id_ in ids] + + def 读取表单( + self, 身份, work_id: str, *, schema_id: str | None = None, base_version: int | None = None + ) -> dict: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + 行 = 作品存储(连).读取(work_id) + 元数据 = 元数据服务(连) + if 行 is not None: + if 行["author_id"] != 身份.作者: + raise 作品错误("SCOPE_DENIED", "作品不属于当前作者") + 绑定 = 元数据.读取绑定("work:" + work_id) + 选择 = 档案结构( + 绑定.schema_id, + 绑定.base_version, + 绑定.effective_schema_hash, + "", + 绑定.extension_version, + ) + elif schema_id is not None and base_version is not None: + 选择 = 档案结构(schema_id, base_version, "", "") + else: + raise 作品错误("WORK_NOT_FOUND", "作品不存在;新建时需要选择档案结构") + 结构, 编辑投影 = 读取档案结构(元数据, 身份.作者, work_id, 选择) + _, 可见投影 = 读取档案结构(元数据, 身份.作者, work_id, 选择, 字段用途名="uiVisible") + return { + "work_id": work_id, + "revision": 行["current_revision"] if 行 else 0, + "content": 投影值(可见投影, 行["content"]) if 行 else {}, + "schema_binding": asdict( + 档案结构( + 选择.schema_id, + 选择.base_version, + 结构.effective_schema_hash, + 编辑投影.projection_version, + 选择.extension_version, + ) + ), + "visible_schema": 生成结构合同(可见投影), + "editable_schema": 生成结构合同(编辑投影), + "visible_projection_version": 可见投影.projection_version, + } + + def 新档案结构( + self, 身份, work_id: str, schema_id: str, 版本: int, *, 扩展版本: int | None = None + ) -> 档案结构: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + 行 = 作品存储(连).读取(work_id) + if 行 is not None and 行["author_id"] != 身份.作者: + raise 作品错误("SCOPE_DENIED", "作品不属于当前作者") + 选择 = 档案结构(schema_id, 版本, "", "", 扩展版本) + 结构, 投影 = 读取档案结构(元数据服务(连), 身份.作者, work_id, 选择) + return 档案结构( + schema_id, 版本, 结构.effective_schema_hash, 投影.projection_version, 扩展版本 + ) + + def 保存档案(self, 身份, 命令ID: str, 请求: 档案保存) -> dict: + 核对作者(身份) + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B01.save_profile", + 请求.target_ref, + 作者动作.人工保存, + 请求.expected_revision, + 请求, + ), + ) + + def 读取作品(self, 身份, work_id: str) -> dict: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + 行 = 作品存储(连).读取(work_id) + if 行 is None: + raise 作品错误("WORK_NOT_FOUND", "作品不存在") + if 行["author_id"] != 身份.作者: + raise 作品错误("SCOPE_DENIED", "作品不属于当前作者") + binding = asdict(元数据服务(连).读取绑定("work:" + work_id)) + 结构, 投影 = 读取档案结构( + 元数据服务(连), + 身份.作者, + work_id, + 档案结构( + binding["schema_id"], + binding["base_version"], + binding["effective_schema_hash"], + "", + binding["extension_version"], + ), + ) + 可见投影 = 投影字段( + 结构, + 元数据服务(连).当前策略(结构.基础.type_id), + 档案授权(身份.作者, work_id), + 字段用途名="uiVisible", + 内容用途="planning", + 运行用途="production", + ) + return { + "work_id": work_id, + "revision": 行["current_revision"], + "content": 投影值(可见投影, 行["content"]), + "schema_binding": binding, + "projection_version": 投影.projection_version, + "visible_projection_version": 可见投影.projection_version, + } + + def 添加章节( + self, + 身份, + 命令ID: str, + work_id: str, + chapter_id: str, + 标题: str, + *, + 预期目录版本: int, + ) -> dict: + return self._提交目录( + 身份, 命令ID, 目录修改(work_id, 预期目录版本, "append", (chapter_id,), 标题) + ) + + def 重排章节( + self, + 身份, + 命令ID: str, + work_id: str, + 章节顺序: tuple[str, ...], + *, + 预期目录版本: int, + ) -> dict: + return self._提交目录(身份, 命令ID, 目录修改(work_id, 预期目录版本, "reorder", 章节顺序)) + + def _提交目录(self, 身份, 命令ID: str, 请求: 目录修改) -> dict: + 核对作者(身份) + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B01.change_directory", + 请求.target_ref, + 作者动作.人工保存, + 请求.expected_revision, + 请求, + ), + ) + + def 读取目录(self, 身份, work_id: str) -> dict: + 核对作者(身份) + with self.数据库.连接(只读=True) as 连: + return 读取作者目录(连, 身份.作者, work_id) + + +def 读取章节归属(连, 作者: str, chapter_id: str) -> dict: + """同事务参与者读取真实章节范围;不允许B05直接读B01表。""" + 行 = 作品存储(连).读取章节归属(chapter_id) + if 行 is None: + raise 作品错误("CHAPTER_NOT_FOUND", "章节不存在") + if 行["author_id"] != 作者: + raise 作品错误("SCOPE_DENIED", "章节不属于当前作者") + return {k: v for k, v in 行.items() if k != "author_id"} + + +def 读取作品范围(连, 作者: str, work_id: str) -> dict: + """跨模块同事务读取目录与作者归属,不暴露作品私表。""" + return 读取作者目录(连, 作者, work_id) + + +__all__ = [ + "作品服务", + "档案保存", + "档案结构", + "作品错误", + "登记作品参与者", + "读取章节归属", + "创作进度服务", + "读取作品范围", + "探索服务", + "校验交付格式", + "探索选择", + "规划保存", + "规划定位", + "规划服务", + "目录节点", + "节点目录保存", +] diff --git a/src/muse/作品规划/模型.py b/src/muse/作品规划/模型.py new file mode 100644 index 0000000..9749abf --- /dev/null +++ b/src/muse/作品规划/模型.py @@ -0,0 +1,143 @@ +"""作品身份、目录与动态档案请求;内容字段由S04描述。""" + +from typing import Any, Literal + +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass + +from muse.元数据.接口 import 字段变更 +from muse.共享.错误 import Muse错误 + + +class 作品错误(Muse错误): + def __init__(self, 代码: str, 说明: str): + super().__init__(说明) + self.错误码 = 代码 + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 档案结构: + schema_id: str + base_version: int + effective_schema_hash: str + projection_version: str + extension_version: int | None = None + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 档案保存: + work_id: str + expected_revision: int + content: dict[str, Any] + schema: 档案结构 + changes: tuple[字段变更, ...] = () + + def __post_init__(self): + if not self.work_id or self.expected_revision < 0: + raise 作品错误("INVALID_REQUEST", "档案保存需要稳定作品身份与预期版本") + + @property + def target_ref(self) -> str: + return "work:" + self.work_id + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 目录修改: + work_id: str + expected_revision: int + operation: str + chapter_ids: tuple[str, ...] + new_title: str = "" + + def __post_init__(self): + if self.operation not in {"append", "reorder"} or self.expected_revision < 0: + raise 作品错误("INVALID_REQUEST", "目录操作或预期版本无效") + if len(set(self.chapter_ids)) != len(self.chapter_ids) or any( + not id_ for id_ in self.chapter_ids + ): + raise 作品错误("INVALID_REQUEST", "章节身份不能为空或重复") + if self.operation == "append" and len(self.chapter_ids) != 1: + raise 作品错误("INVALID_REQUEST", "新增章节需要一个稳定章节身份") + + @property + def target_ref(self) -> str: + return "directory:" + self.work_id + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 探索选择: + work_id: str + draft_id: str + expected_hash: str + expected_revision: int + + def __post_init__(self): + if ( + not self.work_id + or not self.draft_id.strip() + or not self.expected_hash + or self.expected_revision < 0 + ): + raise 作品错误("INVALID_REQUEST", "探索交接需要作品、明确稿件、内容哈希和选定版本") + + @property + def target_ref(self) -> str: + return "exploration-selection:" + self.work_id + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 目录节点: + node_id: str + kind: Literal["volume", "chapter", "scene"] + title: str + parent_id: str | None = None + + def __post_init__(self): + if not self.node_id or not self.title.strip(): + raise 作品错误("INVALID_REQUEST", "目录节点需要稳定身份和名称") + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 节点目录保存: + work_id: str + expected_revision: int + nodes: tuple[目录节点, ...] + + def __post_init__(self): + if not self.work_id or self.expected_revision < 0: + raise 作品错误("INVALID_REQUEST", "节点目录需要作品身份和预期版本") + + @property + def target_ref(self) -> str: + return "directory:" + self.work_id + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 规划保存: + work_id: str + expected_revision: int + schema: 档案结构 + content: dict[str, Any] + chapter_id: str | None = None + selection_revision: int | None = None + changes: tuple[字段变更, ...] = () + node_id: str | None = None + + def __post_init__(self): + if not self.work_id or self.expected_revision < 0: + raise 作品错误("INVALID_REQUEST", "规划需要作品身份与预期版本") + if self.selection_revision is not None and self.selection_revision < 1: + raise 作品错误("INVALID_REQUEST", "采用探索来源时需要真实的已选定版本") + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 规划定位: + candidate_id: str + candidate_revision: int + candidate_hash: str + plan_id: str + expected_revision: int + + @property + def target_ref(self) -> str: + return "plan:" + self.plan_id diff --git a/src/muse/作品规划/目录调整.py b/src/muse/作品规划/目录调整.py new file mode 100644 index 0000000..9fb10ae --- /dev/null +++ b/src/muse/作品规划/目录调整.py @@ -0,0 +1,131 @@ +"""稳定章节身份与有版本目录;重排不会碰正文版本。""" + +from dataclasses import asdict, dataclass + +from muse.作品规划.作品档案 import 核对作者 +from muse.作品规划.存储 import 作品存储 +from muse.作品规划.模型 import 作品错误, 目录修改, 目录节点, 节点目录保存 +from muse.正式变更.接口 import 依赖引用, 修改影响, 参与操作, 固定哈希, 审阅内容, 提交计划 + + +def 读取作者目录(连, 作者: str, work_id: str) -> dict: + 当前 = 作品存储(连).读取(work_id) + if 当前 is None: + raise 作品错误("WORK_NOT_FOUND", "作品不存在") + if 当前["author_id"] != 作者: + raise 作品错误("SCOPE_DENIED", "作品不属于当前作者") + return 作品存储(连).读取目录(work_id) + + +@dataclass(frozen=True) +class 目录写入: + 作者: str + 请求: 目录修改 | 节点目录保存 + + +def 组织目录(nodes: tuple[目录节点, ...], 原节点: list[dict]) -> tuple[list[dict], list[dict]]: + by_id = {n.node_id: n for n in nodes} + if len(by_id) != len(nodes): + raise 作品错误("INVALID_REQUEST", "目录节点身份不能重复") + for old in 原节点: + if old["node_id"] not in by_id or by_id[old["node_id"]].kind != old["kind"]: + raise 作品错误("SCOPE_DENIED", "目录保存必须保留已有节点身份与种类") + children: dict[str | None, list[目录节点]] = {} + for node in nodes: + parent = by_id.get(node.parent_id) if node.parent_id else None + allowed = {"volume": {None}, "chapter": {None, "volume"}, "scene": {"chapter"}} + if (node.parent_id is not None and parent is None) or ( + parent.kind if parent else None + ) not in allowed[node.kind]: + raise 作品错误("PLAN_SCOPE_INVALID", "卷属于作品,章属于作品或卷,场景属于章") + children.setdefault(node.parent_id, []).append(node) + ordered, chapters = [], [] + + def 展开(parent): + for position, node in enumerate(children.get(parent, []), 1): + ordered.append({**asdict(node), "position": position}) + if node.kind == "chapter": + chapters.append( + {"chapter_id": node.node_id, "title": node.title, "position": len(chapters) + 1} + ) + 展开(node.node_id) + + 展开(None) + return ordered, chapters + + +class 目录入口: + 身份 = "B01.change_directory" + 请求类型: type[目录修改] | type[节点目录保存] = 目录修改 + + def 查看(self, 连, 身份, 命令): + 核对作者(身份) + 请求 = 命令.业务请求 + if 请求.target_ref != 命令.目标: + raise 作品错误("SCOPE_DENIED", "目录目标不一致") + 目录 = 读取作者目录(连, 身份.作者, 请求.work_id) + return 审阅内容( + 请求.target_ref, + 目录["revision"], + None, + None, + None, + 固定哈希([目录, 固定哈希(请求)]), + None, + None, + 固定哈希({"owner": 身份.作者, "work": 请求.work_id}), + ("directory",), + (依赖引用("B01.directory", 请求.target_ref, str(目录["revision"])),), + ) + + def 准备(self, 连, 身份, 命令, 当前): + return 提交计划( + (参与操作("B01.write_directory", 目录写入(身份.作者, 命令.业务请求)),), + (修改影响(命令.目标, "directory_changed", str(当前.数据版本), str(当前.数据版本 + 1)),), + ) + + +class 目录参与者: + 身份 = "B01.write_directory" + 请求类型 = 目录写入 + + def 提交(self, 连, 操作): + 当前 = 读取作者目录(连, 操作.作者, 操作.请求.work_id) + if 当前["revision"] != 操作.请求.expected_revision: + raise 作品错误("REVISION_CONFLICT", "目录已改变") + store = 作品存储(连) + previous = store.读取节点目录(操作.请求.work_id)["nodes"] + req = 操作.请求 + if isinstance(req, 节点目录保存): + nodes = req.nodes + else: + nodes = tuple( + 目录节点(n["node_id"], n["kind"], n["title"], n["parent_id"]) for n in previous + ) + if req.operation == "append": + nodes = (*nodes, 目录节点(req.chapter_ids[0], "chapter", req.new_title)) + else: + if set(req.chapter_ids) != {c["chapter_id"] for c in 当前["chapters"]}: + raise 作品错误("REVISION_CONFLICT", "重排必须包含当前目录全部章节") + if any(n.kind == "volume" for n in nodes): + raise 作品错误("PLAN_SCOPE_INVALID", "分卷目录请在节点视图中重排") + rank = {id_: i for i, id_ in enumerate(req.chapter_ids)} + nodes = tuple(sorted(nodes, key=lambda n: rank.get(n.node_id, len(rank)))) + ordered, chapters = 组织目录(nodes, previous) + return store.写完整目录(req.work_id, req.expected_revision, ordered, chapters) + + +class 节点目录入口(目录入口): + 身份 = "B01.change_plan_nodes" + 请求类型 = 节点目录保存 + + +class 目录依赖: + 种类 = "B01.directory" + + def 锁定当前(self, 连, 对象ID): + if not 对象ID.startswith("directory:"): + raise 作品错误("SCOPE_DENIED", "不是作品目录身份") + work_id = 对象ID.removeprefix("directory:") + 作品存储(连).锁作品(work_id) + return str(作品存储(连).读取目录(work_id)["revision"]) diff --git a/src/muse/作品规划/进度查询.py b/src/muse/作品规划/进度查询.py new file mode 100644 index 0000000..b4305b8 --- /dev/null +++ b/src/muse/作品规划/进度查询.py @@ -0,0 +1,54 @@ +"""只读组装实际正文、候选与任务版本,不持久化第二份进度值。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from muse.任务运行.接口 import 任务服务 + from muse.作品规划.接口 import 作品服务 + from muse.共享.调用身份 import 调用身份 + from muse.正文写作.接口 import 正文服务 + + +class 创作进度服务: + def __init__(self, 作品: 作品服务, 正文: 正文服务, 任务: 任务服务): + self.作品, self.正文, self.任务 = 作品, 正文, 任务 + + def 查询(self, 身份: 调用身份, work_id: str) -> dict: + # B01先验证作者与作品,B05及S02仍按自己的公开接口核对实际数据。 + 目录 = self.作品.读取目录(身份, work_id) + assert 身份.作者 is not None + 章节 = [] + for 章 in 目录["chapters"]: + 文稿 = self.正文.读取正文(身份, 章["chapter_id"]) + 候选 = self.正文.列出候选(身份, 章["chapter_id"]) + 章节.append( + { + "chapter_id": 章["chapter_id"], + "title": 章["title"], + "position": 章["position"], + "document_revision": 文稿["revision"], + "document_hash": 文稿["document_hash"], + "visible_characters": len(文稿["visible_text"]), + "candidates": [ + {k: v[k] for k in ("candidate_id", "revision", "decision", "stale")} + for v in 候选 + ], + } + ) + 任务 = self.任务.列出任务(身份.作者, 作品ID=work_id) + return { + "work_id": work_id, + "directory_revision": 目录["revision"], + "total_chapters": len(章节), + "saved_chapters": sum(章["document_revision"] > 0 for 章 in 章节), + "undecided_candidates": sum( + c["decision"] in {"undecided", "defer"} for 章 in 章节 for c in 章["candidates"] + ), + "chapters": 章节, + "tasks": [ + {"task_id": t.任务ID, "state": t.状态.value, "event_cursor": t.最后序号} + for t in 任务 + ], + } diff --git a/src/muse/接入/cli/作品命令.py b/src/muse/接入/cli/作品命令.py new file mode 100644 index 0000000..f1792ce --- /dev/null +++ b/src/muse/接入/cli/作品命令.py @@ -0,0 +1,171 @@ +"""作品、目录与人工正文的CLI适配;不写SQL,不替作者采纳模型候选。""" + +from dataclasses import asdict +from pathlib import Path + +from pydantic import TypeAdapter, ValidationError + +from muse.作品规划.接口 import 规划定位 +from muse.共享.错误 import 配置错误 +from muse.接入.主会话 import 参数合同错误 +from muse.接入.创作操作 import ( + 人工保存请求, + 创作身份, + 回退正文请求, + 探索保存请求, + 探索选定请求, + 新增章节请求, + 档案保存请求, + 节点目录请求, + 规划候选请求, + 规划决定请求, + 规划编辑请求, + 重排章节请求, +) +from muse.正式变更.接口 import 作者动作 + + +def 运行作品命令( + 装配, 动作: str, 目标: str, *, 结构ID="work_core", 版本: int = 1, 分支="main" +) -> dict: + if 装配.配置.HTTP is None: + raise 配置错误("人工创作需要本地作者身份配置") + 身份 = 创作身份(装配.配置.HTTP.作者ID, 装配.配置) + 作品 = 装配.要求作品() + 正文 = 装配.要求正文() + if 动作 == "结构": + return asdict(作品.新档案结构(身份, 目标, 结构ID, 版本)) + if 动作 == "查看": + return 作品.读取作品(身份, 目标) + if 动作 == "目录": + return 作品.读取目录(身份, 目标) + if 动作 == "节点目录": + return 作品.读取节点目录(身份, 目标) + if 动作 == "进度": + return 装配.要求作品进度().查询(身份, 目标) + if 动作 == "正文": + return 正文.读取正文(身份, 目标, 分支=分支) + 类型 = { + "保存档案": 档案保存请求, + "新增章节": 新增章节请求, + "重排章节": 重排章节请求, + "保存节点": 节点目录请求, + "保存正文": 人工保存请求, + "回退正文": 回退正文请求, + }[动作] + try: + 请求 = 类型.model_validate_json(Path(目标).read_text(encoding="utf-8")) + except ValidationError as exc: + raise 参数合同错误(exc.errors(), 前缀=("body",)) from None + if isinstance(请求, 档案保存请求): + return 作品.保存档案(身份, 请求.command_id, 请求.业务请求()) + if isinstance(请求, 节点目录请求): + return 作品.保存节点目录(身份, 请求.command_id, 请求.directory) + if isinstance(请求, 新增章节请求): + return 作品.添加章节( + 身份, + 请求.command_id, + 请求.work_id, + 请求.chapter_id, + 请求.title, + 预期目录版本=请求.expected_directory_revision, + ) + if isinstance(请求, 重排章节请求): + return 作品.重排章节( + 身份, + 请求.command_id, + 请求.work_id, + tuple(请求.chapter_ids), + 预期目录版本=请求.expected_directory_revision, + ) + if isinstance(请求, 人工保存请求): + return 正文.保存人工( + 身份, + 请求.command_id, + 请求.chapter_id, + 请求.expected_revision, + 请求.document, + 分支=请求.branch_id, + ) + return 正文.回退正文( + 身份, + 请求.command_id, + 请求.chapter_id, + 请求.expected_revision, + 请求.restore_from, + 分支=请求.branch_id, + ) + + +def 运行探索命令(装配, 动作: str, 目标: str | None, *, 预期哈希: str | None = None): + if 装配.配置.HTTP is None: + raise 配置错误("私人探索需要本地作者身份配置") + 身份 = 创作身份(装配.配置.HTTP.作者ID, 装配.配置) + if 动作 == "查看选定" and 目标: + return 装配.要求作品().读取探索选定(身份, 目标) + if 动作 == "列表": + return 装配.要求探索().列出(身份) + if not 目标: + raise 配置错误("当前探索动作需要稿件身份或保存请求文件") + if 动作 == "查看": + return 装配.要求探索().读取(身份, 目标) + if 动作 == "检查": + if not 预期哈希: + raise 配置错误("整理稿检查需要固定稿件哈希") + return 装配.要求探索().检查交付(身份, 目标, 预期哈希) + try: + 请求类型 = 探索选定请求 if 动作 == "选定" else 探索保存请求 + 请求 = 请求类型.model_validate_json(Path(目标).read_text(encoding="utf-8")) + except (OSError, UnicodeError): + raise 配置错误("无法读取私人探索保存请求文件") from None + except ValidationError as exc: + raise 参数合同错误(exc.errors(), 前缀=("body",)) from None + if isinstance(请求, 探索选定请求): + return 装配.要求作品().选定探索稿(身份, 请求.command_id, 请求.selection) + return 装配.要求探索().保存(身份, 请求.draft_id, 请求.title, 请求.content, 请求.expected_hash) + + +def 运行规划命令(装配, 动作: str, 目标: str, *, 结构ID: str, 版本: int, 章节ID: str | None = None): + if 装配.配置.HTTP is None: + raise 配置错误("规划操作需要本地作者身份配置") + actor = 创作身份(装配.配置.HTTP.作者ID, 装配.配置) + service = 装配.要求规划() + if 动作 == "读取细纲": + if not 章节ID: + raise 配置错误("读取可用细纲需要明确章节身份") + return service.读取可用细纲(actor, 目标, 章节ID) + if 动作 == "结构": + return asdict(service.新规划结构(actor, 目标, 结构ID, 版本)) + if 动作 == "列表": + return service.列出规划(actor, 目标) + if 动作 == "查看": + return service.读取规划(actor, 目标) + if 动作 == "查看候选": + return service.读取候选(actor, 目标) + 类型 = { + "创建候选": 规划候选请求, + "编辑候选": 规划编辑请求, + "打开审阅": 规划定位, + "决定": 规划决定请求, + }[动作] + try: + req = TypeAdapter(类型).validate_json(Path(目标).read_text(encoding="utf-8")) + except (OSError, UnicodeError): + raise 配置错误("无法读取规划操作请求文件") from None + except ValidationError as exc: + raise 参数合同错误(exc.errors(), 前缀=("body",)) from None + if isinstance(req, 规划候选请求): + return service.保存人工候选(actor, req.command_id, req.request) + if isinstance(req, 规划编辑请求): + return service.编辑候选(actor, req.command_id, req.candidate, tuple(req.changes)) + if isinstance(req, 规划定位): + return service.打开审阅(actor, req) + return service.决定( + actor, + req.command_id, + req.candidate, + 作者动作(req.action), + req.author_review_id, + req.review_hash, + tuple(req.approved_changes), + ) diff --git a/src/muse/接入/http/路由/作品规划.py b/src/muse/接入/http/路由/作品规划.py new file mode 100644 index 0000000..9f4fae6 --- /dev/null +++ b/src/muse/接入/http/路由/作品规划.py @@ -0,0 +1,315 @@ +"""作品与目录HTTP适配;身份来自会话,读写由B01拥有。""" + +from fastapi import APIRouter, Query, Request + +from muse.作品规划.接口 import 档案结构, 规划定位 +from muse.接入.http.作者会话 import 作者依赖 +from muse.接入.创作操作 import ( + 作品概要, + 作品表单结果, + 作品进度结果, + 创作身份, + 探索保存请求, + 探索检查结果, + 探索检查请求, + 探索稿概要, + 探索稿结果, + 探索选定状态, + 探索选定请求, + 新增章节请求, + 核对路径, + 档案保存请求, + 正式回执结果, + 目录结果, + 节点目录结果, + 节点目录请求, + 规划候选结果, + 规划候选请求, + 规划决定请求, + 规划审阅结果, + 规划结果, + 规划编辑请求, + 规划表单结果, + 重排章节请求, +) +from muse.正式变更.接口 import 作者动作 + +路由 = APIRouter(prefix="/api/v1", tags=["作品规划"]) + + +@路由.get( + "/works/{work_id}/plan-nodes", response_model=节点目录结果, operation_id="read_plan_nodes" +) +def 读取规划节点( + work_id: str, request: Request, 作者: 作者依赖, version: int | None = Query(default=None, ge=0) +): + app = request.app.state.装配 + return app.要求作品().读取节点目录(创作身份(作者, app.配置), work_id, 版本=version) + + +@路由.post( + "/works/{work_id}/plan-nodes", response_model=正式回执结果, operation_id="save_plan_nodes" +) +def 保存规划节点(work_id: str, 请求: 节点目录请求, request: Request, 作者: 作者依赖): + 核对路径(work_id, 请求.directory.work_id) + app = request.app.state.装配 + return app.要求作品().保存节点目录(创作身份(作者, app.配置), 请求.command_id, 请求.directory) + + +@路由.get( + "/works/{work_id}/progress", response_model=作品进度结果, operation_id="read_work_progress" +) +def 读取进度(work_id: str, request: Request, 作者: 作者依赖) -> dict: + 装配 = request.app.state.装配 + return 装配.要求作品进度().查询(创作身份(作者, 装配.配置), work_id) + + +@路由.get("/works", response_model=list[作品概要], operation_id="list_works") +def 列出作品(request: Request, 作者: 作者依赖) -> list[dict]: + 装配 = request.app.state.装配 + return 装配.要求作品().列出作品(创作身份(作者, 装配.配置)) + + +@路由.get("/works/{work_id}/form", response_model=作品表单结果, operation_id="read_work_form") +def 读取表单( + work_id: str, + request: Request, + 作者: 作者依赖, + schema_id: str | None = None, + base_version: int | None = Query(default=None, ge=1), +) -> dict: + 装配 = request.app.state.装配 + return 装配.要求作品().读取表单( + 创作身份(作者, 装配.配置), + work_id, + schema_id=schema_id, + base_version=base_version, + ) + + +@路由.post( + "/works/{work_id}/profile", response_model=正式回执结果, operation_id="save_work_profile" +) +def 保存档案(work_id: str, 请求: 档案保存请求, request: Request, 作者: 作者依赖) -> dict: + 核对路径(work_id, 请求.work_id) + 装配 = request.app.state.装配 + return 装配.要求作品().保存档案(创作身份(作者, 装配.配置), 请求.command_id, 请求.业务请求()) + + +@路由.get("/works/{work_id}/chapters", response_model=目录结果, operation_id="read_work_chapters") +def 读取目录(work_id: str, request: Request, 作者: 作者依赖) -> dict: + 装配 = request.app.state.装配 + return 装配.要求作品().读取目录(创作身份(作者, 装配.配置), work_id) + + +@路由.post("/works/{work_id}/chapters", response_model=正式回执结果, operation_id="append_chapter") +def 新增章节(work_id: str, 请求: 新增章节请求, request: Request, 作者: 作者依赖) -> dict: + 核对路径(work_id, 请求.work_id) + 装配 = request.app.state.装配 + return 装配.要求作品().添加章节( + 创作身份(作者, 装配.配置), + 请求.command_id, + work_id, + 请求.chapter_id, + 请求.title, + 预期目录版本=请求.expected_directory_revision, + ) + + +@路由.post( + "/works/{work_id}/chapters/reorder", + response_model=正式回执结果, + operation_id="reorder_chapters", +) +def 重排章节(work_id: str, 请求: 重排章节请求, request: Request, 作者: 作者依赖) -> dict: + 核对路径(work_id, 请求.work_id) + 装配 = request.app.state.装配 + return 装配.要求作品().重排章节( + 创作身份(作者, 装配.配置), + 请求.command_id, + work_id, + tuple(请求.chapter_ids), + 预期目录版本=请求.expected_directory_revision, + ) + + +@路由.get("/explorations", response_model=list[探索稿概要], operation_id="list_explorations") +def 列出探索(request: Request, 作者: 作者依赖): + 装配 = request.app.state.装配 + return 装配.要求探索().列出(创作身份(作者, 装配.配置)) + + +@路由.get("/explorations/{draft_id}", response_model=探索稿结果, operation_id="read_exploration") +def 读取探索(draft_id: str, request: Request, 作者: 作者依赖): + 装配 = request.app.state.装配 + return 装配.要求探索().读取(创作身份(作者, 装配.配置), draft_id) + + +@路由.post("/explorations/{draft_id}", response_model=探索稿结果, operation_id="save_exploration") +def 保存探索(draft_id: str, 请求: 探索保存请求, request: Request, 作者: 作者依赖): + 核对路径(draft_id, 请求.draft_id) + 装配 = request.app.state.装配 + return 装配.要求探索().保存( + 创作身份(作者, 装配.配置), draft_id, 请求.title, 请求.content, 请求.expected_hash + ) + + +@路由.post( + "/explorations/{draft_id}/check", + response_model=探索检查结果, + operation_id="check_exploration_handoff", +) +def 检查探索(draft_id: str, 请求: 探索检查请求, request: Request, 作者: 作者依赖): + 装配 = request.app.state.装配 + return 装配.要求探索().检查交付(创作身份(作者, 装配.配置), draft_id, 请求.expected_hash) + + +@路由.get( + "/works/{work_id}/exploration-selection", + response_model=探索选定状态, + operation_id="read_exploration_selection", +) +def 读取选定(work_id: str, request: Request, 作者: 作者依赖): + 装配 = request.app.state.装配 + return 装配.要求作品().读取探索选定(创作身份(作者, 装配.配置), work_id) + + +@路由.post( + "/works/{work_id}/exploration-selection", + response_model=正式回执结果, + operation_id="select_exploration", +) +def 选定探索(work_id: str, 请求: 探索选定请求, request: Request, 作者: 作者依赖): + 核对路径(work_id, 请求.selection.work_id) + 装配 = request.app.state.装配 + return 装配.要求作品().选定探索稿(创作身份(作者, 装配.配置), 请求.command_id, 请求.selection) + + +@路由.get("/works/{work_id}/plans/schema", response_model=档案结构, operation_id="read_plan_schema") +def 读取规划结构入口( + work_id: str, request: Request, 作者: 作者依赖, schema_id: str, base_version: int = Query(ge=1) +): + app = request.app.state.装配 + return app.要求规划().新规划结构(创作身份(作者, app.配置), work_id, schema_id, base_version) + + +@路由.get("/works/{work_id}/plans", response_model=list[规划结果], operation_id="list_plans") +def 列出规划入口(work_id: str, request: Request, 作者: 作者依赖): + app = request.app.state.装配 + return app.要求规划().列出规划(创作身份(作者, app.配置), work_id) + + +@路由.post( + "/works/{work_id}/plans/candidates", + response_model=正式回执结果, + operation_id="create_author_plan_candidate", +) +def 创建规划候选(work_id: str, 请求: 规划候选请求, request: Request, 作者: 作者依赖): + 核对路径(work_id, 请求.request.work_id) + app = request.app.state.装配 + return app.要求规划().保存人工候选(创作身份(作者, app.配置), 请求.command_id, 请求.request) + + +@路由.get( + "/plan-candidates/{candidate_id}", + response_model=规划候选结果, + operation_id="read_plan_candidate", +) +def 读取规划候选( + candidate_id: str, + request: Request, + 作者: 作者依赖, + version: int | None = Query(default=None, ge=1), +): + app = request.app.state.装配 + return app.要求规划().读取候选(创作身份(作者, app.配置), candidate_id, 版本=version) + + +@路由.post( + "/plan-candidates/{candidate_id}/versions", + response_model=正式回执结果, + operation_id="edit_plan_candidate", +) +def 编辑规划候选(candidate_id: str, 请求: 规划编辑请求, request: Request, 作者: 作者依赖): + 核对路径(candidate_id, 请求.candidate.candidate_id) + app = request.app.state.装配 + return app.要求规划().编辑候选( + 创作身份(作者, app.配置), 请求.command_id, 请求.candidate, tuple(请求.changes) + ) + + +@路由.get("/plans/{plan_id}", response_model=规划结果, operation_id="read_plan") +def 读取正式规划(plan_id: str, request: Request, 作者: 作者依赖): + app = request.app.state.装配 + return app.要求规划().读取规划(创作身份(作者, app.配置), plan_id) + + +@路由.post( + "/plan-candidates/{candidate_id}/reviews", + response_model=规划审阅结果, + operation_id="open_plan_review", +) +def 打开规划审阅(candidate_id: str, 请求: 规划定位, request: Request, 作者: 作者依赖): + 核对路径(candidate_id, 请求.candidate_id) + app = request.app.state.装配 + return app.要求规划().打开审阅(创作身份(作者, app.配置), 请求) + + +@路由.post( + "/plan-candidates/{candidate_id}/decisions", + response_model=正式回执结果, + operation_id="decide_plan_candidate", +) +def 决定规划候选(candidate_id: str, 请求: 规划决定请求, request: Request, 作者: 作者依赖): + 核对路径(candidate_id, 请求.candidate.candidate_id) + app = request.app.state.装配 + return app.要求规划().决定( + 创作身份(作者, app.配置), + 请求.command_id, + 请求.candidate, + 作者动作(请求.action), + 请求.author_review_id, + 请求.review_hash, + tuple(请求.approved_changes), + ) + + +@路由.get( + "/works/{work_id}/chapters/{chapter_id}/fine-outline", + response_model=规划结果, + operation_id="read_usable_fine_outline", +) +def 读取可用细纲入口(work_id: str, chapter_id: str, request: Request, 作者: 作者依赖): + app = request.app.state.装配 + return app.要求规划().读取可用细纲(创作身份(作者, app.配置), work_id, chapter_id) + + +@路由.get("/works/{work_id}/plans/form", response_model=规划表单结果, operation_id="read_plan_form") +def 读取规划表单( + work_id: str, + request: Request, + 作者: 作者依赖, + schema_id: str, + base_version: int = Query(ge=1), + chapter_id: str | None = None, + node_id: str | None = None, +): + app = request.app.state.装配 + return app.要求规划().读取表单( + 创作身份(作者, app.配置), + work_id, + schema_id, + base_version, + chapter_id=chapter_id, + node_id=node_id, + ) + + +@路由.get( + "/works/{work_id}/plans/candidates", + response_model=list[规划候选结果], + operation_id="list_plan_candidates", +) +def 列出规划候选(work_id: str, request: Request, 作者: 作者依赖): + app = request.app.state.装配 + return app.要求规划().列出候选(创作身份(作者, app.配置), work_id) diff --git a/src/muse/接入/http/路由/正文写作.py b/src/muse/接入/http/路由/正文写作.py new file mode 100644 index 0000000..0a84bd9 --- /dev/null +++ b/src/muse/接入/http/路由/正文写作.py @@ -0,0 +1,75 @@ +"""人工正文与历史HTTP入口,沿用B05版本服务与S01回执。""" + +from fastapi import APIRouter, Query, Request + +from muse.接入.http.作者会话 import 作者依赖 +from muse.接入.创作操作 import ( + 人工保存请求, + 创作身份, + 回退正文请求, + 核对路径, + 正式回执结果, + 正文历史结果, + 正文读取结果, +) + +路由 = APIRouter(prefix="/api/v1/chapters", tags=["正文写作"]) + + +@路由.get( + "/{chapter_id}/documents/history", + response_model=list[正文历史结果], + operation_id="list_document_history", +) +def 读取历史( + chapter_id: str, request: Request, 作者: 作者依赖, branch_id: str = "main" +) -> list[dict]: + 装配 = request.app.state.装配 + return 装配.要求正文().列出版本(创作身份(作者, 装配.配置), chapter_id, 分支=branch_id) + + +@路由.get("/{chapter_id}/documents", response_model=正文读取结果, operation_id="read_document") +def 读取正文( + chapter_id: str, + request: Request, + 作者: 作者依赖, + branch_id: str = "main", + revision: int | None = Query(default=None, ge=1), +) -> dict: + 装配 = request.app.state.装配 + return 装配.要求正文().读取正文( + 创作身份(作者, 装配.配置), + chapter_id, + 分支=branch_id, + 版本=revision, + ) + + +@路由.post("/{chapter_id}/documents", response_model=正式回执结果, operation_id="save_document") +def 保存正文(chapter_id: str, 请求: 人工保存请求, request: Request, 作者: 作者依赖) -> dict: + 核对路径(chapter_id, 请求.chapter_id) + 装配 = request.app.state.装配 + return 装配.要求正文().保存人工( + 创作身份(作者, 装配.配置), + 请求.command_id, + chapter_id, + 请求.expected_revision, + 请求.document, + 分支=请求.branch_id, + ) + + +@路由.post( + "/{chapter_id}/documents/restore", response_model=正式回执结果, operation_id="restore_document" +) +def 回退正文(chapter_id: str, 请求: 回退正文请求, request: Request, 作者: 作者依赖) -> dict: + 核对路径(chapter_id, 请求.chapter_id) + 装配 = request.app.state.装配 + return 装配.要求正文().回退正文( + 创作身份(作者, 装配.配置), + 请求.command_id, + chapter_id, + 请求.expected_revision, + 请求.restore_from, + 分支=请求.branch_id, + ) diff --git a/src/muse/接入/创作操作.py b/src/muse/接入/创作操作.py new file mode 100644 index 0000000..fb5deec --- /dev/null +++ b/src/muse/接入/创作操作.py @@ -0,0 +1,478 @@ +"""人工创作命令的共同外壳;作者身份从接入取得,不接受客户端自报。""" + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from muse.作品规划.接口 import ( + 作品错误, + 探索选择, + 档案保存, + 档案结构, + 节点目录保存, + 规划保存, + 规划定位, +) +from muse.元数据.接口 import 字段变更 +from muse.共享.调用身份 import 内容用途, 调用身份 +from muse.故事世界.接口 import 事实定位, 事实提案请求, 事实结构, 知情记录 +from muse.正文写作.接口 import 候选定位, 正文草稿, 段落 + + +class 创作命令(BaseModel): + model_config = ConfigDict(extra="forbid") + command_id: str = Field(min_length=1) + + +class 档案保存请求(创作命令): + work_id: str = Field(min_length=1) + expected_revision: int = Field(ge=0) + content: dict = Field(default_factory=dict) + schema_binding: 档案结构 + changes: list[字段变更] = Field(default_factory=list) + + def 业务请求(self) -> 档案保存: + return 档案保存( + self.work_id, + self.expected_revision, + self.content, + self.schema_binding, + tuple(self.changes), + ) + + +class 新增章节请求(创作命令): + work_id: str = Field(min_length=1) + chapter_id: str = Field(min_length=1) + title: str + expected_directory_revision: int = Field(ge=0) + + +class 重排章节请求(创作命令): + work_id: str = Field(min_length=1) + chapter_ids: list[str] + expected_directory_revision: int = Field(ge=0) + + +class 人工保存请求(创作命令): + chapter_id: str = Field(min_length=1) + branch_id: str = "main" + expected_revision: int = Field(ge=0) + document: 正文草稿 + + +class 回退正文请求(创作命令): + chapter_id: str = Field(min_length=1) + branch_id: str = "main" + expected_revision: int = Field(ge=0) + restore_from: int = Field(ge=1) + + +class 候选修改请求(创作命令): + candidate_id: str = Field(min_length=1) + expected_candidate_revision: int = Field(ge=1) + expected_candidate_hash: str = Field(min_length=1) + document: 正文草稿 | None = None + selected_changes: list[str] = Field(default_factory=list) + + +class 候选审阅请求(BaseModel): + model_config = ConfigDict(extra="forbid") + candidate_id: str = Field(min_length=1) + candidate_revision: int = Field(ge=1) + candidate_hash: str = Field(min_length=1) + target_ref: str = Field(min_length=1) + expected_revision: int = Field(ge=0) + + def 定位(self) -> 候选定位: + return 候选定位( + self.candidate_id, + self.candidate_revision, + self.candidate_hash, + self.target_ref, + self.expected_revision, + ) + + +class 候选决定请求(候选审阅请求, 创作命令): + action: Literal["adopt", "reject", "defer"] + author_review_id: str = Field(min_length=1) + review_hash: str = Field(min_length=1) + approved_changes: list[str] = Field(default_factory=list) + + +class 候选差异结果(BaseModel): + change_id: str + start: int + end: int + before: list[段落] + after: list[段落] + before_text: str + after_text: str + + +class 候选检查结果(BaseModel): + format: str + format_version: int + quality: str + + +class 正文候选结果(BaseModel): + candidate_id: str + revision: int + candidate_hash: str + document_id: str + chapter_id: str + branch_id: str + origin: str + base_revision: int + current_document_revision: int + document: 正文草稿 + visible_text: str + checks: 候选检查结果 + decision: Literal["undecided", "adopt", "reject", "defer"] + stale: bool + changes: list[候选差异结果] + + +class 作品表单结果(BaseModel): + work_id: str + revision: int + content: dict[str, Any] + schema_binding: 档案结构 + visible_schema: dict[str, Any] + editable_schema: dict[str, Any] + visible_projection_version: str + + +class 作品概要(BaseModel): + work_id: str + revision: int + content: dict[str, Any] + + +class 正式回执结果(BaseModel): + receipt_id: str + command_id: str + target_ref: str + action: str + author_review_id: str | None + basis: dict[str, Any] + results: list[dict[str, Any]] + impact_ids: list[str] + + +class 章节条目(BaseModel): + chapter_id: str + title: str + position: int + + +class 目录结果(BaseModel): + work_id: str + revision: int + chapters: list[章节条目] + + +class 节点目录请求(创作命令): + directory: 节点目录保存 + + +class 节点条目(BaseModel): + node_id: str + kind: Literal["volume", "chapter", "scene"] + title: str + parent_id: str | None + position: int + + +class 节点目录结果(BaseModel): + work_id: str + revision: int + nodes: list[节点条目] + + +class 正文读取结果(BaseModel): + work_id: str + document_id: str + chapter_id: str + branch_id: str + revision: int + state: Literal["saved", "unsaved"] + document: 正文草稿 + visible_text: str + document_hash: str + visible_text_hash: str + + +class 正文历史结果(BaseModel): + revision: int + document_hash: str + visible_text_hash: str + restored_from: int | None + created_at: datetime + + +class 候选审阅结果(BaseModel): + review_id: str + review_hash: str + snapshot: dict[str, Any] + candidate: 正文候选结果 + before: 正文读取结果 + + +class 候选进度结果(BaseModel): + candidate_id: str + revision: int + decision: str + stale: bool + + +class 章节进度结果(BaseModel): + chapter_id: str + title: str + position: int + document_revision: int + document_hash: str + visible_characters: int + candidates: list[候选进度结果] + + +class 任务进度结果(BaseModel): + task_id: str + state: str + event_cursor: int + + +class 作品进度结果(BaseModel): + work_id: str + directory_revision: int + total_chapters: int + saved_chapters: int + undecided_candidates: int + chapters: list[章节进度结果] + tasks: list[任务进度结果] + + +def 创作身份(作者: str, 配置) -> 调用身份: + # 人工命令不是S02后台任务,不编造数据库中不存在的task_id。 + return 调用身份(作者, None, 配置.运行用途, 内容用途.规划) + + +def 核对路径(路径身份: str, 请求身份: str) -> None: + if 路径身份 != 请求身份: + raise 作品错误("SCOPE_DENIED", "请求目标与访问路径不一致") + + +class 事实创建请求(创作命令): + proposal: 事实提案请求 + + +class 事实审阅请求(BaseModel): + model_config = ConfigDict(extra="forbid") + proposal_id: str + proposal_revision: int = Field(ge=1) + proposal_hash: str + target_ref: str + expected_revision: int = Field(ge=0) + + def 定位(self) -> 事实定位: + return 事实定位( + self.proposal_id, + self.proposal_revision, + self.proposal_hash, + self.target_ref, + self.expected_revision, + ) + + +class 事实决定请求(事实审阅请求, 创作命令): + action: Literal["adopt", "reject", "defer"] + author_review_id: str + review_hash: str + approved_changes: list[str] = Field(default_factory=list) + + +class 事实来源版本(BaseModel): + chapter_id: str + branch_id: str + revision: int + document_hash: str + + +class 世界事实结果(BaseModel): + object_id: str + type_id: str + revision: int + system_revision: int + content: dict[str, Any] + effective_from: str | None + knowledge_mode: str + independent: bool + content_hash: str + projection_version: str + schema_binding: dict[str, Any] + sources: list[事实来源版本] + planned_window: dict[str, str] | None = None + + +class 事实省略原因(BaseModel): + object_id: str + reason: str + + +class 世界查询结果(BaseModel): + work_id: str + directory_revision: int + system_revision: int + facts: list[世界事实结果] + beliefs: list[世界事实结果] + plans: list[世界事实结果] + omitted: list[事实省略原因] + + +class 事实提案结果(BaseModel): + proposal_id: str + revision: int + proposal_hash: str + work_id: str + object_id: str + expected_revision: int + schema_binding: 事实结构 = Field(alias="schema") + content: dict[str, Any] + independent: bool + sources: list[dict[str, Any]] + effective_from: str | None + knowledge_mode: str + known_to: list[知情记录] + planned_window: dict[str, str] | None + decision: str + + +class 事实审阅结果(BaseModel): + review_id: str + review_hash: str + snapshot: dict[str, Any] + proposal: 事实提案结果 + + +class 探索保存请求(BaseModel): + model_config = ConfigDict(extra="forbid") + draft_id: str + title: str + content: str + expected_hash: str | None + + +class 探索检查请求(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_hash: str + + +class 探索稿概要(BaseModel): + draft_id: str + title: str + revision: int + draft_hash: str + + +class 探索稿结果(探索稿概要): + content: str + + +class 交付格式问题(BaseModel): + code: str + message: str + + +class 探索检查结果(BaseModel): + draft_id: str + draft_hash: str + valid: bool + problems: list[交付格式问题] + setting_count: int + contract_version: str + + +class 探索选定请求(创作命令): + selection: 探索选择 + + +class 已选探索稿(BaseModel): + work_id: str + revision: int + snapshot: 探索稿结果 + selected_by: str + selected_at: str + + +class 探索选定状态(BaseModel): + work_id: str + revision: int + selection: 已选探索稿 | None + + +class 规划候选请求(创作命令): + request: 规划保存 + + +class 规划编辑请求(创作命令): + candidate: 规划定位 + changes: list[字段变更] + + +class 规划决定请求(创作命令): + candidate: 规划定位 + action: Literal["adopt", "reject", "defer"] + author_review_id: str + review_hash: str + approved_changes: list[str] + + +class 规划候选结果(BaseModel): + candidate_id: str + plan_id: str + revision: int + candidate_hash: str + work_id: str + chapter_id: str | None + node_id: str | None = None + expected_revision: int + schema_binding: 档案结构 + content: dict[str, Any] + decision: str + visible_schema: dict[str, Any] + editable_schema: dict[str, Any] + + +class 规划结果(BaseModel): + plan_id: str + work_id: str + chapter_id: str | None + node_id: str | None = None + type_id: str + revision: int + content: dict[str, Any] + schema_binding: dict[str, Any] + source_dependencies: list[dict[str, str]] + + +class 规划审阅结果(BaseModel): + review_id: str + review_hash: str + snapshot: dict[str, Any] + candidate: 规划候选结果 + before: 规划结果 | None + + +class 规划表单结果(BaseModel): + work_id: str + chapter_id: str | None + node_id: str | None = None + plan_id: str + revision: int + schema_binding: 档案结构 + content: dict[str, Any] + visible_schema: dict[str, Any] + editable_schema: dict[str, Any] diff --git a/src/muse/正文写作/__init__.py b/src/muse/正文写作/__init__.py new file mode 100644 index 0000000..645a924 --- /dev/null +++ b/src/muse/正文写作/__init__.py @@ -0,0 +1 @@ +"""正文工作版本与作者保存;公开接口见接口.py。""" diff --git a/src/muse/正文写作/人工保存.py b/src/muse/正文写作/人工保存.py new file mode 100644 index 0000000..aa8f473 --- /dev/null +++ b/src/muse/正文写作/人工保存.py @@ -0,0 +1,82 @@ +"""人工写作与回退由作者发起,不取决于模型评分或候选状态。""" + +from dataclasses import dataclass + +from muse.作品规划.接口 import 读取章节归属 +from muse.正式变更.接口 import 依赖引用, 修改影响, 参与操作, 变更错误, 固定哈希, 审阅内容, 提交计划 +from muse.正文写作.存储 import 正文存储 +from muse.正文写作.模型 import 人工正文保存, 正文草稿 +from muse.正文写作.正文格式 import 文稿身份 + + +@dataclass(frozen=True) +class 正文写入: + 作者: str + 请求: 人工正文保存 + 草稿: 正文草稿 + + +def 解析新稿(存储: 正文存储, 请求: 人工正文保存) -> 正文草稿: + if 请求.document is not None: + return 请求.document + 行 = 存储.读取(文稿身份(请求.chapter_id, 请求.branch_id), 请求.restore_from) + if 行 is None: + raise 变更错误("REVISION_CONFLICT", "指定历史正文版本不存在") + return 存储.恢复草稿(行) + + +class 正文保存入口: + 身份 = "B05.save_manual" + 请求类型 = 人工正文保存 + + def 查看(self, 连, 身份, 命令): + 请求 = 命令.业务请求 + 章节 = 读取章节归属(连, 身份.作者, 请求.chapter_id) + id_ = 文稿身份(请求.chapter_id, 请求.branch_id) + if id_ != 命令.目标: + raise 变更错误("SCOPE_DENIED", "文稿目标与章节分支不一致") + 行 = 正文存储(连).读取(id_) + 版本 = 行["revision"] if 行 else 0 + return 审阅内容( + id_, + 版本, + None, + None, + None, + 固定哈希([行["document_hash"] if 行 else None, 固定哈希(请求)]), + None, + None, + 固定哈希(章节), + ("document",), + ( + 依赖引用( + "B01.directory", "directory:" + 章节["work_id"], str(章节["directory_revision"]) + ), + 依赖引用("B05.document", id_, str(版本)), + ), + ) + + def 准备(self, 连, 身份, 命令, 当前): + 请求 = 命令.业务请求 + 草稿 = 解析新稿(正文存储(连), 请求) + return 提交计划( + (参与操作("B05.write_document", 正文写入(身份.作者, 请求, 草稿)),), + (修改影响(命令.目标, "document_changed", str(当前.数据版本), str(当前.数据版本 + 1)),), + ) + + +class 正文参与者: + 身份 = "B05.write_document" + 请求类型 = 正文写入 + + def 提交(self, 连, 操作): + # 作者范围复检仍走B01的同事务公开接口。 + 读取章节归属(连, 操作.作者, 操作.请求.chapter_id) + return 正文存储(连).写入(操作.作者, 操作.请求, 操作.草稿) + + +class 文稿依赖: + 种类 = "B05.document" + + def 锁定当前(self, 连, 对象ID): + return str(正文存储(连).锁文稿(对象ID)) diff --git a/src/muse/正文写作/候选版本.py b/src/muse/正文写作/候选版本.py new file mode 100644 index 0000000..4fc6a91 --- /dev/null +++ b/src/muse/正文写作/候选版本.py @@ -0,0 +1,174 @@ +"""固定正文基线上的候选版本与段落差异;作者修改仍先进入候选。""" + +from dataclasses import asdict, dataclass +from difflib import SequenceMatcher + +from muse.作品规划.接口 import 读取章节归属 +from muse.正式变更.接口 import ( + 依赖引用, + 参与操作, + 变更错误, + 固定哈希, + 审阅内容, + 提交计划, + 读取作者决定, +) +from muse.正文写作.存储 import 正文存储 +from muse.正文写作.模型 import 人工候选保存, 正文草稿, 段落 +from muse.正文写作.正文格式 import 可见文本, 文稿身份, 正文结构哈希 + + +def 段落差异(基线: 正文草稿, 候选: 正文草稿) -> list[dict]: + 前, 后 = 基线.paragraphs, 候选.paragraphs + 比较 = SequenceMatcher(a=[固定哈希(p) for p in 前], b=[固定哈希(p) for p in 后], autojunk=False) + return [ + { + "change_id": 固定哈希( + [i, j, x, y, [asdict(p) for p in 前[i:j]], [asdict(p) for p in 后[x:y]]] + ), + "start": i, + "end": j, + "before": [asdict(p) for p in 前[i:j]], + "after": [asdict(p) for p in 后[x:y]], + "before_text": 可见文本(正文草稿(前[i:j])) if j > i else "", + "after_text": 可见文本(正文草稿(后[x:y])) if y > x else "", + } + for op, i, j, x, y in 比较.get_opcodes() + if op != "equal" + ] + + +def 选择差异(基线: 正文草稿, 候选: 正文草稿, 已选: tuple[str, ...]) -> 正文草稿: + from pydantic import TypeAdapter + + 差异 = 段落差异(基线, 候选) + if not 已选 or set(已选) - {v["change_id"] for v in 差异}: + raise 变更错误("REVIEW_MISMATCH", "所选差异不属于当前候选版本") + 段落集 = list(基线.paragraphs) + for 项 in reversed(差异): + if 项["change_id"] in 已选: + 段落集[项["start"] : 项["end"]] = TypeAdapter(list[段落]).validate_python(项["after"]) + # 空文稿仍有一个空段落;删除全部内容不会留下无效的零段落文档。 + return 正文草稿(tuple(段落集) or (段落(基线.paragraphs[0].paragraph_id, ()),)) + + +def 核对候选归属(连, 身份, candidate_id: str, 版本: int | None = None) -> dict: + if not 身份.允许写正式内容: + raise 变更错误("SCOPE_DENIED", "候选入口需要已认证的生产作者") + 行 = 正文存储(连).读取候选(candidate_id, 版本) + if 行 is None: + raise 变更错误("CANDIDATE_NOT_FOUND", "候选或指定版本不存在") + 读取章节归属(连, 身份.作者, 行["chapter_id"]) + return 行 + + +def 呈现候选(连, 身份, candidate_id: str, 版本: int | None = None) -> dict: + 行 = 核对候选归属(连, 身份, candidate_id, 版本) + 存储 = 正文存储(连) + 文稿ID = 文稿身份(行["chapter_id"], 行["branch_id"]) + 基线 = 存储.读取草稿(文稿ID, 行["base_revision"]) + 草稿 = 存储.恢复草稿(行) + 当前 = 存储.读取(文稿ID) + return { + "candidate_id": str(行["candidate_id"]), + "revision": 行["revision"], + "candidate_hash": 行["candidate_hash"], + "document_id": 文稿ID, + "chapter_id": 行["chapter_id"], + "branch_id": 行["branch_id"], + "origin": 行["origin"], + "base_revision": 行["base_revision"], + "current_document_revision": 当前["revision"] if 当前 else 0, + "document": asdict(草稿), + "visible_text": 可见文本(草稿), + "checks": 行["checks"], + "decision": 读取作者决定(连, candidate_id, 行["revision"]), + "stale": (当前["revision"] if 当前 else 0) != 行["base_revision"], + "changes": 段落差异(基线, 草稿), + } + + +@dataclass(frozen=True) +class 候选写入: + 作者: str + 请求: 人工候选保存 + 草稿: 正文草稿 + 基线哈希: str + + +class 候选保存入口: + 身份 = "B05.save_candidate" + 请求类型 = 人工候选保存 + + def 查看(self, 连, 身份, 命令): + 请求 = 命令.业务请求 + 章节 = 读取章节归属(连, 身份.作者, 请求.chapter_id) + 文稿ID = 文稿身份(请求.chapter_id, 请求.branch_id) + 存储 = 正文存储(连) + 行 = 存储.读取候选(请求.candidate_id) + 版 = 行["revision"] if 行 else 0 + if 行: + 核对候选归属(连, 身份, 请求.candidate_id) + if (行["chapter_id"], 行["branch_id"], 行["base_revision"]) != ( + 请求.chapter_id, + 请求.branch_id, + 请求.base_revision, + ): + raise 变更错误("SCOPE_DENIED", "改稿不能改变候选所属文稿或原基线") + if 请求.expected_candidate_hash != 行["candidate_hash"]: + raise 变更错误("REVISION_CONFLICT", "待编辑候选版本已经改变") + if 读取作者决定(连, 请求.candidate_id, 版) not in {"undecided", "defer"}: + raise 变更错误("REVISION_CONFLICT", "已决定候选保留历史,请从当前正文另建改稿") + 正文 = 存储.读取(文稿ID) + 文稿版 = 正文["revision"] if 正文 else 0 + if 请求.base_revision != 文稿版: + raise 变更错误("SOURCE_STALE", "候选基线正文已经改变,请比较当前正文后另建候选") + return 审阅内容( + 请求.candidate_id, + 版, + None, + None, + None, + 固定哈希(请求), + None, + None, + 固定哈希(章节), + ("candidate",), + ( + 依赖引用( + "B01.directory", "directory:" + 章节["work_id"], str(章节["directory_revision"]) + ), + 依赖引用("B05.document", 文稿ID, str(文稿版)), + 依赖引用("B05.candidate", 请求.candidate_id, str(版)), + ), + ) + + def 准备(self, 连, 身份, 命令, 当前): + 请求 = 命令.业务请求 + 存储 = 正文存储(连) + 基线 = 存储.读取草稿(文稿身份(请求.chapter_id, 请求.branch_id), 请求.base_revision) + 草稿 = 请求.document + if 草稿 is None: + 行 = 存储.读取候选(请求.candidate_id) + if 行 is None: + raise 变更错误("CANDIDATE_NOT_FOUND", "部分选择需要已有候选") + 草稿 = 选择差异(基线, 存储.恢复草稿(行), 请求.selected_changes) + return 提交计划( + (参与操作("B05.write_candidate", 候选写入(身份.作者, 请求, 草稿, 正文结构哈希(基线))),) + ) + + +class 候选参与者: + 身份 = "B05.write_candidate" + 请求类型 = 候选写入 + + def 提交(self, 连, 操作): + 读取章节归属(连, 操作.作者, 操作.请求.chapter_id) + return 正文存储(连).写入候选(操作.作者, 操作.请求, 操作.草稿, 操作.基线哈希) + + +class 候选依赖: + 种类 = "B05.candidate" + + def 锁定当前(self, 连, 对象ID): + return str(正文存储(连).锁候选(对象ID)) diff --git a/src/muse/正文写作/存储.py b/src/muse/正文写作/存储.py new file mode 100644 index 0000000..0c84dd0 --- /dev/null +++ b/src/muse/正文写作/存储.py @@ -0,0 +1,191 @@ +"""B05正文与历史版本SQL;不读取作品、事实或S01私表。""" + +import hashlib +from dataclasses import asdict + +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb +from pydantic import TypeAdapter + +from muse.正文写作.模型 import 人工候选保存, 人工正文保存, 正文草稿, 正文错误, 段落 +from muse.正文写作.正文格式 import 可见文本哈希, 文稿身份, 正文结构哈希 + + +class 正文存储: + def __init__(self, 连): + self.连 = 连 + + def 列出版本(self, document_id: str) -> list[dict]: + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT revision,document_hash,visible_text_hash,restored_from,created_at " + "FROM muse_document_version WHERE document_id=%s ORDER BY revision DESC", + (document_id,), + ) + .fetchall() + ) + + def 读取(self, document_id: str, 版本: int | None = None) -> dict | None: + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT d.document_id,d.chapter_id,d.branch_id,v.revision,v.document," + "v.document_hash,v.visible_text_hash,v.restored_from " + "FROM muse_document d JOIN muse_document_version v ON v.document_id=d.document_id " + "AND v.revision=COALESCE(%s,d.current_revision) WHERE d.document_id=%s", + (版本, document_id), + ) + .fetchone() + ) + + def 读取草稿(self, document_id: str, 版本: int) -> 正文草稿: + if 版本 == 0: + return 正文草稿((段落(document_id + ":p0", ()),)) + 行 = self.读取(document_id, 版本) + if 行 is None: + raise 正文错误("指定正文版本不存在") + return self.恢复草稿(行) + + def 读取候选(self, candidate_id: str, 版本: int | None = None) -> dict | None: + return ( + self.连.cursor(row_factory=dict_row) + .execute( + "SELECT c.candidate_id,c.chapter_id,c.branch_id,c.origin,c.current_revision,v.* " + "FROM muse_writing_candidate c JOIN muse_writing_candidate_version v " + "ON v.candidate_id=c.candidate_id AND v.revision=COALESCE(%s,c.current_revision) " + "WHERE c.candidate_id=%s", + (版本, candidate_id), + ) + .fetchone() + ) + + def 列出候选ID(self, chapter_id: str, branch_id: str) -> list[str]: + return [ + str(r[0]) + for r in self.连.execute( + "SELECT candidate_id FROM muse_writing_candidate " + "WHERE chapter_id=%s AND branch_id=%s " + "ORDER BY candidate_id", + (chapter_id, branch_id), + ).fetchall() + ] + + def 锁候选(self, candidate_id: str) -> int: + key = int.from_bytes( + hashlib.sha256(("B05:candidate:" + candidate_id).encode()).digest()[:8], signed=True + ) + self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,)) + 行 = self.连.execute( + "SELECT current_revision FROM muse_writing_candidate WHERE candidate_id=%s FOR UPDATE", + (candidate_id,), + ).fetchone() + return 行[0] if 行 else 0 + + def 写入候选(self, 作者: str, 请求: 人工候选保存, 草稿: 正文草稿, 基线哈希: str) -> dict: + from muse.正式变更.接口 import 固定哈希 + + 版 = 请求.expected_candidate_revision + 1 + if 请求.expected_candidate_revision == 0: + self.连.execute( + "INSERT INTO muse_writing_candidate" + "(candidate_id,chapter_id,branch_id,origin,current_revision) " + "VALUES (%s,%s,%s,'author',%s)", + (请求.candidate_id, 请求.chapter_id, 请求.branch_id, 版), + ) + else: + 行 = self.连.execute( + "UPDATE muse_writing_candidate SET current_revision=%s WHERE candidate_id=%s " + "AND current_revision=%s RETURNING candidate_id", + (版, 请求.candidate_id, 请求.expected_candidate_revision), + ).fetchone() + if 行 is None: + raise 正文错误("候选已由其他请求修改") + 文稿哈希 = 正文结构哈希(草稿) + 检查 = {"format": "passed", "format_version": 1, "quality": "not_applicable"} + 哈希 = 固定哈希( + [ + 请求.candidate_id, + 版, + 请求.chapter_id, + 请求.branch_id, + 请求.base_revision, + 基线哈希, + 文稿哈希, + 检查, + ] + ) + self.连.execute( + "INSERT INTO muse_writing_candidate_version" + "(candidate_id,revision,base_revision,base_hash," + "document,document_hash,visible_text_hash,candidate_hash,checks,author_id) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + 请求.candidate_id, + 版, + 请求.base_revision, + 基线哈希, + Jsonb(asdict(草稿)), + 文稿哈希, + 可见文本哈希(草稿), + 哈希, + Jsonb(检查), + 作者, + ), + ) + return {"candidate_id": 请求.candidate_id, "revision": 版, "candidate_hash": 哈希} + + def 锁文稿(self, document_id: str) -> int: + key = int.from_bytes( + hashlib.sha256(("B05:document:" + document_id).encode()).digest()[:8], signed=True + ) + self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,)) + 行 = self.连.execute( + "SELECT current_revision FROM muse_document WHERE document_id=%s FOR UPDATE", + (document_id,), + ).fetchone() + return 行[0] if 行 else 0 + + @staticmethod + def 恢复草稿(行: dict) -> 正文草稿: + 草稿 = TypeAdapter(正文草稿).validate_python(行["document"]) + if ( + 正文结构哈希(草稿) != 行["document_hash"] + or 可见文本哈希(草稿) != 行["visible_text_hash"] + ): + raise 正文错误("已保存正文与其结构或文本哈希不一致") + return 草稿 + + def 写入(self, 作者: str, 请求: 人工正文保存, 草稿: 正文草稿) -> dict: + id_ = 文稿身份(请求.chapter_id, 请求.branch_id) + 版本 = 请求.expected_revision + 1 + if 请求.expected_revision == 0: + self.连.execute( + "INSERT INTO muse_document(document_id,chapter_id,branch_id,current_revision) " + "VALUES (%s,%s,%s,%s)", + (id_, 请求.chapter_id, 请求.branch_id, 版本), + ) + else: + 行 = self.连.execute( + "UPDATE muse_document SET current_revision=%s WHERE document_id=%s " + "AND current_revision=%s RETURNING document_id", + (版本, id_, 请求.expected_revision), + ).fetchone() + if 行 is None: + raise 正文错误("正文基线已改变") + 结构哈希 = 正文结构哈希(草稿) + 文本哈希 = 可见文本哈希(草稿) + self.连.execute( + "INSERT INTO muse_document_version(document_id,revision,document,document_hash," + "visible_text_hash,author_id,restored_from) VALUES (%s,%s,%s,%s,%s,%s,%s)", + (id_, 版本, Jsonb(asdict(草稿)), 结构哈希, 文本哈希, 作者, 请求.restore_from), + ) + return { + "document_id": id_, + "chapter_id": 请求.chapter_id, + "branch_id": 请求.branch_id, + "revision": 版本, + "document_hash": 结构哈希, + "visible_text_hash": 文本哈希, + "restored_from": 请求.restore_from, + } diff --git a/src/muse/正文写作/接口.py b/src/muse/正文写作/接口.py new file mode 100644 index 0000000..9ba8bfe --- /dev/null +++ b/src/muse/正文写作/接口.py @@ -0,0 +1,261 @@ +"""正文写作公开合同;正式版本写入由本模块参与S01事务。""" + +from dataclasses import asdict +from uuid import NAMESPACE_URL, uuid5 + +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 可见文本, 可见文本哈希, 文稿身份, 正文结构哈希 + + +def 登记正文参与者(目录: 参与者目录) -> None: + 目录.登记入口(正文保存入口()) + 目录.登记参与者(正文参与者()) + 目录.登记依赖(文稿依赖()) + 目录.登记入口(候选保存入口()) + 目录.登记入口(正文候选决定入口()) + 目录.登记参与者(候选参与者()) + 目录.登记依赖(候选依赖()) + + +def 读取正文依据(连, 作者: str, chapter_id: str, revision: int, *, 分支: str = "main") -> dict: + """事实及其他同事务消费者读取确切正文;同时返回当前指针供来源复检。""" + 章节 = 读取章节归属(连, 作者, chapter_id) + id_ = 文稿身份(chapter_id, 分支) + 存储 = 正文存储(连) + 行 = 存储.读取(id_, revision) + 当前 = 存储.读取(id_) + if 行 is None or 当前 is None: + raise 正文错误("事实依据引用的正文版本不存在") + return { + "work_id": 章节["work_id"], + "document_id": id_, + "chapter_id": chapter_id, + "revision": revision, + "current_revision": 当前["revision"], + "document_hash": 行["document_hash"], + "document": 存储.恢复草稿(行), + } + + +class 正文服务: + def __init__(self, 数据库: 数据库工厂, 正式: 正式变更服务): + self.数据库, self.正式 = 数据库, 正式 + + def 创建人工候选( + self, + 身份, + 命令ID: str, + chapter_id: str, + 预期版本: int, + 草稿: 正文草稿, + *, + 分支: str = "main", + ) -> dict: + from muse.正式变更.接口 import 固定哈希 + + id_ = str(uuid5(NAMESPACE_URL, "muse:B05:candidate:" + 固定哈希([身份.作者, 命令ID]))) + return self._保存候选( + 身份, 命令ID, 人工候选保存(id_, chapter_id, 分支, 预期版本, 0, document=草稿) + ) + + def 编辑候选( + self, + 身份, + 命令ID: str, + candidate_id: str, + 候选版本: int, + 候选哈希: str, + 草稿: 正文草稿 | None = None, + *, + 选择变更: tuple[str, ...] = (), + ) -> dict: + with self.数据库.连接(只读=True) as 连: + 行 = 核对候选归属(连, 身份, candidate_id) + return self._保存候选( + 身份, + 命令ID, + 人工候选保存( + candidate_id, + 行["chapter_id"], + 行["branch_id"], + 行["base_revision"], + 候选版本, + 候选哈希, + 草稿, + 选择变更, + ), + ) + + def _保存候选(self, 身份, 命令ID: str, 请求: 人工候选保存) -> dict: + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B05.save_candidate", + 请求.candidate_id, + 作者动作.人工保存, + 请求.expected_candidate_revision, + 请求, + ), + ) + + def 读取候选(self, 身份, candidate_id: str, *, 版本: int | None = None) -> dict: + with self.数据库.连接(只读=True) as 连: + return 呈现候选(连, 身份, candidate_id, 版本) + + def 列出候选(self, 身份, chapter_id: str, *, 分支: str = "main") -> list[dict]: + if not 身份.允许写正式内容: + raise 变更错误("SCOPE_DENIED", "候选入口需要已认证作者") + with self.数据库.连接(只读=True) as 连: + 读取章节归属(连, 身份.作者, chapter_id) + return [呈现候选(连, 身份, id_) for id_ in 正文存储(连).列出候选ID(chapter_id, 分支)] + + def 打开候选审阅(self, 身份, 定位: 候选定位) -> dict: + 审阅 = self.正式.打开审阅( + 身份, + 变更命令( + "review", + "B05.decide_candidate", + 定位.target_ref, + 作者动作.采纳, + 定位.expected_revision, + 定位, + ), + ) + 候选 = self.读取候选(身份, 定位.candidate_id, 版本=定位.candidate_revision) + return { + **审阅, + "candidate": 候选, + "before": self.读取正文( + 身份, 候选["chapter_id"], 分支=候选["branch_id"], 版本=定位.expected_revision + ), + } + + def 决定候选( + self, + 身份, + 命令ID: str, + 定位: 候选定位, + 动作: 作者动作, + 审阅ID: str, + 审阅哈希: str, + 批准变更: tuple[str, ...], + ) -> dict: + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B05.decide_candidate", + 定位.target_ref, + 动作, + 定位.expected_revision, + 定位, + 审阅ID, + 审阅哈希, + 批准变更, + ), + ) + + def 列出版本(self, 身份, chapter_id: str, *, 分支: str = "main") -> list[dict]: + if not 身份.允许写正式内容: + raise 变更错误("SCOPE_DENIED", "作者正文入口需要已认证作者") + with self.数据库.连接(只读=True) as 连: + 读取章节归属(连, 身份.作者, chapter_id) + return 正文存储(连).列出版本(文稿身份(chapter_id, 分支)) + + def 保存人工( + self, + 身份, + 命令ID: str, + chapter_id: str, + 预期版本: int, + 草稿: 正文草稿, + *, + 分支: str = "main", + ) -> dict: + return self._提交(身份, 命令ID, 人工正文保存(chapter_id, 分支, 预期版本, document=草稿)) + + def 回退正文( + self, 身份, 命令ID: str, chapter_id: str, 预期版本: int, 旧版本: int, *, 分支: str = "main" + ) -> dict: + return self._提交( + 身份, 命令ID, 人工正文保存(chapter_id, 分支, 预期版本, restore_from=旧版本) + ) + + def _提交(self, 身份, 命令ID: str, 请求: 人工正文保存) -> dict: + return self.正式.提交变更( + 身份, + 变更命令( + 命令ID, + "B05.save_manual", + 文稿身份(请求.chapter_id, 请求.branch_id), + 作者动作.人工保存, + 请求.expected_revision, + 请求, + ), + ) + + def 读取正文( + self, 身份, chapter_id: str, *, 分支: str = "main", 版本: int | None = None + ) -> dict: + if not 身份.允许写正式内容: + raise 变更错误("SCOPE_DENIED", "作者正文入口需要已认证作者") + with self.数据库.连接(只读=True) as 连: + 章节 = 读取章节归属(连, 身份.作者, chapter_id) + id_ = 文稿身份(chapter_id, 分支) + 行 = 正文存储(连).读取(id_, 版本) + if 行 is None: + if 版本 not in {None, 0}: + raise 变更错误("REVISION_CONFLICT", "指定正文版本不存在") + 草稿 = 正文草稿((段落(id_ + ":p0", ()),)) + 版 = 0 + 状态 = "unsaved" + else: + 草稿 = 正文存储.恢复草稿(行) + 版 = 行["revision"] + 状态 = "saved" + return { + "work_id": 章节["work_id"], + "document_id": id_, + "chapter_id": chapter_id, + "branch_id": 分支, + "revision": 版, + "state": 状态, + "document": asdict(草稿), + "visible_text": 可见文本(草稿), + "document_hash": 正文结构哈希(草稿), + "visible_text_hash": 可见文本哈希(草稿), + } + + +__all__ = [ + "文本节点", + "换行节点", + "段落", + "正文草稿", + "正文错误", + "可见文本", + "可见文本哈希", + "正文结构哈希", + "正文服务", + "登记正文参与者", + "人工正文保存", + "候选定位", + "读取正文依据", +] diff --git a/src/muse/正文写作/模型.py b/src/muse/正文写作/模型.py new file mode 100644 index 0000000..f736197 --- /dev/null +++ b/src/muse/正文写作/模型.py @@ -0,0 +1,124 @@ +"""正文协议保护身份与可见文本;题材和故事内容不进入固定代码字段。""" + +from typing import Literal + +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass + +from muse.共享.错误 import Muse错误 + + +class 正文错误(Muse错误): + 错误码 = "DOCUMENT_INVALID" + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 文本节点: + text: str + marks: tuple[Literal["strong", "emphasis"], ...] = () + type: Literal["text"] = "text" + + def __post_init__(self): + if not isinstance(self.text, str) or any(c in self.text for c in "\r\n"): + raise 正文错误("段内换行必须使用显式换行节点") + if self.type != "text" or set(self.marks) - {"strong", "emphasis"}: + raise 正文错误("正文只接受登记的文字节点和行内样式") + object.__setattr__( + self, "marks", tuple(m for m in ("strong", "emphasis") if m in self.marks) + ) + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 换行节点: + newline: Literal["\n", "\r\n", "\r"] = "\n" + type: Literal["line_break"] = "line_break" + + def __post_init__(self): + if self.type != "line_break" or self.newline not in {"\n", "\r\n", "\r"}: + raise 正文错误("显式换行节点无效") + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 段落: + paragraph_id: str + content: tuple[文本节点 | 换行节点, ...] + + def __post_init__(self): + if not self.paragraph_id.strip() or any( + not isinstance(v, (文本节点, 换行节点)) for v in self.content + ): + raise 正文错误("段落必须有稳定身份与允许的内容节点") + 内容: list[文本节点 | 换行节点] = [] + for 节点 in self.content: + if isinstance(节点, 文本节点): + if not 节点.text: + continue + 前 = 内容[-1] if 内容 else None + if isinstance(前, 文本节点) and 前.marks == 节点.marks: + 内容[-1] = 文本节点(前.text + 节点.text, 前.marks) + continue + 内容.append(节点) + object.__setattr__(self, "content", tuple(内容)) + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 正文草稿: + paragraphs: tuple[段落, ...] + format_version: int = 1 + + def __post_init__(self): + if self.format_version != 1 or not self.paragraphs: + raise 正文错误("正文需要受支持格式与至少一个段落;空稿使用空段落") + ids = [p.paragraph_id for p in self.paragraphs] + if len(ids) != len(set(ids)): + raise 正文错误("正文段落身份不能重复") + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 人工正文保存: + chapter_id: str + branch_id: str + expected_revision: int + document: 正文草稿 | None = None + restore_from: int | None = None + + def __post_init__(self): + if not self.chapter_id or not self.branch_id or self.expected_revision < 0: + raise 正文错误("正文保存需要章节、分支和基线版本") + if (self.document is None) == (self.restore_from is None): + raise 正文错误("正文保存或历史回退必须明确选择一个") + if self.restore_from is not None and self.restore_from < 1: + raise 正文错误("回退需要已保存的历史版本") + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 候选定位: + candidate_id: str + candidate_revision: int + candidate_hash: str + target_ref: str + expected_revision: int + + def __post_init__(self): + if self.candidate_revision < 1 or self.expected_revision < 0 or not self.candidate_hash: + raise 正文错误("候选审阅需要明确的候选版本、哈希和正文基线") + + +@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid")) +class 人工候选保存: + candidate_id: str + chapter_id: str + branch_id: str + base_revision: int + expected_candidate_revision: int + expected_candidate_hash: str | None = None + document: 正文草稿 | None = None + selected_changes: tuple[str, ...] = () + + def __post_init__(self): + if self.base_revision < 0 or self.expected_candidate_revision < 0: + raise 正文错误("候选与正文基线不能为负数") + if (self.document is None) == (not self.selected_changes): + raise 正文错误("候选改稿需要全文或明确的差异选择,两者只能选一项") + if len(set(self.selected_changes)) != len(self.selected_changes): + raise 正文错误("候选差异选择不能重复") diff --git a/src/muse/正文写作/正式提交.py b/src/muse/正文写作/正式提交.py new file mode 100644 index 0000000..054e3e8 --- /dev/null +++ b/src/muse/正文写作/正式提交.py @@ -0,0 +1,73 @@ +"""正文候选的正式决定;复用人工正文参与者和S01共同事务。""" + +from muse.作品规划.接口 import 读取章节归属 +from muse.正式变更.接口 import ( + 作者动作, + 依赖引用, + 修改影响, + 参与操作, + 变更错误, + 固定哈希, + 审阅内容, + 提交计划, +) +from muse.正文写作.人工保存 import 正文写入 +from muse.正文写作.候选版本 import 核对候选归属, 段落差异 +from muse.正文写作.存储 import 正文存储 +from muse.正文写作.模型 import 人工正文保存, 候选定位 +from muse.正文写作.正文格式 import 文稿身份, 正文结构哈希 + + +class 正文候选决定入口: + 身份 = "B05.decide_candidate" + 请求类型 = 候选定位 + + def 查看(self, 连, 身份, 命令): + 请求 = 命令.业务请求 + 行 = 核对候选归属(连, 身份, 请求.candidate_id) + if 行["revision"] != 请求.candidate_revision or 行["candidate_hash"] != 请求.candidate_hash: + raise 变更错误("REVISION_CONFLICT", "候选与被审版本不一致") + 文稿ID = 文稿身份(行["chapter_id"], 行["branch_id"]) + if 请求.target_ref != 文稿ID: + raise 变更错误("SCOPE_DENIED", "候选与目标文稿不一致") + 存储 = 正文存储(连) + 正文 = 存储.读取(文稿ID) + 版 = 正文["revision"] if 正文 else 0 + 基线 = 存储.读取草稿(文稿ID, 版) + 草稿 = 存储.恢复草稿(行) + 章节 = 读取章节归属(连, 身份.作者, 行["chapter_id"]) + return 审阅内容( + 文稿ID, + 版, + 请求.candidate_id, + 行["revision"], + 行["candidate_hash"], + 固定哈希(段落差异(基线, 草稿)), + 固定哈希({"format_version": 1}), + "document-v1", + 固定哈希([行["base_revision"], 行["base_hash"], 章节]), + ("document",), + ( + 依赖引用( + "B01.directory", "directory:" + 章节["work_id"], str(章节["directory_revision"]) + ), + 依赖引用("B05.document", 文稿ID, str(版)), + 依赖引用("B05.candidate", 请求.candidate_id, str(行["revision"])), + ), + ) + + def 准备(self, 连, 身份, 命令, 当前): + if 命令.动作 in {作者动作.拒绝, 作者动作.暂缓}: + return 提交计划(()) + if 命令.动作 is not 作者动作.采纳: + raise 变更错误("INVALID_REQUEST", "候选入口只接受明确作者决定") + 行 = 核对候选归属(连, 身份, 命令.业务请求.candidate_id) + 基线 = 正文存储(连).读取草稿(当前.目标, 当前.数据版本) + if 行["base_revision"] != 当前.数据版本 or 行["base_hash"] != 正文结构哈希(基线): + raise 变更错误("SOURCE_STALE", "候选依据的正文已改变,不能直接覆盖作者新稿") + 草稿 = 正文存储.恢复草稿(行) + 请求 = 人工正文保存(行["chapter_id"], 行["branch_id"], 当前.数据版本, document=草稿) + return 提交计划( + (参与操作("B05.write_document", 正文写入(身份.作者, 请求, 草稿)),), + (修改影响(当前.目标, "document_changed", str(当前.数据版本), str(当前.数据版本 + 1)),), + ) diff --git a/src/muse/正文写作/正文格式.py b/src/muse/正文写作/正文格式.py new file mode 100644 index 0000000..4c1b0d3 --- /dev/null +++ b/src/muse/正文写作/正文格式.py @@ -0,0 +1,33 @@ +"""正文的可见文本与结构序列化;不做Unicode或空白规范化。""" + +import hashlib +import json +from dataclasses import asdict + +from muse.正文写作.模型 import 文本节点, 正文草稿 + + +def 可见文本(草稿: 正文草稿) -> str: + return "\n".join( + "".join(n.text if isinstance(n, 文本节点) else n.newline for n in p.content) + for p in 草稿.paragraphs + ) + + +def 正文结构哈希(草稿: 正文草稿) -> str: + 字节 = json.dumps( + asdict(草稿), ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode() + return hashlib.sha256(字节).hexdigest() + + +def 可见文本哈希(草稿: 正文草稿) -> str: + return hashlib.sha256(可见文本(草稿).encode()).hexdigest() + + +def 文稿身份(chapter_id: str, branch_id: str) -> str: + from uuid import NAMESPACE_URL, uuid5 + + return str( + uuid5(NAMESPACE_URL, "muse:B05:" + json.dumps([chapter_id, branch_id], ensure_ascii=False)) + ) diff --git a/tests/契约/test_正文与模型输出.py b/tests/契约/test_正文与模型输出.py new file mode 100644 index 0000000..0455375 --- /dev/null +++ b/tests/契约/test_正文与模型输出.py @@ -0,0 +1,94 @@ +"""正文结构的独立接缝:中文原文、段落身份与结构/可见哈希。""" + +import json +from pathlib import Path + +from muse.正文写作.接口 import 可见文本, 文本节点, 正文结构哈希, 正文草稿, 段落 + + +def test_正文不擅自改写NFC与换行__7d1f17(): + from muse.正文写作.接口 import 可见文本哈希, 换行节点 + + 原文 = "Cafe\u0301\r\n第二行\r第三行" + 规范表示 = "Café\n第二行\n第三行" + 草稿 = 正文草稿( + ( + 段落( + "p1", + ( + 文本节点("Cafe\u0301"), + 换行节点("\r\n"), + 文本节点("第二行"), + 换行节点("\r"), + 文本节点("第三行"), + ), + ), + ) + ) + 对照 = 正文草稿( + ( + 段落( + "p1", + ( + 文本节点("Café"), + 换行节点("\n"), + 文本节点("第二行"), + 换行节点("\n"), + 文本节点("第三行"), + ), + ), + ) + ) + assert 可见文本(草稿) == 原文 and 可见文本(对照) == 规范表示 + assert 可见文本哈希(草稿) != 可见文本哈希(对照) + assert 正文结构哈希(草稿) != 正文结构哈希(对照) + + +def test_中文夹具往返保留空段落与原码点__aa1001(): + 样本 = json.loads((Path(__file__).parents[1] / "夹具/中文正文.json").read_text())["samples"] + for 项 in 样本: + 段 = tuple(段落(f"p-{i}", (文本节点(文本),)) for i, 文本 in enumerate(项["paragraphs"])) + 草稿 = 正文草稿(段) + assert 可见文本(草稿) == "\n".join(项["paragraphs"]) + for 选区 in 项.get("selections", []): + 文本 = 可见文本(正文草稿((段[选区["paragraph_index"]],))) + assert 文本[选区["start"] : 选区["end"]] == 选区["expected_text"] + 另名 = 正文草稿(tuple(段落("new-" + p.paragraph_id, p.content) for p in 段)) + assert 可见文本(草稿) == 可见文本(另名) + assert 正文结构哈希(草稿) != 正文结构哈希(另名) + + +def test_正文外壳拒绝额外节点属性__aa1002(): + import pytest + from pydantic import TypeAdapter, ValidationError + + 合同 = TypeAdapter(正文草稿) + 输入 = { + "paragraphs": [ + { + "paragraph_id": "p1", + "content": [{"type": "text", "text": "文字", "html": "