From 02063c8454d96e631dc7479cf5c8f62c1b523bee Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 19 Sep 2026 00:14:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=B8=8A=E4=B8=8B=E6=96=87):=20=E5=A4=8D?= =?UTF-8?q?=E5=88=A9=E6=9C=BA=E5=88=B6=E6=89=B9=E6=AC=A13=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=8F=82=E8=80=83=E6=94=B9=E5=86=99=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E9=80=89=E6=AE=B5=E6=8E=88=E6=9D=83=E9=93=BE=EF=BC=8C=E5=81=8F?= =?UTF-8?q?=E5=A5=BD=E4=B8=8E=E5=8F=82=E8=80=83=E6=9D=90=E6=96=99=E8=BF=9B?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E5=BF=AB=E7=85=A7=E5=B9=B6=E5=8F=97=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=A4=8D=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/接口契约/生成/openapi.generated.json | 8 + .../新版设计/模块设计/B04-知识方法.md | 1 + src/muse/上下文/任务范围.py | 9 + src/muse/上下文/依赖校验.py | 18 ++ src/muse/上下文/参考材料.py | 89 +++++++++ src/muse/上下文/接口.py | 4 + src/muse/上下文/范围授权.py | 24 +++ src/muse/上下文/范围校验.py | 21 ++ src/muse/正文写作/提示词/修改选段.md | 2 + src/muse/正文写作/选段任务.py | 7 + src/muse/知识方法/接口.py | 2 + src/muse/知识方法/方法卡.py | 92 +++++++++ src/muse/编排/审校修订.py | 22 ++- tests/契约/test_参考改写合同.py | 77 ++++++++ tests/用例清单.json | 145 ++++++++++++++ tests/集成/test_参考改写.py | 183 ++++++++++++++++++ web/src/功能/正文写作/选段改写.tsx | 2 +- web/src/接口/生成/类型.ts | 5 + 18 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 src/muse/上下文/参考材料.py create mode 100644 tests/契约/test_参考改写合同.py create mode 100644 tests/集成/test_参考改写.py diff --git a/docs/接口契约/生成/openapi.generated.json b/docs/接口契约/生成/openapi.generated.json index 30a76ff..959abf3 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -20814,6 +20814,14 @@ "type": "array", "title": "Protected Ranges", "default": [] + }, + "references": { + "items": { + "type": "string" + }, + "type": "array", + "title": "References", + "default": [] } }, "additionalProperties": false, diff --git a/docs/系统架构/新版设计/模块设计/B04-知识方法.md b/docs/系统架构/新版设计/模块设计/B04-知识方法.md index 6aef77c..25b78a1 100644 --- a/docs/系统架构/新版设计/模块设计/B04-知识方法.md +++ b/docs/系统架构/新版设计/模块设计/B04-知识方法.md @@ -20,6 +20,7 @@ | 读取方法评测材料 | 维护事务、作者、具体确认版本ID | 核原确认内容、版本与证据,按S04 generation/evaluation投影;只形成B10受批准的资料快照,不启用方法或写生产消费 | | 核对方法评测投影 | 同用途元数据连接和已封存材料 | 重验内容/结构/字段策略哈希;评测不直接读正式方法表 | | 读取消费统计 | 作者、方法版本范围 | 按版本聚合的实际消费计数,供 B09 偏好重排的强信号路;只读,不改消费台账,不把被检索或任务成功当作消费 | +| 读取参考材料 | 作者、确切方法版本清单、两种用途 | 当前且已确认或启用版本的参考材料渲染与哈希,供参考改写冻结与依赖复检;不查启用凭据、不写消费 | 所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。 diff --git a/src/muse/上下文/任务范围.py b/src/muse/上下文/任务范围.py index 09b1d8f..ab6ffae 100644 --- a/src/muse/上下文/任务范围.py +++ b/src/muse/上下文/任务范围.py @@ -237,6 +237,7 @@ class 上下文服务: 含目录: bool = False, 含声音: bool = False, 含偏好: bool = False, + 参考版本: tuple[str, ...] = (), ) -> dict: return 授权任务范围( self.数据库, @@ -251,6 +252,7 @@ class 上下文服务: 含目录=含目录, 含声音=含声音, 含偏好=含偏好, + 参考版本=参考版本, ) def 读取范围(self, 任务ID: str) -> dict: @@ -276,6 +278,7 @@ class 上下文服务: 核对声音装配(范围, 装配, 绑定, 声音) 核对偏好装配(范围, 装配, 绑定, 核对范围偏好(连, 范围)) 方法 = 核对范围方法(连, 范围) + from muse.上下文.参考材料 import 核对范围参考 from muse.上下文.材料装配 import 按键读取材料 入选方法 = [条 for 条 in 方法 if 条["材料键"] in 装配.入选来源] @@ -283,6 +286,12 @@ class 上下文服务: 实际材料 = 按键读取材料(装配) if any(实际材料.get(条["材料键"]) != 条["材料"] for 条 in 入选方法): raise 上下文错误("CONTEXT_INPUT_INVALID", "入选方法正文与授权材料不一致") + 参考 = 核对范围参考(连, 范围) + 入选参考 = [条 for 条 in 参考 if 条["材料键"] in 装配.入选来源] + if 入选参考: + 实际材料 = 按键读取材料(装配) + if any(实际材料.get(条["材料键"]) != 条["材料"] for 条 in 入选参考): + raise 上下文错误("CONTEXT_INPUT_INVALID", "入选参考正文与授权材料不一致") 新建 = 上下文存储(连, self.数据库.用途).写入快照( { "snapshot_id": 身份, diff --git a/src/muse/上下文/依赖校验.py b/src/muse/上下文/依赖校验.py index 58e49a5..c553d19 100644 --- a/src/muse/上下文/依赖校验.py +++ b/src/muse/上下文/依赖校验.py @@ -285,6 +285,24 @@ def 核对依赖( 锁定.append( 依赖锁定(条["preference_id"], "preference", str(条["revision"]), 偏好结构, 偏好投影) ) + + from muse.上下文.参考材料 import 核对范围参考 + + try: + 参考 = 核对范围参考(连, 冻结范围) + except 上下文错误 as 错误: + 变化项.append(错误.说明) + else: + for 条 in 参考: + 锁定.append( + 依赖锁定( + 条["version_id"], + "reference", + str(条["version"]), + 条["schema_hash"], + 条["projection_version"], + ) + ) return 依赖状态(有效=not 变化项, 锁定依据=tuple(锁定), 变化项=tuple(变化项)) diff --git a/src/muse/上下文/参考材料.py b/src/muse/上下文/参考材料.py new file mode 100644 index 0000000..c6a2122 --- /dev/null +++ b/src/muse/上下文/参考材料.py @@ -0,0 +1,89 @@ +"""B04参考方法材料的确切投影;参考是作者明确挑选的来源,不是作品绑定。 + +参考改写合同见模块设计/B06-审校修订.md「参考改写(选段改写链路扩展)」: +挑选发生在流程发起前,选中的方法版本作为授权输入进入冻结范围; +材料是可选材料语义,漂移即整体拒绝,不部分放行。 +""" + +from muse.上下文.模型 import 上下文错误, 来源绑定 + +_身份字段 = ( + "version_id", + "method_id", + "version", + "材料键", + "材料哈希", + "schema_hash", + "projection_version", +) + + +def 冻结参考身份(条: dict) -> dict: + """范围冻结只保留身份与哈希;材料文本不入范围,读取时经B04现取。""" + return {键: 条[键] for 键 in _身份字段} + + +def 校验参考节(范围: dict) -> None: + 参考 = 范围.get("references") + if 参考 is None: + return + if not isinstance(参考, list): + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结参考清单必须是列表") + 已见: set[str] = set() + for 条 in 参考: + if not isinstance(条, dict) or any( + not isinstance(条.get(键), str) or not 条[键] + for 键 in ( + "version_id", + "method_id", + "材料键", + "材料哈希", + "schema_hash", + "projection_version", + ) + ): + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结参考缺少版本或材料身份") + if type(条.get("version")) is not int or 条["version"] < 1: + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结参考版本必须为正整数") + if 条["version_id"] in 已见 or 条["材料键"] != "参考方法:" + 条["version_id"]: + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结参考重复或材料键不匹配") + 已见.add(条["version_id"]) + + +def 核对范围参考(连, 范围: dict, *, 读取预算=None) -> list[dict]: + """按冻结身份现读当前材料并逐项核对;任一漂移即整体拒绝。""" + 校验参考节(范围) + 参考 = 范围.get("references") + if not 参考: + return [] + from muse.知识方法.接口 import 方法错误, 读取参考材料 + + try: + 当前 = 读取参考材料( + 连, + tuple(条["version_id"] for 条 in 参考), + 作者=范围["authorized_by"], + 内容用途=范围["content_purpose"], + 运行用途=范围["run_purpose"], + 读取预算=读取预算, + ) + except 方法错误 as 错: + raise 上下文错误("SOURCE_STALE", f"参考方法不可用:{错.说明}") from 错 + 按版本 = {条["version_id"]: 条 for 条 in 当前} + 结果 = [] + for 条 in 参考: + 现 = 按版本.get(条["version_id"]) + if 现 is None: + raise 上下文错误("SOURCE_STALE", f"参考方法 {条['version_id']} 已不在当前清单") + if any( + 现[键] != 条[键] for 键 in ("version", "材料哈希", "schema_hash", "projection_version") + ): + raise 上下文错误("SOURCE_STALE", f"参考方法 {条['version_id']} 材料或投影已漂移") + 结果.append({**条, "材料": 现["材料"], "title": 现["title"], "type_id": 现["type_id"]}) + return 结果 + + +def 参考绑定(条: dict) -> 来源绑定: + return 来源绑定( + 条["version_id"], str(条["version"]), 条["schema_hash"], 条["projection_version"] + ) diff --git a/src/muse/上下文/接口.py b/src/muse/上下文/接口.py index cd2d2ca..825a1be 100644 --- a/src/muse/上下文/接口.py +++ b/src/muse/上下文/接口.py @@ -16,6 +16,7 @@ from muse.上下文.冻结快照 import ( 文本哈希, 输入哈希, ) +from muse.上下文.参考材料 import 参考绑定, 校验参考节, 核对范围参考 from muse.上下文.回放材料 import 历史版本选择, 回放材料选择, 核对回放字段策略, 读取回放材料 from muse.上下文.声音材料 import 核对范围声音 from muse.上下文.探索回放 import ( @@ -80,6 +81,9 @@ __all__ = [ "核对范围声音", "核对范围偏好", "偏好绑定", + "核对范围参考", + "参考绑定", + "校验参考节", "核对方法消费材料", "上下文快照", "上下文错误", diff --git a/src/muse/上下文/范围授权.py b/src/muse/上下文/范围授权.py index c3e4b74..4178240 100644 --- a/src/muse/上下文/范围授权.py +++ b/src/muse/上下文/范围授权.py @@ -51,10 +51,15 @@ def 授权任务范围( 含目录: bool = False, 含声音: bool = False, 含偏好: bool = False, + 参考版本: tuple[str, ...] = (), ) -> dict: """按作者身份冻结历史文稿、目标章细纲与事实系统版本;两种用途随范围绑定。""" if not 作品ID: raise 上下文错误("CONTEXT_SCOPE_INVALID", "任务范围必须绑定明确作品") + if isinstance(参考版本, str) or not isinstance(参考版本, tuple): + raise 上下文错误("CONTEXT_SCOPE_INVALID", f"参考版本必须以元组显式声明:{参考版本!r}") + if len(参考版本) > 8: + raise 上下文错误("CONTEXT_SCOPE_INVALID", "参考方法版本不超过8个") if 身份.作者 is None or 身份.用途 is not 数据库.用途: raise 上下文错误("SCOPE_DENIED", "任务范围授权需要与装配用途一致的作者身份") 校验截止(截止) @@ -126,8 +131,25 @@ def 授权任务范围( else None ) 偏好 = 偏好身份(读取偏好投影(连, 身份.作者, 作品ID, 身份.任务)) if 含偏好 else [] + 参考: list[dict] = [] + if 参考版本: + from muse.上下文.参考材料 import 冻结参考身份 + from muse.知识方法.接口 import 读取参考材料 + + 参考 = [ + 冻结参考身份(条) + for 条 in 读取参考材料( + 连, + 参考版本, + 作者=身份.作者, + 内容用途=内容用途, + 运行用途=运行用途, + 读取预算=用量.预留来源, + ) + ] 来源集 = [d["document_id"] for d in 文稿] 来源集.extend(x["preference_id"] for x in 偏好) + 来源集.extend(条["version_id"] for 条 in 参考) if 声音 is not None: 来源集.append(声音["version_id"]) 来源集.extend(条["binding_id"] for 条 in 方法) @@ -167,6 +189,8 @@ def 授权任务范围( 范围["preference_task"] = 身份.任务 if 含方法: 范围["methods"] = 方法 + if 参考: + 范围["references"] = 参考 if 细纲 is not None: 范围["target_plan"] = 细纲 if 事实 is not None: diff --git a/src/muse/上下文/范围校验.py b/src/muse/上下文/范围校验.py index 27229c1..89ea035 100644 --- a/src/muse/上下文/范围校验.py +++ b/src/muse/上下文/范围校验.py @@ -8,6 +8,7 @@ from muse.上下文.偏好材料 import ( 校验偏好节, 核对偏好装配, ) +from muse.上下文.参考材料 import 校验参考节 from muse.上下文.声音材料 import ( 校验声音节, 核对声音装配, @@ -77,6 +78,23 @@ def _校验冻结输入( 条["projection_version"], ): raise 上下文错误("SOURCE_STALE", "方法版本或材料哈希与授权不一致") + 参考集 = {条["version_id"]: 条 for 条 in 范围.get("references", [])} + 参考键 = {条["材料键"] for 条 in 参考集.values()} + if any(键.startswith("参考方法:") and 键 not in 参考键 for 键 in 装配.入选来源): + raise 上下文错误("SCOPE_DENIED", "装配包含未授权参考方法材料") + 入选参考 = {vid for vid, 条 in 参考集.items() if 条["材料键"] in 装配.入选来源} + 实绑参考 = {条.来源ID for 条 in 绑定 if 条.来源ID in 参考集} + if 入选参考 != 实绑参考: + raise 上下文错误("CONTEXT_INPUT_INVALID", "入选参考与冻结版本绑定不一致") + for 绑 in 绑定: + if 绑.来源ID in 参考集: + 条 = 参考集[绑.来源ID] + if (绑.数据版本, 绑.结构哈希, 绑.投影版本) != ( + str(条["version"]), + 条["schema_hash"], + 条["projection_version"], + ): + raise 上下文错误("SOURCE_STALE", "参考版本或材料哈希与授权不一致") 目录源 = 范围.get("directory_source") if 目录源: for 项 in 绑定: @@ -159,11 +177,13 @@ def 恢复冻结范围(数据库: 数据库工厂, 任务: 任务快照) -> dict or 事实 is not None or 目录源 is not None or 范围.get("methods") + or 范围.get("references") or "voice" in 范围 ): _校验用途绑定(范围, 固定) 校验声音节(范围.get("voice")) 校验偏好节(范围) + 校验参考节(范围) if 范围.get("preference_task") not in (None, 任务.任务ID): raise 上下文错误("SCOPE_DENIED", "偏好绑定的任务不是当前任务") 身份集 = 范围.get("source_ids") @@ -200,6 +220,7 @@ def 恢复冻结范围(数据库: 数据库工厂, 任务: 任务快照) -> dict 方法ID.add(条["binding_id"]) 基础 = {项["document_id"] for 项 in 文稿} | 方法ID 基础.update(x["preference_id"] for x in 范围.get("preferences", [])) + 基础.update(x["version_id"] for x in 范围.get("references", [])) if 范围.get("voice") is not None: 基础.add(范围["voice"]["version_id"]) if 目录源 is not None: diff --git a/src/muse/正文写作/提示词/修改选段.md b/src/muse/正文写作/提示词/修改选段.md index 0e7ea57..773396b 100644 --- a/src/muse/正文写作/提示词/修改选段.md +++ b/src/muse/正文写作/提示词/修改选段.md @@ -6,4 +6,6 @@ expand在原场景内补动作、对话和感官细节;compress删去冗余但 原文、附近语境、作者指示与已确认声音由上下文提供。保留人物、数量、时间、推测情态、引文和保护表达,不以其他修改抵消某一处损失。改变因果或事实需要作者另行明确判断,不能自称已通过语义核验。 +上下文若提供“参考方法:”前缀的材料,那是作者为本次改写明确挑选的参考范式:只借鉴其节奏、技法与细节处理方式,结合修订目标、人物与声音重新原创;不照抄参考材料的句子,不把其中的人名、地名、专有名词或数字带入选段,也不因参考材料而扩大改写范围。没有参考材料时不虚构参考。 + 只返回输出合同允许的字段。位置和候选身份由服务端决定,不编造诊断、调用回执或作者批准。 diff --git a/src/muse/正文写作/选段任务.py b/src/muse/正文写作/选段任务.py index 53ffbab..7de2c01 100644 --- a/src/muse/正文写作/选段任务.py +++ b/src/muse/正文写作/选段任务.py @@ -87,6 +87,7 @@ class 选段写作授权: purpose: str selections: tuple[原文选段, ...] protected_ranges: tuple[原文选段, ...] = () + references: tuple[str, ...] = () def __post_init__(self): if self.revision < 1 or not self.purpose.strip() or not 1 <= len(self.selections) <= 32: @@ -95,6 +96,12 @@ class 选段写作授权: raise 正文错误("选段改写需明确作品、章节及分支") if len({s.selection_id for s in self.selections}) != len(self.selections): raise 正文错误("圈选不能重复") + if len(self.references) > 8: + raise 正文错误("参考方法不超过8个") + if len(set(self.references)) != len(self.references): + raise 正文错误("参考方法版本不能重复") + if not all(type(r) is str and r.strip() for r in self.references): + raise 正文错误("参考方法需要非空版本身份") def 准备选段改写(连, 作者, 授权: 选段写作授权): diff --git a/src/muse/知识方法/接口.py b/src/muse/知识方法/接口.py index e5c78c4..4290a4a 100644 --- a/src/muse/知识方法/接口.py +++ b/src/muse/知识方法/接口.py @@ -13,6 +13,7 @@ from muse.正式变更.接口 import 作者动作, 变更命令, 正式变更服 from muse.知识方法.存储 import 方法存储 from muse.知识方法.方法卡 import ( 列出方法族类型, + 读取参考材料, 读取方法检索候选, 读取方法检索资料, 读取方法检索身份, @@ -304,6 +305,7 @@ __all__ = [ "消费输入", "读取方法检索资料", "读取方法检索候选", + "读取参考材料", "读取方法检索身份", "读取方法索引维护资料", "读取版本材料大小", diff --git a/src/muse/知识方法/方法卡.py b/src/muse/知识方法/方法卡.py index 5f6e78d..68dddd5 100644 --- a/src/muse/知识方法/方法卡.py +++ b/src/muse/知识方法/方法卡.py @@ -9,6 +9,7 @@ from __future__ import annotations import json from dataclasses import asdict from typing import Any +from uuid import UUID from muse.元数据.接口 import 元数据服务, 投影值, 投影字段, 授权摘要, 校验值, 选择实例维护者 from muse.正式变更.接口 import 固定哈希 @@ -201,6 +202,97 @@ def 读取方法检索候选( } +def 读取参考材料( + 连, + version_ids: tuple[str, ...], + *, + 作者: str, + 内容用途: str, + 运行用途: str, + 读取预算=None, +) -> list[dict[str, Any]]: + """按确切版本清单渲染参考材料;只接受作者自有、当前且已确认或启用的版本。 + + 参考是作者明确挑选的来源,不是作品绑定:不查启用凭据、不写消费记录; + 供参考改写的冻结与依赖复检使用,材料与哈希形状与绑定材料同构。 + """ + from muse.元数据.接口 import 元数据服务, 元数据错误, 投影值, 投影字段 + from muse.正式变更.接口 import 固定哈希 + from muse.知识方法.模型 import 证据引用 + from muse.知识方法.证据反例 import 核对证据 + + if isinstance(version_ids, str) or not isinstance(version_ids, (tuple, list)): + raise 方法错误("METHOD_INPUT_INVALID", "参考版本清单必须是元组或列表") + 存储 = 方法存储(连) + 已见: set[str] = set() + 材料: list[dict[str, Any]] = [] + for 原始 in version_ids: + vid = str(原始) + if not vid.strip(): + raise 方法错误("METHOD_INPUT_INVALID", "参考版本需要非空身份") + try: + UUID(vid) + except (ValueError, TypeError, AttributeError): + raise 方法错误("METHOD_NOT_FOUND", f"参考方法 {vid} 已不存在") from None + if vid in 已见: + raise 方法错误("METHOD_INPUT_INVALID", f"参考版本重复声明:{vid}") + 已见.add(vid) + 头 = 存储.读取检索版本头(vid) + if 头 is None: + raise 方法错误("METHOD_NOT_FOUND", f"参考方法 {vid} 已不存在") + if 头["owner"] != 作者: + raise 方法错误("SCOPE_DENIED", "参考方法不属于当前作者") + if 头["version"] != 头["current_version"]: + raise 方法错误("METHOD_VERSION_STALE", "参考版本不是方法当前版本") + if 头["state"] not in {"confirmed", "enabled"}: + raise 方法错误("METHOD_STATE_UNAVAILABLE", "参考方法当前状态不可用") + if 读取预算 is not None: + 读取预算(读取版本材料大小(连, vid)) + 版本, 证据 = 读取版本材料(连, vid) + if 版本["proposal_id"] is None: + raise 方法错误("EVIDENCE_INVALID", "方法版本缺少确认提案,只能历史回查") + 核对证据( + 连, + 作者, + tuple(证据引用(kind=证["kind"], **证["ref"], note=证["note"]) for 证 in 证据), + ) + 元数据 = 元数据服务(连) + try: + 结构 = 元数据.读取有效结构(版本["schema_id"], 版本["schema_version"]) + if 结构.effective_schema_hash != 版本["effective_schema_hash"]: + raise 方法错误("SOURCE_STALE", "方法版本的结构依据已变化") + 投影 = 投影字段( + 结构, + 元数据.当前策略(版本["type_id"]), + 方法授权(版本["owner"]), + 字段用途名="aiContext", + 内容用途=内容用途, + 运行用途=运行用途, + ) + 内容 = 投影值(投影, 版本["content"]) + except 元数据错误 as 错: + raise 方法错误("SOURCE_STALE", f"方法字段用途或结构不可用:{错}") from 错 + if not 内容: + raise 方法错误("SOURCE_STALE", "该方法没有允许当前模型用途的内容字段") + 文本 = 渲染方法材料({**版本, "content": 内容}, 证据) + 材料.append( + { + "材料键": "参考方法:" + vid, + "method_id": str(版本["method_id"]), + "version_id": vid, + "version": int(头["version"]), + "type_id": 版本["type_id"], + "title": 版本["title"], + "材料": 文本, + "材料哈希": 固定哈希([文本]), + "schema_hash": 结构.effective_schema_hash, + "projection_version": 投影.projection_version, + "参数上限": 版本["参数上限"], + } + ) + return 材料 + + def 读取方法检索身份(连, *, 作者: str | None = None, 数量: int = 5001) -> tuple[str, ...]: """当前可发现版本的轻量清单;不展开正文、提案或来源证据。""" return tuple( diff --git a/src/muse/编排/审校修订.py b/src/muse/编排/审校修订.py index a1e4b69..389920b 100644 --- a/src/muse/编排/审校修订.py +++ b/src/muse/编排/审校修订.py @@ -10,8 +10,12 @@ from uuid import uuid4 from muse.上下文.接口 import ( 依赖重验检查, + 偏好绑定, + 参考绑定, 投影版本, 来源绑定, + 核对范围偏好, + 核对范围参考, 核对范围声音, 装配材料, ) @@ -107,6 +111,8 @@ def 发起模型修订(app, 身份, 命令ID, 授权: 模型修订授权 | 选 ), 含目录=True, 含声音=True, + 含偏好=True, + 参考版本=tuple(授权.references) if isinstance(授权, 选段写作授权) else (), ) 策略 = 角色策略目录.从发布包() request = 任务请求( @@ -167,7 +173,10 @@ def 登记审校修订(登记, app, 计价): _, _, _, text = 核对修订任务(连, ctx.任务) scope = 上下文.恢复冻结范围(ctx.任务) voice = 核对范围声音(连, scope) + 偏好材料 = 核对范围偏好(连, scope) + 参考材料 = 核对范围参考(连, scope) 材料 = {"修订目标": json.dumps(text, ensure_ascii=False, sort_keys=True)} + 必读 = ["修订目标"] doc = scope["documents"][0] bindings = [ 来源绑定( @@ -179,6 +188,7 @@ def 登记审校修订(登记, app, 计价): ] if voice is not None: 材料[voice["材料键"]] = voice["材料"] + 必读.append(voice["材料键"]) bindings.append( 来源绑定( voice["version_id"], @@ -187,7 +197,17 @@ def 登记审校修订(登记, app, 计价): voice["projection_version"], ) ) - 组装 = 装配材料(材料, tuple(材料), 字节上限=65536) + for 条 in 偏好材料: + 材料[条["材料键"]] = 条["材料"] + 必读.append(条["材料键"]) + bindings.append(偏好绑定(条)) + # 参考方法是可选材料:超预算记省略原因,不拒绝整轮修订。 + for 条 in 参考材料: + 材料[条["材料键"]] = 条["材料"] + 组装 = 装配材料(材料, tuple(必读), 字节上限=65536) + for 条 in 参考材料: + if 条["材料键"] in 组装.入选来源: + bindings.append(参考绑定(条)) snap = 上下文.冻结上下文(ctx.任务.任务ID, 组装, tuple(bindings)) return 步骤结果({"snapshot_id": snap.快照ID}, 检查点={"snapshot_id": snap.快照ID}) diff --git a/tests/契约/test_参考改写合同.py b/tests/契约/test_参考改写合同.py new file mode 100644 index 0000000..fc3bade --- /dev/null +++ b/tests/契约/test_参考改写合同.py @@ -0,0 +1,77 @@ +"""参考改写授权合同:references 有界、唯一、非空;旧授权载荷向后兼容。""" + +import pytest +from pydantic import TypeAdapter, ValidationError + +from muse.共享.错误 import Muse错误 +from muse.正文写作.接口 import 原文选段, 选段写作授权 + + +def _选段(): + return 原文选段("p1", "0" * 64, 0, 4, "雨声很轻") + + +def _授权(**changes): + base = { + "work_id": "w1", + "chapter_id": "c1", + "branch_id": "main", + "revision": 1, + "document_hash": "a" * 64, + "mode": "rewrite", + "purpose": "改为机甲近身刀法", + "selections": (_选段(),), + } + return 选段写作授权(**{**base, **changes}) + + +@pytest.mark.case_id( + "NC-w32-a10008", + environment="离线", + given="固定选段授权与参考方法版本清单", + when="构造带 references 的选段写作授权", + then=["参考清单有界、唯一、非空;默认空清单向后兼容"], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_参考清单有界唯一非空且默认兼容__a10008(): + assert _授权().references == () + assert _授权(references=("v1", "v2")).references == ("v1", "v2") + with pytest.raises(Muse错误): + _授权(references=tuple(f"v{i}" for i in range(9))) + with pytest.raises(Muse错误): + _授权(references=("v1", "v1")) + with pytest.raises(Muse错误): + _授权(references=("v1", " ")) + + +@pytest.mark.case_id( + "NC-w32-a10009", + environment="离线", + given="不含 references 字段的旧授权载荷", + when="经公开边界重建授权", + then=["旧载荷按空参考清单兼容;注入未知字段拒绝"], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_旧授权载荷兼容且未知字段拒绝__a10009(): + adapter = TypeAdapter(选段写作授权) + payload = { + "work_id": "w1", + "chapter_id": "c1", + "branch_id": "main", + "revision": 1, + "document_hash": "a" * 64, + "mode": "rewrite", + "purpose": "改为机甲近身刀法", + "selections": [ + { + "paragraph_id": "p1", + "paragraph_hash": "0" * 64, + "start": 0, + "end": 4, + "original": "雨声很轻", + } + ], + } + assert adapter.validate_python(payload).references == () + with pytest.raises(ValidationError): + adapter.validate_python({**payload, "references": ["v1"], "unknown_field": True}) diff --git a/tests/用例清单.json b/tests/用例清单.json index 38ec7e9..3add562 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -27096,6 +27096,151 @@ "数据库" ] }, + { + "case_id": "NC-w32-a10008", + "environment": "离线", + "given": "固定选段授权与参考方法版本清单", + "when": "构造带 references 的选段写作授权", + "then": [ + "参考清单有界、唯一、非空;默认空清单向后兼容" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/契约/test_参考改写合同.py", + "symbol": "test_参考清单有界唯一非空且默认兼容__a10008", + "parameter_ids": [], + "node_ids": [ + "tests/契约/test_参考改写合同.py::test_参考清单有界唯一非空且默认兼容__a10008" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10009", + "environment": "离线", + "given": "不含 references 字段的旧授权载荷", + "when": "经公开边界重建授权", + "then": [ + "旧载荷按空参考清单兼容;注入未知字段拒绝" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/契约/test_参考改写合同.py", + "symbol": "test_旧授权载荷兼容且未知字段拒绝__a10009", + "parameter_ids": [], + "node_ids": [ + "tests/契约/test_参考改写合同.py::test_旧授权载荷兼容且未知字段拒绝__a10009" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10010", + "environment": "隔离PG与合成HTTP提供方", + "given": "带参考版本与活跃偏好的选段改写授权", + "when": "发起并执行受控修订流程", + "then": [ + "偏好与参考材料入选装配并绑定进快照;写手输入携带材料" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/集成/test_参考改写.py", + "symbol": "test_偏好与参考材料进快照与写手输入__a10010", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_参考改写.py::test_偏好与参考材料进快照与写手输入__a10010" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "参考环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, + { + "case_id": "NC-w32-a10011", + "environment": "隔离PG与合成HTTP提供方", + "given": "携带不存在参考版本的选段授权", + "when": "发起受控修订", + "then": [ + "授权范围阶段快速拒绝,不创建任务" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/集成/test_参考改写.py", + "symbol": "test_未知参考版本在发起时拒绝__a10011", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_参考改写.py::test_未知参考版本在发起时拒绝__a10011" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "参考环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, + { + "case_id": "NC-w32-a10012", + "environment": "隔离PG与合成HTTP提供方", + "given": "已冻结的参考改写任务", + "when": "参考方法推进新版本后读取快照", + "then": [ + "旧参考版本不再当前,快照标注失效且要求重新授权" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/集成/test_参考改写.py", + "symbol": "test_参考版本漂移使快照失效__a10012", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_参考改写.py::test_参考版本漂移使快照失效__a10012" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "参考环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, { "case_id": "NC-work-current-binding", "environment": "隔离 PostgreSQL;结构升级为明确的已完成状态夹具,不代替作者升级旅程", diff --git a/tests/集成/test_参考改写.py b/tests/集成/test_参考改写.py new file mode 100644 index 0000000..a4e55ac --- /dev/null +++ b/tests/集成/test_参考改写.py @@ -0,0 +1,183 @@ +"""参考改写真实链路:授权携带参考版本,偏好与参考材料进冻结快照;漂移即失效。""" + +import json +from dataclasses import replace +from pathlib import Path +from uuid import uuid4 + +import pytest +import test_上下文快照与索引写入 as 方法测试 +import test_两阶段写手 as 写手测试 +import test_模型修订任务 as 修订测试 + +from muse.任务运行.接口 import 任务状态 +from muse.作者经验.接口 import 偏好范围, 偏好请求 +from muse.共享.错误 import Muse错误 +from muse.启动 import 装配审校修订 +from muse.正文写作.接口 import 原文选段, 文本节点, 正文草稿, 段落, 段落哈希, 选段写作授权 +from muse.资料研究.接口 import 导入请求, 资料服务 + +pytestmark = pytest.mark.数据库 +生成环境 = 修订测试.生成环境 + + +@pytest.fixture +def 参考环境(生成环境): + env = 生成环境 + 装配 = env["装配"] + 装配审校修订( + 装配, 写手测试.合成计价(), Path(__file__).parents[2] / "配置/流程模板/审校修订.yaml" + ) + doc = 正文草稿( + (段落("p1", (文本节点("机甲的刀锋嗡鸣。"),)), 段落("p2", (文本节点("火花四散。"),))) + ) + env["正文"].保存人工(env["作者"], "ref-seed", "ch-4", 0, doc) + saved = env["正文"].读取正文(env["作者"], "ch-4") + + 方法 = 装配.要求知识方法() + with 方法.数据库.连接() as 连: + 来源 = 资料服务().导入( + 连, + env["作者"].作者, + 导入请求( + kind="reference", + title="机甲打斗参考", + origin="seed://ref-method", + content="机甲近身刀法借势卸力,受击反馈优先于光效描写。", + authorized_uses=("analysis",), + ), + ) + 请求 = 方法测试._提案(str(uuid4()), "机甲近身刀法", str(来源.source_id), owner=env["作者"].作者) + 回执 = 方法.提出方法(env["作者"], str(uuid4()), 请求) + 方法测试._确认(方法, env["作者"], 回执["target_ref"]) + 版本 = 方法.读取方法详情(env["作者"], 请求.method_id)["versions"][0] + + 经验 = 装配.要求作者经验() + 经验.保存偏好( + env["作者"], + str(uuid4()), + 偏好请求( + preference_id=str(uuid4()), + topic="打斗描写", + text="重视受击反馈,不堆砌形容词。", + quote="今后重视受击反馈。", + scope=偏好范围(), + ), + ) + + 选段 = 原文选段("p2", 段落哈希(doc.paragraphs[1]), 0, 5, "火花四散。") + 授权 = 选段写作授权( + "gen-work", + "ch-4", + "main", + saved["revision"], + saved["document_hash"], + "rewrite", + "改为机甲近身刀法,参考所选方法", + (选段,), + (), + references=(str(版本["version_id"]),), + ) + env.update( + 授权=授权, + 选段=选段, + 参考版本=str(版本["version_id"]), + 参考方法ID=请求.method_id, + 来源ID=str(来源.source_id), + ) + return env + + +def _快照ID(task): + return next( + s["checkpoint"]["snapshot_id"] + for s in task.步骤 + if s.get("checkpoint") and "snapshot_id" in s["checkpoint"] + ) + + +@pytest.mark.case_id( + "NC-w32-a10010", + environment="隔离PG与合成HTTP提供方", + given="带参考版本与活跃偏好的选段改写授权", + when="发起并执行受控修订流程", + then=["偏好与参考材料入选装配并绑定进快照;写手输入携带材料"], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_偏好与参考材料进快照与写手输入__a10010(参考环境): + env = 参考环境 + tid = 修订测试.发起(env, command="ref-revision-1") + env["剧本"].append( + { + "类型": "文本", + "文本": { + "edits": [ + { + "selection_id": env["选段"].selection_id, + "action": "replace", + "replacement": "刀锋擦过装甲,反震顺着掌心爬上来。", + "reason": "合成参考改写", + } + ] + }, + } + ) + task = 修订测试.跑(env, tid) + assert task.状态 == 任务状态.已完成 + 输入 = json.loads(env["收到"][-1]["input"]) + assert "重视受击反馈,不堆砌形容词。" in 输入["装配材料"] + + 快照 = env["装配"].要求上下文().读取快照(_快照ID(task)) + assert 快照.失效说明 is None + assert "参考方法:" + env["参考版本"] in 快照.装配["入选"] + assert any(str(绑.来源ID) == env["参考版本"] for 绑 in 快照.来源绑定) + result = env["装配"].要求审校().读取模型修订(env["作者"], tid) + assert result["source_kind"] == "selection" and result["outcome"] == "candidate" + + +@pytest.mark.case_id( + "NC-w32-a10011", + environment="隔离PG与合成HTTP提供方", + given="携带不存在参考版本的选段授权", + when="发起受控修订", + then=["授权范围阶段快速拒绝,不创建任务"], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_未知参考版本在发起时拒绝__a10011(参考环境): + env = 参考环境 + env["授权"] = replace(env["授权"], references=("不存在的版本",)) + with pytest.raises(Muse错误): + 修订测试.发起(env, command="ref-revision-bad") + + +@pytest.mark.case_id( + "NC-w32-a10012", + environment="隔离PG与合成HTTP提供方", + given="已冻结的参考改写任务", + when="参考方法推进新版本后读取快照", + then=["旧参考版本不再当前,快照标注失效且要求重新授权"], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_参考版本漂移使快照失效__a10012(参考环境): + env = 参考环境 + tid = 修订测试.发起(env, command="ref-revision-drift") + svc = env["装配"].任务运行 + claim = svc.领取步骤("revision-worker", ["revision.freeze"]) + assert claim is not None + svc.执行一步(claim) + 快照ID = _快照ID(svc.读取任务(tid)) + + 方法 = env["装配"].要求知识方法() + 新请求 = 方法测试._提案( + str(uuid4()), + "机甲近身刀法二版", + env["来源ID"], + owner=env["作者"].作者, + 方法ID=env["参考方法ID"], + ) + 回执 = 方法.提出方法(env["作者"], str(uuid4()), 新请求) + 方法测试._确认(方法, env["作者"], 回执["target_ref"]) + + 快照 = env["装配"].要求上下文().读取快照(快照ID) + assert 快照.失效说明 and "参考方法" in 快照.失效说明 + assert "需重新授权" in 快照.失效说明 diff --git a/web/src/功能/正文写作/选段改写.tsx b/web/src/功能/正文写作/选段改写.tsx index 6691139..a3b78c1 100644 --- a/web/src/功能/正文写作/选段改写.tsx +++ b/web/src/功能/正文写作/选段改写.tsx @@ -114,7 +114,7 @@ export function Ui选段改写({ pending.current = { command_id: crypto.randomUUID(), config_id: config, - request: { ...target, mode, purpose, selections, protected_ranges }, + request: { ...target, mode, purpose, selections, protected_ranges, references: [] }, }; } const { data } = await 客户端.POST("/api/v1/revision-tasks", { diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts index 1bcced6..bbae693 100644 --- a/web/src/接口/生成/类型.ts +++ b/web/src/接口/生成/类型.ts @@ -7114,6 +7114,11 @@ export interface components { * @default [] */ protected_ranges: components["schemas"]["muse____________________22"][]; + /** + * References + * @default [] + */ + references: string[]; }; /** _定位陈述 */ muse______________________20: {