diff --git a/docs/接口契约/生成/openapi.generated.json b/docs/接口契约/生成/openapi.generated.json index 19f3fde..ebbe115 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -20905,6 +20905,14 @@ "type": "array", "title": "References", "default": [] + }, + "slice_references": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Slice References", + "default": [] } }, "additionalProperties": false, diff --git a/src/muse/上下文/任务范围.py b/src/muse/上下文/任务范围.py index 4d8d068..870834e 100644 --- a/src/muse/上下文/任务范围.py +++ b/src/muse/上下文/任务范围.py @@ -241,6 +241,7 @@ class 上下文服务: 含声音: bool = False, 含偏好: bool = False, 参考版本: tuple[str, ...] = (), + 片段参考: tuple[str, ...] = (), ) -> dict: return 授权任务范围( self.数据库, @@ -256,6 +257,7 @@ class 上下文服务: 含声音=含声音, 含偏好=含偏好, 参考版本=参考版本, + 片段参考=片段参考, ) def 读取范围(self, 任务ID: str) -> dict: diff --git a/src/muse/上下文/依赖校验.py b/src/muse/上下文/依赖校验.py index c553d19..849ceb1 100644 --- a/src/muse/上下文/依赖校验.py +++ b/src/muse/上下文/依赖校验.py @@ -294,15 +294,26 @@ def 核对依赖( 变化项.append(错误.说明) else: for 条 in 参考: - 锁定.append( - 依赖锁定( - 条["version_id"], - "reference", - str(条["version"]), - 条["schema_hash"], - 条["projection_version"], + if 条.get("载体") == "slice": + 锁定.append( + 依赖锁定( + 条["slice_id"], + "reference-slice", + str(条["revision"]), + 条["内容哈希"], + "reference-slice-v1", + ) + ) + else: + 锁定.append( + 依赖锁定( + 条["version_id"], + "reference", + str(条["version"]), + 条["schema_hash"], + 条["projection_version"], + ) ) - ) return 依赖状态(有效=not 变化项, 锁定依据=tuple(锁定), 变化项=tuple(变化项)) diff --git a/src/muse/上下文/参考材料.py b/src/muse/上下文/参考材料.py index c6a2122..2bc269b 100644 --- a/src/muse/上下文/参考材料.py +++ b/src/muse/上下文/参考材料.py @@ -17,6 +17,22 @@ _身份字段 = ( "projection_version", ) +_片段身份字段 = ( + "slice_id", + "source_id", + "revision", + "起点", + "终点", + "材料键", + "内容哈希", + "书名", +) + + +def 冻结片段身份(条: dict) -> dict: + """场景切片冻结身份;内容哈希是来源版本漂移哨兵,材料文本不入范围。""" + return {**{键: 条[键] for 键 in _片段身份字段}, "载体": "slice"} + def 冻结参考身份(条: dict) -> dict: """范围冻结只保留身份与哈希;材料文本不入范围,读取时经B04现取。""" @@ -31,29 +47,71 @@ def 校验参考节(范围: dict) -> None: 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"]) + if not isinstance(条, dict): + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结参考缺少身份") + 载体 = 条.get("载体", "method") + if 载体 == "method": + _校验方法参考(条, 已见) + elif 载体 == "slice": + _校验片段参考(条, 已见) + else: + raise 上下文错误("CONTEXT_SCOPE_INVALID", f"未知参考载体:{载体!r}") + + +def _校验方法参考(条: dict, 已见: set[str]) -> None: + if 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, 已见: set[str]) -> None: + if any( + not isinstance(条.get(键), str) or not 条[键] + for 键 in ("slice_id", "source_id", "材料键", "内容哈希", "书名") + ): + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结片段参考缺少切片或来源身份") + if ( + type(条.get("revision")) is not int + or 条["revision"] < 1 + or type(条.get("起点")) is not int + or 条["起点"] < 0 + or type(条.get("终点")) is not int + or 条["终点"] <= 条["起点"] + ): + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结片段参考定位不合法") + if 条["slice_id"] in 已见 or 条["材料键"] != "参考片段:" + 条["slice_id"]: + raise 上下文错误("CONTEXT_SCOPE_INVALID", "冻结片段参考重复或材料键不匹配") + 已见.add(条["slice_id"]) def 核对范围参考(连, 范围: dict, *, 读取预算=None) -> list[dict]: - """按冻结身份现读当前材料并逐项核对;任一漂移即整体拒绝。""" + """按冻结身份现读当前材料并逐项核对;任一漂移即整体拒绝。两类载体混合处理。""" 校验参考节(范围) 参考 = 范围.get("references") + if not 参考: + return [] + 方法条 = [条 for 条 in 参考 if 条.get("载体", "method") == "method"] + 片段条 = [条 for 条 in 参考 if 条.get("载体") == "slice"] + 结果 = _核对方法参考(连, 范围, 方法条, 读取预算) + 结果.extend(_核对片段参考(连, 范围["authorized_by"], 片段条)) + return 结果 + + +def _核对方法参考(连, 范围, 参考, 读取预算) -> list[dict]: if not 参考: return [] from muse.知识方法.接口 import 方法错误, 读取参考材料 @@ -83,7 +141,48 @@ def 核对范围参考(连, 范围: dict, *, 读取预算=None) -> list[dict]: return 结果 +def _核对片段参考(连, 范围作者, 参考) -> list[dict]: + """场景切片现读:来源仍需 reference_reading 授权且版本内容未漂移。""" + if not 参考: + return [] + from muse.资料研究.接口 import 读取场景切片, 读取来源当前指纹 + + 当前 = { + 条["slice_id"]: 条 for 条 in 读取场景切片(连, 范围作者, [条["slice_id"] for 条 in 参考]) + } + 按来源 = 读取来源当前指纹(连, sorted({条["source_id"] for 条 in 参考})) + 结果 = [] + for 条 in 参考: + 现 = 当前.get(条["slice_id"]) + if 现 is None: + raise 上下文错误("SOURCE_STALE", f"参考片段 {条['slice_id'][:8]} 已退出授权范围") + 来源行 = 按来源.get(条["source_id"]) + if ( + 来源行 is None + or int(来源行["当前版本"]) != 条["revision"] + or 来源行["当前哈希"] != 条["内容哈希"] + ): + raise 上下文错误( + "SOURCE_STALE", f"参考片段来源 {条['书名']} 已推进新版本,参考片段需重新授权" + ) + 材料 = f"【参考原文·{条['书名']}】\n{现['检索文本']}" + from muse.正式变更.接口 import 固定哈希 + + 结果.append( + { + **条, + "材料": 材料, + "材料哈希": 固定哈希([材料]), + "载体": "slice", + } + ) + return 结果 + + def 参考绑定(条: dict) -> 来源绑定: + """方法与片段两类参考条目都构造成快照来源绑定;字段按载体取自冻结身份。""" + if 条.get("载体") == "slice": + return 来源绑定(条["slice_id"], str(条["revision"]), 条["内容哈希"], "reference-slice-v1") return 来源绑定( 条["version_id"], str(条["version"]), 条["schema_hash"], 条["projection_version"] ) diff --git a/src/muse/上下文/场景检索.py b/src/muse/上下文/场景检索.py new file mode 100644 index 0000000..faf5fe2 --- /dev/null +++ b/src/muse/上下文/场景检索.py @@ -0,0 +1,109 @@ +"""场景检索:参考书场景切片的关键词+向量两路等权融合;与方法检索平行。 + +语料为 B03 当前具备 reference_reading 用途来源的切片;授权过滤在查询时 join。 +候选键带 ``slice:`` 前缀,与方法版本身份键空间不混。v1 不接偏好重排。 +""" + +from __future__ import annotations + +from typing import Any + +from muse.上下文.模型 import 上下文错误 +from muse.基础设施.检索.关键词 import 关键词得分 +from muse.基础设施.检索.向量 import 向量得分 +from muse.基础设施.检索.融合 import 倒数排名融合 +from muse.基础设施.模型.嵌入 import 特征哈希嵌入器 + +切片数量上限 = 50 + + +def 读取新鲜切片索引(连) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """代次与索引一致才可用;对不上即拒绝为陈旧,不默重建。""" + from psycopg.rows import dict_row + + 行 = ( + 连.cursor(row_factory=dict_row) + .execute( + "SELECT v.ledger_id, v.索引身份, v.embedder, v.slice_count, g.generation " + "FROM public.muse_source_slice_guard g " + "LEFT JOIN public.muse_source_slice_index_version v ON v.active " + "WHERE g.singleton" + ) + .fetchone() + ) + if 行 is None or 行["ledger_id"] is None: + raise 上下文错误("CONTEXT_INDEX_STALE", "场景切片索引尚未构建") + return dict(行), [] + + +def 场景检索(连, 查询: str, *, 数量: int = 10) -> dict[str, Any]: + """两路等权 RRF;只读,不写任何表。结果带书名与原文预览供知情挑选。""" + from psycopg.rows import dict_row + + if not isinstance(查询, str) or not 查询.strip(): + raise 上下文错误("CONTEXT_INPUT_INVALID", "场景检索需要明确的非空查询") + if type(数量) is not int or not 1 <= 数量 <= 切片数量上限: + raise 上下文错误("CONTEXT_INPUT_INVALID", f"场景检索数量必须在 1 到 {切片数量上限} 之间") + 台账, _ = 读取新鲜切片索引(连) + 匹配 = 台账["embedder"].rsplit("-", 2) + 维度 = int(匹配[1]) if len(匹配) == 3 and 匹配[1].isdigit() else None + if 维度 is None: + raise 上下文错误("CONTEXT_INDEX_STALE", "切片索引嵌入器身份无法解析") + 嵌入器 = 特征哈希嵌入器(维度) + if 嵌入器.身份 != 台账["embedder"]: + raise 上下文错误("CONTEXT_INDEX_STALE", "切片索引嵌入器与当前实现不一致") + 行 = ( + 连.cursor(row_factory=dict_row) + .execute( + "SELECT x.slice_id, x.检索文本, i.vector, s.title, x.revision, x.起点, x.终点, " + "v.content_hash " + "FROM public.muse_source_slice x " + "JOIN public.muse_source_slice_index i ON i.slice_id=x.slice_id " + "JOIN public.muse_source s ON s.source_id=x.source_id " + "JOIN muse_source_version v ON v.source_id=x.source_id AND v.revision=x.revision " + "WHERE s.authorized_uses ? 'reference_reading' AND i.embedder=%s", + (台账["embedder"],), + ) + .fetchall() + ) + if not 行: + return { + "结果": [], + "索引身份": 台账["索引身份"], + "empty_reason": "empty_index", + "说明": "当前没有可检索的场景切片", + } + 键集 = {f"slice:{r['slice_id']}": r for r in 行} + 语料 = {键: r["检索文本"] for 键, r in 键集.items()} + 词分 = 关键词得分(查询, 语料) + 量分 = 向量得分(嵌入器.嵌入(查询), {键: r["vector"] for 键, r in 键集.items()}) + 融合 = 倒数排名融合([词分, 量分], [1.0, 1.0]) + 排名 = sorted(融合.items(), key=lambda kv: -kv[1])[:数量] + 结果 = [] + for 键, 分 in 排名: + r = 键集[键] + 预览 = r["检索文本"][:80] + 结果.append( + { + "slice_id": str(r["slice_id"]), + "key": 键, + "书名": r["title"], + "revision": int(r["revision"]), + "起点": int(r["起点"]), + "终点": int(r["终点"]), + "内容哈希": r["content_hash"], + "预览": 预览, + "关键词得分": 词分.get(键, 0.0), + "向量得分": 量分.get(键, 0.0), + "融合得分": 分, + } + ) + return { + "结果": 结果, + "索引身份": 台账["索引身份"], + "embedder": 台账["embedder"], + "empty_reason": None if 结果 else "no_match", + } + + +__all__ = ["场景检索", "读取新鲜切片索引", "切片数量上限"] diff --git a/src/muse/上下文/接口.py b/src/muse/上下文/接口.py index 596035b..71929b2 100644 --- a/src/muse/上下文/接口.py +++ b/src/muse/上下文/接口.py @@ -19,6 +19,7 @@ from muse.上下文.冻结快照 import ( ) from muse.上下文.参考材料 import 参考绑定, 校验参考节, 核对范围参考 from muse.上下文.回放材料 import 历史版本选择, 回放材料选择, 核对回放字段策略, 读取回放材料 +from muse.上下文.场景检索 import 场景检索 from muse.上下文.声音材料 import 核对范围声音 from muse.上下文.探索回放 import ( 回放, @@ -72,6 +73,7 @@ __all__ = [ "核对回放字段策略", "保护模型发送", "混合检索", + "场景检索", "合并信号表", "权重表", "偏好信号", diff --git a/src/muse/上下文/混合检索.py b/src/muse/上下文/混合检索.py index 97e32fd..9847f51 100644 --- a/src/muse/上下文/混合检索.py +++ b/src/muse/上下文/混合检索.py @@ -21,18 +21,13 @@ from muse.上下文.模型 import 上下文错误 from muse.上下文.索引维护 import 索引字节上限, 索引数量上限, 读取新鲜索引 from muse.基础设施.检索.关键词 import 关键词得分 from muse.基础设施.检索.向量 import 向量得分 +from muse.基础设施.检索.融合 import 倒数排名融合 from muse.基础设施.模型.嵌入 import 嵌入器 from muse.知识方法.接口 import 方法错误, 读取方法检索候选, 读取方法检索身份, 读取生效绑定 def _rrf(得分表列: list[dict[str, float]], 权重: list[float]) -> dict[str, float]: - 融合: dict[str, float] = {} - 常数 = 60.0 - for 得分表, 权 in zip(得分表列, 权重, strict=True): - 排名 = sorted(得分表.items(), key=lambda kv: -kv[1]) - for 位次, (键, _) in enumerate(排名): - 融合[键] = 融合.get(键, 0.0) + 权 / (常数 + 位次 + 1) - return 融合 + return 倒数排名融合(得分表列, 权重) def _偏好呈现(信号: 偏好信号 | None) -> dict[str, Any]: diff --git a/src/muse/上下文/范围授权.py b/src/muse/上下文/范围授权.py index 4178240..022b838 100644 --- a/src/muse/上下文/范围授权.py +++ b/src/muse/上下文/范围授权.py @@ -52,14 +52,19 @@ def 授权任务范围( 含声音: bool = False, 含偏好: bool = False, 参考版本: tuple[str, ...] = (), + 片段参考: 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 isinstance(片段参考, str) or not isinstance(片段参考, tuple): + raise 上下文错误("CONTEXT_SCOPE_INVALID", f"片段参考必须以元组显式声明:{片段参考!r}") + if len(参考版本) + len(片段参考) > 8: + raise 上下文错误("CONTEXT_SCOPE_INVALID", "参考总数(方法加片段)不超过8个") + if len(set(片段参考)) != len(片段参考): + raise 上下文错误("CONTEXT_SCOPE_INVALID", "片段参考不能重复") if 身份.作者 is None or 身份.用途 is not 数据库.用途: raise 上下文错误("SCOPE_DENIED", "任务范围授权需要与装配用途一致的作者身份") 校验截止(截止) @@ -136,7 +141,7 @@ def 授权任务范围( from muse.上下文.参考材料 import 冻结参考身份 from muse.知识方法.接口 import 读取参考材料 - 参考 = [ + 参考.extend( 冻结参考身份(条) for 条 in 读取参考材料( 连, @@ -146,10 +151,24 @@ def 授权任务范围( 运行用途=运行用途, 读取预算=用量.预留来源, ) - ] + ) + if 片段参考: + from muse.上下文.参考材料 import 冻结片段身份 + from muse.资料研究.接口 import 读取场景切片 + + 切片行 = 读取场景切片(连, 身份.作者, list(片段参考)) + 按身份 = {条["slice_id"]: 条 for 条 in 切片行} + for 身份键 in 片段参考: + 现 = 按身份.get(身份键) + if 现 is None: + raise 上下文错误("SOURCE_STALE", f"参考片段 {身份键[:8]} 不存在或未授权") + 参考.extend( + 冻结片段身份({**现, "材料键": "参考片段:" + 现["slice_id"]}) + for 现 in (按身份[键] for 键 in 片段参考) + ) 来源集 = [d["document_id"] for d in 文稿] 来源集.extend(x["preference_id"] for x in 偏好) - 来源集.extend(条["version_id"] for 条 in 参考) + 来源集.extend(条.get("version_id") or 条["slice_id"] for 条 in 参考) if 声音 is not None: 来源集.append(声音["version_id"]) 来源集.extend(条["binding_id"] for 条 in 方法) diff --git a/src/muse/上下文/范围校验.py b/src/muse/上下文/范围校验.py index 89ea035..4dbe464 100644 --- a/src/muse/上下文/范围校验.py +++ b/src/muse/上下文/范围校验.py @@ -78,23 +78,31 @@ def _校验冻结输入( 条["projection_version"], ): raise 上下文错误("SOURCE_STALE", "方法版本或材料哈希与授权不一致") - 参考集 = {条["version_id"]: 条 for 条 in 范围.get("references", [])} + 参考集 = {(条.get("version_id") or 条["slice_id"]): 条 for 条 in 范围.get("references", [])} + 方法参考 = {键: 条 for 键, 条 in 参考集.items() if 条.get("载体", "method") == "method"} + 片段参考 = {键: 条 for 键, 条 in 参考集.items() if 条.get("载体") == "slice"} 参考键 = {条["材料键"] for 条 in 参考集.values()} if any(键.startswith("参考方法:") and 键 not in 参考键 for 键 in 装配.入选来源): raise 上下文错误("SCOPE_DENIED", "装配包含未授权参考方法材料") + 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 绑.来源ID in 方法参考: + 条 = 方法参考[绑.来源ID] if (绑.数据版本, 绑.结构哈希, 绑.投影版本) != ( str(条["version"]), 条["schema_hash"], 条["projection_version"], ): raise 上下文错误("SOURCE_STALE", "参考版本或材料哈希与授权不一致") + if 绑.来源ID in 片段参考: + 条 = 片段参考[绑.来源ID] + if 绑.结构哈希 != 条["内容哈希"] or 绑.数据版本 != str(条["revision"]): + raise 上下文错误("SOURCE_STALE", "参考片段来源版本与授权不一致") 目录源 = 范围.get("directory_source") if 目录源: for 项 in 绑定: @@ -220,7 +228,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", [])) + 基础.update(x.get("version_id") or x["slice_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/基础设施/检索/融合.py b/src/muse/基础设施/检索/融合.py new file mode 100644 index 0000000..65cd912 --- /dev/null +++ b/src/muse/基础设施/检索/融合.py @@ -0,0 +1,16 @@ +"""倒数排名融合(RRF):多路得分表的加权融合;纯函数。""" + +from __future__ import annotations + + +def 倒数排名融合(得分表列: list[dict[str, float]], 权重: list[float]) -> dict[str, float]: + 融合: dict[str, float] = {} + 常数 = 60.0 + for 得分表, 权 in zip(得分表列, 权重, strict=True): + 排名 = sorted(得分表.items(), key=lambda kv: -kv[1]) + for 位次, (键, _) in enumerate(排名): + 融合[键] = 融合.get(键, 0.0) + 权 / (常数 + 位次 + 1) + return 融合 + + +__all__ = ["倒数排名融合"] diff --git a/src/muse/正文写作/选段任务.py b/src/muse/正文写作/选段任务.py index 7de2c01..e4d8e9f 100644 --- a/src/muse/正文写作/选段任务.py +++ b/src/muse/正文写作/选段任务.py @@ -88,6 +88,7 @@ class 选段写作授权: selections: tuple[原文选段, ...] protected_ranges: tuple[原文选段, ...] = () references: tuple[str, ...] = () + slice_references: tuple[str, ...] = () def __post_init__(self): if self.revision < 1 or not self.purpose.strip() or not 1 <= len(self.selections) <= 32: @@ -96,12 +97,16 @@ 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(self.references) + len(self.slice_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 正文错误("参考方法需要非空版本身份") + if len(set(self.slice_references)) != len(self.slice_references) or not all( + type(r) is str and r.strip() for r in self.slice_references + ): + raise 正文错误("片段参考需非空切片身份且不重复") def 准备选段改写(连, 作者, 授权: 选段写作授权): diff --git a/src/muse/编排/审校修订.py b/src/muse/编排/审校修订.py index ae9d9ad..40cc0b7 100644 --- a/src/muse/编排/审校修订.py +++ b/src/muse/编排/审校修订.py @@ -113,6 +113,7 @@ def 发起模型修订(app, 身份, 命令ID, 授权: 模型修订授权 | 选 含声音=True, 含偏好=True, 参考版本=tuple(授权.references) if isinstance(授权, 选段写作授权) else (), + 片段参考=tuple(授权.slice_references) if isinstance(授权, 选段写作授权) else (), ) 策略 = 角色策略目录.从发布包() request = 任务请求( @@ -211,10 +212,11 @@ def 登记审校修订(登记, app, 计价): snap = 上下文.冻结上下文(ctx.任务.任务ID, 组装, tuple(bindings)) # 只把装配入选的参考写入检查点:供真实交付后登记方法消费(kind=review)。 # 未入选参考未进入模型输入,不登记消费;旧任务检查点无该键,向后兼容为空。 + # 只收方法载体:片段参考不是方法版本,不登记方法消费(见 B06 契约)。 入选参考 = [ {"version_id": 条["version_id"], "材料哈希": 条["材料哈希"]} for 条 in 参考材料 - if 条["材料键"] in 组装.入选来源 + if 条.get("载体", "method") == "method" and 条["材料键"] in 组装.入选来源 ] return 步骤结果( {"snapshot_id": snap.快照ID}, diff --git a/src/muse/资料研究/场景切片.py b/src/muse/资料研究/场景切片.py index d3e0a77..73e9d22 100644 --- a/src/muse/资料研究/场景切片.py +++ b/src/muse/资料研究/场景切片.py @@ -183,7 +183,7 @@ def 读取场景切片(连, 作者: str, slice_ids: list[str]) -> list[dict[str, 资料存储(连) .查询( "SELECT x.slice_id, x.source_id, x.revision, x.起点, x.终点, x.检索文本, " - "v.content_hash, s.title, s.authorized_uses " + "v.content_hash, v.import_result, s.title, s.authorized_uses " "FROM muse_source_slice x " "JOIN muse_source_version v ON v.source_id=x.source_id AND v.revision=x.revision " "JOIN muse_source s ON s.source_id=x.source_id " @@ -205,10 +205,35 @@ def 读取场景切片(连, 作者: str, slice_ids: list[str]) -> list[dict[str, "用途": 条["authorized_uses"], } for 条 in 行 - if 条["authorized_uses"] is not None and "reference_reading" in 条["authorized_uses"] + if 条["authorized_uses"] is not None + and "reference_reading" in 条["authorized_uses"] + and (条["import_result"] or {}).get("导入者") == 作者 ] +def 读取来源当前指纹(连, source_ids: list[str]) -> dict[str, dict[str, Any]]: + """来源→当前版本号与内容哈希;供参考链路的版本漂移哨兵。""" + if not source_ids: + return {} + 占位 = ",".join(["%s"] * len(source_ids)) + 行 = ( + 资料存储(连) + .查询( + "SELECT s.source_id, " + "(SELECT MAX(v.revision) FROM muse_source_version v " + "WHERE v.source_id=s.source_id) AS 当前版本, " + "(SELECT v2.content_hash FROM muse_source_version v2 WHERE v2.source_id=s.source_id " + "ORDER BY v2.revision DESC LIMIT 1) AS 当前哈希 " + f"FROM muse_source s WHERE s.source_id IN ({占位})", + tuple(source_ids), + ) + .fetchall() + ) + return { + str(行["source_id"]): {"当前版本": 行["当前版本"], "当前哈希": 行["当前哈希"]} for 行 in 行 + } + + __all__ = [ "目标窗长", "最短窗长", @@ -218,4 +243,5 @@ __all__ = [ "计算切片", "构建场景索引", "读取场景切片", + "读取来源当前指纹", ] diff --git a/src/muse/资料研究/接口.py b/src/muse/资料研究/接口.py index eb03d49..17f2ebc 100644 --- a/src/muse/资料研究/接口.py +++ b/src/muse/资料研究/接口.py @@ -11,7 +11,7 @@ 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 执行窗口提取 @@ -238,4 +238,5 @@ __all__ = [ "构建场景索引", "计算切片", "读取场景切片", + "读取来源当前指纹", ] diff --git a/tests/用例清单.json b/tests/用例清单.json index 35ff3ac..d1f0c46 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -27745,6 +27745,75 @@ "数据库" ] }, + { + "case_id": "NC-w32-a10035", + "environment": "隔离PG与合成HTTP提供方", + "given": "带片段参考的选段改写授权且候选真实交付", + "when": "发起并执行受控修订流程", + "then": [ + "片段原文材料入选装配并绑定进快照,材料键带参考片段前缀", + "写手输入携带片段材料;不登记方法消费(片段非方法版本)" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/集成/test_参考改写.py", + "symbol": "test_片段参考材料进快照__a10035", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_参考改写.py::test_片段参考材料进快照__a10035" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "参考环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "片段环境", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, + { + "case_id": "NC-w32-a10036", + "environment": "隔离PG与合成HTTP提供方", + "given": "已冻结的片段参考改写任务", + "when": "参考书原文推进新版本(内容哈希漂移)", + "then": [ + "快照标注失效且要求重新授权" + ], + "contract": "docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", + "file": "tests/集成/test_参考改写.py", + "symbol": "test_片段来源漂移使快照失效__a10036", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_参考改写.py::test_片段来源漂移使快照失效__a10036" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "参考环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "片段环境", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, { "case_id": "NC-w33-a10025", "environment": "离线纯函数", @@ -27938,6 +28007,104 @@ "数据库" ] }, + { + "case_id": "NC-w33-a10032", + "environment": "隔离PG与生成环境", + "given": "已构建的授权来源切片索引", + "when": "以场景相关查询执行场景检索", + "then": [ + "两路融合返回切片:带书名、预览与双路得分;授权来源独占结果" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#场景片段检索", + "file": "tests/集成/test_场景切片.py", + "symbol": "test_场景检索返回知情候选__a10032", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_场景切片.py::test_场景检索返回知情候选__a10032" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "切片环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, + { + "case_id": "NC-w33-a10033", + "environment": "隔离PG与生成环境", + "given": "尚未构建切片索引的库", + "when": "执行场景检索", + "then": [ + "拒绝为索引陈旧,不静默返回空结果" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#场景片段检索", + "file": "tests/集成/test_场景切片.py", + "symbol": "test_未建索引拒绝__a10033", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_场景切片.py::test_未建索引拒绝__a10033" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, + { + "case_id": "NC-w33-a10034", + "environment": "隔离PG与生成环境", + "given": "授权来源用途被撤回后的切片索引", + "when": "执行场景检索", + "then": [ + "结果为空且说明可读,不扩大为索引不可用" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#场景片段检索", + "file": "tests/集成/test_场景切片.py", + "symbol": "test_用途撤回后检索为空__a10034", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_场景切片.py::test_用途撤回后检索为空__a10034" + ], + "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 index 52ea335..90c1238 100644 --- a/tests/集成/test_参考改写.py +++ b/tests/集成/test_参考改写.py @@ -268,3 +268,113 @@ def test_无参考修订不登记消费__a10024(参考环境): "SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (tid,) ).fetchone()[0] assert count == 0 + + +@pytest.fixture +def 片段环境(参考环境): + env = 参考环境 + from muse.上下文.接口 import 场景检索 + from muse.资料研究.接口 import 构建场景索引, 资料服务 + + with env["装配"].要求数据库().连接() as 连: + 资料服务().导入( + 连, + env["作者"].作者, + 导入请求( + kind="reference", + title="打斗场景参考书", + origin="seed://slice-rewrite", + content="机甲的刀锋嗡鸣着划过装甲。\n火花在真空中凝成细线。\n" * 300, + authorized_uses=("reference_reading",), + ), + ) + 构建场景索引(连, env["作者"].作者) + env["片段"] = 场景检索(连, "刀锋划过装甲", 数量=1)["结果"][0] + env["授权"] = replace(env["授权"], references=(), slice_references=(env["片段"]["slice_id"],)) + return env + + +@pytest.mark.case_id( + "NC-w32-a10035", + environment="隔离PG与合成HTTP提供方", + given="带片段参考的选段改写授权且候选真实交付", + when="发起并执行受控修订流程", + then=[ + "片段原文材料入选装配并绑定进快照,材料键带参考片段前缀", + "写手输入携带片段材料;不登记方法消费(片段非方法版本)", + ], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_片段参考材料进快照__a10035(片段环境): + env = 片段环境 + tid = 修订测试.发起(env, command="slice-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 + 键 = "参考片段:" + env["片段"]["slice_id"] + assert 键 in 快照.装配["入选"] + assert any( + str(绑.来源ID) == env["片段"]["slice_id"] and 绑.结构哈希 == env["片段"]["内容哈希"] + for 绑 in 快照.来源绑定 + ) + # 片段不是方法版本:不登记方法消费。 + with env["装配"].要求数据库().连接(只读=True) as 连: + count = 连.execute( + "SELECT COUNT(*) FROM muse_method_usage WHERE task_id=%s", (tid,) + ).fetchone()[0] + assert count == 0 + + +@pytest.mark.case_id( + "NC-w32-a10036", + environment="隔离PG与合成HTTP提供方", + given="已冻结的片段参考改写任务", + when="参考书原文推进新版本(内容哈希漂移)", + then=["快照标注失效且要求重新授权"], + contract="docs/系统架构/新版设计/模块设计/B06-审校修订.md#参考改写选段改写链路扩展", +) +def test_片段来源漂移使快照失效__a10036(片段环境): + env = 片段环境 + tid = 修订测试.发起(env, command="slice-revision-drift") + svc = env["装配"].任务运行 + claim = svc.领取步骤("revision-worker", ["revision.freeze"]) + assert claim is not None + svc.执行一步(claim) + 快照ID = _快照ID(svc.读取任务(tid)) + + from muse.资料研究.接口 import 资料服务 + + with env["装配"].要求数据库().连接() as 连: + 资料服务().导入( + 连, + env["作者"].作者, + 导入请求( + kind="reference", + title="打斗场景参考书", + origin="seed://slice-rewrite", + content="改写后的新版内容。\n装甲裂缝里的火花。\n" * 300, + authorized_uses=("reference_reading",), + ), + ) + 快照 = env["装配"].要求上下文().读取快照(快照ID) + assert 快照.失效说明 and "参考片段" in 快照.失效说明 + assert "需重新授权" in 快照.失效说明 diff --git a/tests/集成/test_场景切片.py b/tests/集成/test_场景切片.py index 15aefac..232707c 100644 --- a/tests/集成/test_场景切片.py +++ b/tests/集成/test_场景切片.py @@ -118,3 +118,68 @@ def test_非导入者来源不入池__a10031(切片环境): 回执 = 构建场景索引(连, "other-author") assert 回执["来源数"] == 0 and 回执["切片数"] == 0 assert 计算切片("有字。\n", env["授权来源"], 1) + + +@pytest.mark.case_id( + "NC-w33-a10032", + environment="隔离PG与生成环境", + given="已构建的授权来源切片索引", + when="以场景相关查询执行场景检索", + then=["两路融合返回切片:带书名、预览与双路得分;授权来源独占结果"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#场景片段检索", +) +def test_场景检索返回知情候选__a10032(切片环境): + env = 切片环境 + 库 = env["装配"].要求数据库() + from muse.上下文.接口 import 场景检索 + + with 库.连接() as 连: + 构建场景索引(连, env["作者"].作者) + 回执 = 场景检索(连, "刀锋擦过装甲 反震", 数量=3) + assert 回执["empty_reason"] is None + assert 1 <= len(回执["结果"]) <= 3 + 首 = 回执["结果"][0] + assert 首["书名"] == "机甲打斗参考" + assert "刀锋" in 首["预览"] + assert "关键词得分" in 首 and "向量得分" in 首 and 首["融合得分"] > 0 + + +@pytest.mark.case_id( + "NC-w33-a10033", + environment="隔离PG与生成环境", + given="尚未构建切片索引的库", + when="执行场景检索", + then=["拒绝为索引陈旧,不静默返回空结果"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#场景片段检索", +) +def test_未建索引拒绝__a10033(生成环境): + env = 生成环境 + from muse.上下文.接口 import 场景检索 + from muse.上下文.模型 import 上下文错误 + + with env["装配"].要求数据库().连接(只读=True) as 连: + with pytest.raises(上下文错误): + 场景检索(连, "任意查询") + + +@pytest.mark.case_id( + "NC-w33-a10034", + environment="隔离PG与生成环境", + given="授权来源用途被撤回后的切片索引", + when="执行场景检索", + then=["结果为空且说明可读,不扩大为索引不可用"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#场景片段检索", +) +def test_用途撤回后检索为空__a10034(切片环境): + env = 切片环境 + 库 = env["装配"].要求数据库() + from muse.上下文.接口 import 场景检索 + + with 库.连接() as 连: + 构建场景索引(连, env["作者"].作者) + 连.execute( + "UPDATE muse_source SET authorized_uses='[]'::jsonb WHERE source_id=%s", + (env["授权来源"],), + ) + 回执 = 场景检索(连, "刀锋", 数量=3) + assert 回执["结果"] == [] diff --git a/web/src/功能/正文写作/选段改写.tsx b/web/src/功能/正文写作/选段改写.tsx index 6e3b49b..4780214 100644 --- a/web/src/功能/正文写作/选段改写.tsx +++ b/web/src/功能/正文写作/选段改写.tsx @@ -236,6 +236,7 @@ export function Ui选段改写({ selections, protected_ranges, references: refSelected, + slice_references: [], }, }; } diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts index c6d81f3..07da840 100644 --- a/web/src/接口/生成/类型.ts +++ b/web/src/接口/生成/类型.ts @@ -7153,6 +7153,11 @@ export interface components { * @default [] */ references: string[]; + /** + * Slice References + * @default [] + */ + slice_references: string[]; }; /** _定位陈述 */ muse______________________20: {