From f0a61286df8076452677a4e34c2ad9880e030792 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 19 Sep 2026 00:49:52 +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=A12=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=B7=B7=E5=90=88=E6=A3=80=E7=B4=A2=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=E9=87=8D=E6=8E=92=EF=BC=9A=E4=B8=89=E4=BF=A1=E5=8F=B7=E5=88=86?= =?UTF-8?q?=E5=88=97=E3=80=81=E7=89=88=E6=9C=AC=E5=8C=96=E6=9D=83=E9=87=8D?= =?UTF-8?q?=E3=80=81=E6=9C=89=E7=95=8C=E6=83=A9=E7=BD=9A=E4=B8=8E=E6=8E=A2?= =?UTF-8?q?=E7=B4=A2=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/接口契约/生成/openapi.generated.json | 16 +++ src/muse/上下文/偏好重排.py | 129 ++++++++++++++++++++++ src/muse/上下文/接口.py | 4 + src/muse/上下文/混合检索.py | 57 +++++++++- src/muse/作者经验/模型.py | 2 + src/muse/接入/http/路由/知识方法.py | 5 +- src/muse/知识方法/存储.py | 11 ++ src/muse/知识方法/接口.py | 22 +++- src/muse/知识方法/消费记录.py | 11 +- tests/单元/test_偏好重排.py | 105 ++++++++++++++++++ tests/用例清单.json | 124 +++++++++++++++++++++ tests/集成/test_检索偏好重排.py | 70 ++++++++++++ web/src/功能/正文写作/选段改写.tsx | 18 ++- web/src/接口/生成/类型.ts | 6 + 14 files changed, 570 insertions(+), 10 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 959abf3..fd2a07b 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -5740,6 +5740,17 @@ "default": 10, "title": "Limit" } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 100, + "default": "", + "title": "Domain" + } } ], "responses": { @@ -21307,6 +21318,11 @@ "title": "Index Generation", "default": 0 }, + "weight_version": { + "type": "string", + "title": "Weight Version", + "default": "" + }, "种类": { "type": "string", "title": "种类", diff --git a/src/muse/上下文/偏好重排.py b/src/muse/上下文/偏好重排.py new file mode 100644 index 0000000..9807d75 --- /dev/null +++ b/src/muse/上下文/偏好重排.py @@ -0,0 +1,129 @@ +"""偏好重排:挑选/消费信号 + 版本化权重 → 混合检索的偏好路;纯函数,可回放可审计。 + +合同见模块设计/B09-上下文.md「偏好重排通路」: +- 弱强信号分列计数,不混算;"种类→权重"映射由版本化权重表承担。 +- 偏好路相对关键词/向量双路(各 1.0)初始权重保守(0.25)。 +- 正偏好进第三 RRF 路;负偏好按有界惩罚从融合分中扣减, + 惩罚量级不超过偏好路末位贡献,保证弱信号不能反转强相关。 +- 保留探索位:未被探索过的高相关版本注入候选,对抗熟悉度泡沫。 +- v1 不做权重自动调参:权重表只经人工审查的版本号变更。 +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +权重表: dict[str, Any] = { + "version": "preference-rerank-v1", + "种类权重": {"选用": 0.05, "拒绝": -0.08, "消费": 0.10}, + "路权重": 0.25, + "探索位": 2, +} + + +@dataclass(frozen=True, slots=True) +class 偏好信号: + """单个方法版本的信号聚合;弱信号(挑选)与强信号(消费)分列。""" + + 选用次数: int = 0 + 拒绝次数: int = 0 + 消费次数: int = 0 + 选中位次: tuple[int, ...] = () + + +def 合并信号表(挑选信号: list[dict], 消费统计: Mapping[str, int]) -> dict[str, 偏好信号]: + """把 B07 挑选信号行与 B04 消费统计合并为版本→信号表;纯函数。""" + 表: dict[str, 偏好信号] = {} + for 行 in 挑选信号: + vid = str(行["method_version_id"]) + 表[vid] = 偏好信号( + 选用次数=int(行.get("选用次数", 0)), + 拒绝次数=int(行.get("拒绝次数", 0)), + 选中位次=tuple(行.get("选中位次", ())), + ) + for vid, 次数 in 消费统计.items(): + 既有 = 表.get(str(vid)) + if 既有 is None: + 表[str(vid)] = 偏好信号(消费次数=int(次数)) + else: + 表[str(vid)] = 偏好信号( + 选用次数=既有.选用次数, + 拒绝次数=既有.拒绝次数, + 消费次数=int(次数), + 选中位次=既有.选中位次, + ) + return 表 + + +def 偏好值(信号: 偏好信号, *, 权重: Mapping[str, Any] = 权重表) -> float: + 种 = 权重["种类权重"] + return 信号.选用次数 * 种["选用"] + 信号.拒绝次数 * 种["拒绝"] + 信号.消费次数 * 种["消费"] + + +def 构建偏好路(信号表: Mapping[str, 偏好信号]) -> dict[str, float]: + """只有正偏好进 RRF 第三路;无信号或负值不占路。""" + return {vid: 偏好值(信) for vid, 信 in 信号表.items() if 偏好值(信) > 0} + + +def 应用拒绝惩罚( + 融合: dict[str, float], 信号表: Mapping[str, 偏好信号], *, 权重: Mapping[str, Any] = 权重表 +) -> dict[str, float]: + """负偏好按有界值从融合分扣减;上界为偏好路末位贡献(路权重/61)。""" + 路 = float(权重["路权重"]) + 结果 = dict(融合) + for vid, 信 in 信号表.items(): + 值 = 偏好值(信, 权重=权重) + if 值 < 0 and vid in 结果: + 结果[vid] -= 路 * (-值 / (1 + -值)) / 61.0 + return 结果 + + +def 注入探索位( + 排名: list[tuple[str, float]], + 信号表: Mapping[str, 偏好信号], + 数量: int, + *, + 探索位数: int | None = None, +) -> tuple[list[tuple[str, float]], frozenset[str]]: + """保证头部结果含至多 N 个未探索版本;返回新排名与探索位版本集。 + + 未探索 = 无任何挑选或消费信号。头部已够则不动;不足时从尾部按分 + 取未探索候选,替换头部末尾的已探索项;确定性、不改分数。 + """ + 位数 = 权重表["探索位"] if 探索位数 is None else 探索位数 + if 位数 <= 0 or 数量 <= 0 or not 排名: + return 排名, frozenset() + + def 未探索(vid: str) -> bool: + return vid not in 信号表 + + 头部, 尾部 = 排名[:数量], 排名[数量:] + 已有 = sum(1 for vid, _ in 头部 if 未探索(vid)) + if 已有 >= min(位数, len(头部)): + return 排名, frozenset(vid for vid, _ in 头部 if 未探索(vid)) + 候选 = [(vid, 分) for vid, 分 in 尾部 if 未探索(vid)][: 位数 - 已有] + if not 候选: + return 排名, frozenset(vid for vid, _ in 头部 if 未探索(vid)) + 结果: list[tuple[str, float]] = [] + 待换 = len(候选) + for 项 in reversed(头部): + if 待换 > 0 and not 未探索(项[0]): + 待换 -= 1 + continue + 结果.append(项) + 结果.reverse() + 结果.extend(候选) + return 结果, frozenset(vid for vid, _ in 结果 if 未探索(vid)) + + +__all__ = [ + "权重表", + "偏好信号", + "合并信号表", + "偏好值", + "构建偏好路", + "应用拒绝惩罚", + "注入探索位", +] diff --git a/src/muse/上下文/接口.py b/src/muse/上下文/接口.py index 825a1be..596035b 100644 --- a/src/muse/上下文/接口.py +++ b/src/muse/上下文/接口.py @@ -10,6 +10,7 @@ from muse.上下文.任务范围 import ( ) from muse.上下文.依赖校验 import 依赖重验检查, 核对依赖 from muse.上下文.偏好材料 import 偏好绑定, 核对范围偏好 +from muse.上下文.偏好重排 import 偏好信号, 合并信号表, 权重表 from muse.上下文.冻结快照 import ( 快照身份, 快照载荷, @@ -71,6 +72,9 @@ __all__ = [ "核对回放字段策略", "保护模型发送", "混合检索", + "合并信号表", + "权重表", + "偏好信号", "重建方法索引", "方法索引操作", "登记方法索引参与者", diff --git a/src/muse/上下文/混合检索.py b/src/muse/上下文/混合检索.py index 826d6da..97e32fd 100644 --- a/src/muse/上下文/混合检索.py +++ b/src/muse/上下文/混合检索.py @@ -6,8 +6,17 @@ work_id 只用于绑定过滤提示,不进入事实存储。 from __future__ import annotations +from collections.abc import Mapping from typing import Any +from muse.上下文.偏好重排 import ( + 偏好信号, + 偏好值, + 应用拒绝惩罚, + 权重表, + 构建偏好路, + 注入探索位, +) from muse.上下文.模型 import 上下文错误 from muse.上下文.索引维护 import 索引字节上限, 索引数量上限, 读取新鲜索引 from muse.基础设施.检索.关键词 import 关键词得分 @@ -26,10 +35,33 @@ def _rrf(得分表列: list[dict[str, float]], 权重: list[float]) -> dict[str, return 融合 +def _偏好呈现(信号: 偏好信号 | None) -> dict[str, Any]: + """单候选的偏好分项;信号快照随回执留痕,支撑重排可审计。""" + if 信号 is None: + return {"选用次数": 0, "拒绝次数": 0, "消费次数": 0, "偏好值": 0.0} + return { + "选用次数": 信号.选用次数, + "拒绝次数": 信号.拒绝次数, + "消费次数": 信号.消费次数, + "偏好值": round(偏好值(信号), 6), + } + + def 混合检索( - 连, 查询: str, 嵌入器: 嵌入器, *, work_id: str = "", 数量: int = 10, 作者: str | None = None + 连, + 查询: str, + 嵌入器: 嵌入器, + *, + work_id: str = "", + 数量: int = 10, + 作者: str | None = None, + 偏好信号表: Mapping[str, 偏好信号] | None = None, ) -> dict[str, Any]: - """整体字节损坏拒绝;有界候选的当前失效单独呈现,不扩大为全库不可用。""" + """整体字节损坏拒绝;有界候选的当前失效单独呈现,不扩大为全库不可用。 + + 偏好信号表为空时不启用重排,回执与双路等权融合完全一致; + 启用时正偏好进第三 RRF 路、负偏好有界惩罚,并保留探索位。 + """ if not isinstance(查询, str) or not 查询.strip(): raise 上下文错误("CONTEXT_INPUT_INVALID", "方法检索需要明确的非空查询") @@ -63,10 +95,22 @@ def 混合检索( 向量 = {str(r["method_version_id"]): r["vector"] for r in 行} 量分 = 向量得分(嵌入器.嵌入(查询), 向量) 融合 = _rrf([词分, 量分], [1.0, 1.0]) + 重排启用 = bool(偏好信号表) + 探索集: frozenset[str] = frozenset() + if 重排启用 and 偏好信号表 is not None: + 偏好路 = 构建偏好路(偏好信号表) + 得分表列: list[dict[str, float]] = [词分, 量分] + 路权重: list[float] = [1.0, 1.0] + if 偏好路: + 得分表列.append(偏好路) + 路权重.append(float(权重表["路权重"])) + 融合 = 应用拒绝惩罚(_rrf(得分表列, 路权重), 偏好信号表) 绑定集 = set() if work_id: 绑定集 = {str(b["version_id"]) for b in 读取生效绑定(连, work_id)} 排名 = sorted(融合.items(), key=lambda kv: (-kv[1], kv[0])) + if 重排启用 and 偏好信号表 is not None: + 排名, 探索集 = 注入探索位(排名, 偏好信号表, 数量) 索引行 = {str(条["method_version_id"]): 条 for 条 in 行} 来源缓存: dict = {} 剩余字节 = 索引字节上限 @@ -109,6 +153,14 @@ def 混合检索( "向量得分": round(量分.get(version_id, 0.0), 4), "已绑定本书": str(version_id) in 绑定集, "来源回读": 版本["来源回读"], + **( + { + "探索位": str(version_id) in 探索集, + "偏好": _偏好呈现(偏好信号表.get(str(version_id)) if 偏好信号表 else None), + } + if 重排启用 + else {} + ), } ) if len(结果) == 数量: @@ -132,6 +184,7 @@ def 混合检索( else ("" if 结果 else "方法检索没有命中可授权卡"), "empty_reason": None if 结果 else ("index_pending" if 待更新 else "no_match"), **状态, + **({"重排权重版本": 权重表["version"]} if 重排启用 else {}), } diff --git a/src/muse/作者经验/模型.py b/src/muse/作者经验/模型.py index a5a735f..90fdac5 100644 --- a/src/muse/作者经验/模型.py +++ b/src/muse/作者经验/模型.py @@ -159,6 +159,7 @@ class 参考挑选反馈请求: work_id: str = "" quote: str = "" index_generation: int = 0 + weight_version: str = "" 种类: str = "参考挑选" def __post_init__(self): @@ -181,6 +182,7 @@ class 参考挑选反馈请求: type(self.index_generation) is int and self.index_generation >= 0, "索引代次须为非负整数", ) + _需要(len(self.weight_version) <= 64, "重排权重版本标识不超64字") _需要(len(self.quote) <= 2000, "作者原话不超2000字") diff --git a/src/muse/接入/http/路由/知识方法.py b/src/muse/接入/http/路由/知识方法.py index 7824697..d44913e 100644 --- a/src/muse/接入/http/路由/知识方法.py +++ b/src/muse/接入/http/路由/知识方法.py @@ -182,9 +182,12 @@ def 检索方法( q: str = Query(min_length=1), work_id: str = Query(default=""), limit: int = Query(default=10, ge=1, le=50), + domain: str = Query(default="", max_length=100), ): 装配 = request.app.state.装配 - return 装配.要求知识方法().检索方法(创作身份(作者, 装配.配置), q, work_id=work_id, 数量=limit) + return 装配.要求知识方法().检索方法( + 创作身份(作者, 装配.配置), q, work_id=work_id, 数量=limit, 领域=domain + ) @路由.post("/methods/reindex", operation_id="rebuild_method_index") diff --git a/src/muse/知识方法/存储.py b/src/muse/知识方法/存储.py index 6322edc..4e52be4 100644 --- a/src/muse/知识方法/存储.py +++ b/src/muse/知识方法/存储.py @@ -422,5 +422,16 @@ class 方法存储: (method_version_id, 数量), ).fetchall() + def 统计消费( + self, 作者: str, *, 种类: tuple[str, ...] = ("generation", "review", "planning") + ) -> dict[str, int]: + """按方法版本聚合真实创作消费计数;启用验证消费不算偏好信号。""" + 行 = self._查( + "SELECT method_version_id::text AS vid, count(*) AS n FROM muse_method_usage " + "WHERE created_by=%s AND kind = ANY(%s) GROUP BY method_version_id", + (作者, list(种类)), + ).fetchall() + return {r["vid"]: int(r["n"]) for r in 行} + __all__ = ["方法存储"] diff --git a/src/muse/知识方法/接口.py b/src/muse/知识方法/接口.py index 4290a4a..7055b36 100644 --- a/src/muse/知识方法/接口.py +++ b/src/muse/知识方法/接口.py @@ -35,7 +35,7 @@ from muse.知识方法.模型 import ( 绑定命令, 证据引用, ) -from muse.知识方法.消费记录 import 记录消费 +from muse.知识方法.消费记录 import 记录消费, 读取消费统计 from muse.知识方法.绑定身份 import 编码绑定身份 from muse.知识方法.评测材料 import 核对方法评测投影, 读取方法评测材料 @@ -261,15 +261,28 @@ class 知识方法服务: # ---- 检索与索引 ---- - def 检索方法(self, 身份, 查询: str, *, work_id: str = "", 数量: int = 10) -> dict: + def 检索方法( + self, 身份, 查询: str, *, work_id: str = "", 数量: int = 10, 领域: str = "" + ) -> dict: self._核对作者(身份) - from muse.上下文.接口 import 混合检索 + from muse.上下文.接口 import 合并信号表, 混合检索 from muse.作品规划.接口 import 读取作品范围 + from muse.作者经验.接口 import 读取挑选信号 with self.数据库.连接(只读=True) as 连: if work_id: 读取作品范围(连, 身份.作者, work_id) - return 混合检索(连, 查询, 特征哈希嵌入器(), work_id=work_id, 数量=数量, 作者=身份.作者) + # 偏好信号经公开接口取得:弱信号来自 B07 挑选台账,强信号来自本模块消费统计。 + 信号表 = 合并信号表(读取挑选信号(连, 身份.作者, 领域=领域), 读取消费统计(连, 身份.作者)) + return 混合检索( + 连, + 查询, + 特征哈希嵌入器(), + work_id=work_id, + 数量=数量, + 作者=身份.作者, + 偏好信号表=信号表 or None, + ) def 重建索引(self, 身份) -> dict: self._核对作者(身份) @@ -306,6 +319,7 @@ __all__ = [ "读取方法检索资料", "读取方法检索候选", "读取参考材料", + "读取消费统计", "读取方法检索身份", "读取方法索引维护资料", "读取版本材料大小", diff --git a/src/muse/知识方法/消费记录.py b/src/muse/知识方法/消费记录.py index b264daf..289a0fb 100644 --- a/src/muse/知识方法/消费记录.py +++ b/src/muse/知识方法/消费记录.py @@ -110,6 +110,15 @@ def 记录消费( return {"usage_id": usage_id, "幂等": 写入 == 0} +def 读取消费统计(连, 作者: str) -> dict[str, int]: + """按版本聚合的真实创作消费计数;只读,供 B09 偏好重排的强信号路。 + + 只统计 generation/review/planning 三类实际创作消费; + enablement 是启用验证消费,不代表作者偏好,不计入。 + """ + return 方法存储(连).统计消费(作者) + + def 读取来源回查链(连, method_version_id: str) -> dict[str, Any]: """版本→方法→提案证据→B03 来源版本 的回查链。""" @@ -151,4 +160,4 @@ def 读取来源回查链(连, method_version_id: str) -> dict[str, Any]: } -__all__ = ["记录消费", "读取来源回查链"] +__all__ = ["记录消费", "读取消费统计", "读取来源回查链"] diff --git a/tests/单元/test_偏好重排.py b/tests/单元/test_偏好重排.py new file mode 100644 index 0000000..1f6fb83 --- /dev/null +++ b/tests/单元/test_偏好重排.py @@ -0,0 +1,105 @@ +"""偏好重排纯函数合同:分列计数、有界惩罚、确定性探索位与权重护栏。""" + +import pytest + +from muse.上下文.偏好重排 import ( + 偏好信号, + 偏好值, + 合并信号表, + 应用拒绝惩罚, + 权重表, + 构建偏好路, + 注入探索位, +) + + +@pytest.mark.case_id( + "NC-w32-a10015", + environment="离线", + given="挑选信号行与消费统计", + when="合并信号表", + then=["弱强信号分列计数不混算;同版本两路信号并存"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", +) +def test_合并信号表分列不混算__a10015(): + 表 = 合并信号表( + [ + {"method_version_id": "v1", "选用次数": 3, "拒绝次数": 1, "选中位次": [1, 2]}, + {"method_version_id": "v2", "选用次数": 0, "拒绝次数": 2, "选中位次": []}, + ], + {"v1": 5, "v3": 2}, + ) + assert 表["v1"] == 偏好信号(3, 1, 5, (1, 2)) + assert 表["v2"] == 偏好信号(0, 2, 0, ()) + assert 表["v3"] == 偏好信号(0, 0, 2, ()) + # 分列:选用与消费不互相折算 + assert 偏好值(表["v1"]) == pytest.approx(3 * 0.05 + 1 * -0.08 + 5 * 0.10) + + +@pytest.mark.case_id( + "NC-w32-a10016", + environment="离线", + given="正负偏好信号与双路融合分", + when="构建偏好路并应用拒绝惩罚", + then=["只有正偏好进路;惩罚有界且不超过偏好路末位贡献"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", +) +def test_正偏好进路负偏好有界惩罚__a10016(): + 信号表 = { + "v1": 偏好信号(选用次数=2), + "v2": 偏好信号(拒绝次数=3), + "v3": 偏好信号(), + } + 路 = 构建偏好路(信号表) + assert set(路) == {"v1"} and 路["v1"] > 0 + 融合 = {"v1": 0.03, "v2": 0.03, "v3": 0.03} + 调整 = 应用拒绝惩罚(融合, 信号表) + assert 调整["v1"] == 0.03 and 调整["v3"] == 0.03 + assert 调整["v2"] < 0.03 + # 惩罚上界 = 路权重/61(偏好路末位贡献量级) + assert 0.03 - 调整["v2"] <= 权重表["路权重"] / 61.0 + 1e-12 + # 大量拒绝也不能把惩罚推出上界(有界归一) + 巨量 = {"v9": 偏好信号(拒绝次数=10000)} + 融合巨量 = {"v9": 0.03} + assert 0.03 - 应用拒绝惩罚(融合巨量, 巨量)["v9"] < 权重表["路权重"] / 61.0 + + +@pytest.mark.case_id( + "NC-w32-a10017", + environment="离线", + given="含已探索头部与未探索尾部的排名", + when="注入探索位", + then=["至多两个未探索版本晋升,替换头部末尾已探索项,结果确定"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", +) +def test_探索位晋升确定且有界__a10017(): + 信号表 = {"a": 偏好信号(选用次数=1), "b": 偏好信号(消费次数=1), "c": 偏好信号(拒绝次数=1)} + 排名 = [("a", 0.9), ("b", 0.8), ("c", 0.7), ("d", 0.6), ("e", 0.5)] + 新排名, 探索集 = 注入探索位(排名, 信号表, 3) + 头部 = [vid for vid, _ in 新排名[:3]] + # d、e 未探索:晋升两个,替换头部末尾的已探索项(c、b) + assert 头部[0] == "a" and set(头部[1:]) == {"d", "e"} + assert 探索集 == {"d", "e"} + # 确定性:同输入同输出 + assert 注入探索位(排名, 信号表, 3)[0] == 新排名 + # 头部已含足量未探索项时不动 + 不动, 集 = 注入探索位([("d", 0.9), ("e", 0.8), ("a", 0.7)], 信号表, 3) + assert [vid for vid, _ in 不动] == ["d", "e", "a"] and 集 == {"d", "e"} + # 全已探索且无尾部候选时不动 + 全探索 = [("a", 0.9), ("b", 0.8)] + assert 注入探索位(全探索, 信号表, 2)[0] == 全探索 + + +@pytest.mark.case_id( + "NC-w32-a10018", + environment="离线", + given="版本化权重表", + when="核对权重护栏", + then=["偏好路权重不超过0.25,权重表带版本标识"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", +) +def test_权重表护栏__a10018(): + assert 权重表["version"] == "preference-rerank-v1" + assert 0 < 权重表["路权重"] <= 0.25 + assert 权重表["种类权重"]["拒绝"] < 0 < 权重表["种类权重"]["选用"] + assert 权重表["探索位"] in (1, 2) diff --git a/tests/用例清单.json b/tests/用例清单.json index f487b74..3cd1db8 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -27307,6 +27307,130 @@ "数据库" ] }, + { + "case_id": "NC-w32-a10015", + "environment": "离线", + "given": "挑选信号行与消费统计", + "when": "合并信号表", + "then": [ + "弱强信号分列计数不混算;同版本两路信号并存" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", + "file": "tests/单元/test_偏好重排.py", + "symbol": "test_合并信号表分列不混算__a10015", + "parameter_ids": [], + "node_ids": [ + "tests/单元/test_偏好重排.py::test_合并信号表分列不混算__a10015" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10016", + "environment": "离线", + "given": "正负偏好信号与双路融合分", + "when": "构建偏好路并应用拒绝惩罚", + "then": [ + "只有正偏好进路;惩罚有界且不超过偏好路末位贡献" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", + "file": "tests/单元/test_偏好重排.py", + "symbol": "test_正偏好进路负偏好有界惩罚__a10016", + "parameter_ids": [], + "node_ids": [ + "tests/单元/test_偏好重排.py::test_正偏好进路负偏好有界惩罚__a10016" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10017", + "environment": "离线", + "given": "含已探索头部与未探索尾部的排名", + "when": "注入探索位", + "then": [ + "至多两个未探索版本晋升,替换头部末尾已探索项,结果确定" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", + "file": "tests/单元/test_偏好重排.py", + "symbol": "test_探索位晋升确定且有界__a10017", + "parameter_ids": [], + "node_ids": [ + "tests/单元/test_偏好重排.py::test_探索位晋升确定且有界__a10017" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10018", + "environment": "离线", + "given": "版本化权重表", + "when": "核对权重护栏", + "then": [ + "偏好路权重不超过0.25,权重表带版本标识" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", + "file": "tests/单元/test_偏好重排.py", + "symbol": "test_权重表护栏__a10018", + "parameter_ids": [], + "node_ids": [ + "tests/单元/test_偏好重排.py::test_权重表护栏__a10018" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10019", + "environment": "隔离PG与真实检索索引;不证明文学效果", + "given": "两个已确认方法与新鲜索引,其一有真实挑选反馈", + "when": "经服务公开面检索", + "then": [ + "无信号回执保持等权合同;有信号启用重排并携带偏好分项与探索位" + ], + "contract": "docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", + "file": "tests/集成/test_检索偏好重排.py", + "symbol": "test_挑选信号改变检索回执且无信号保持等权__a10019", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_检索偏好重排.py::test_挑选信号改变检索回执且无信号保持等权__a10019" + ], + "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..d9c4a1b --- /dev/null +++ b/tests/集成/test_检索偏好重排.py @@ -0,0 +1,70 @@ +"""检索偏好重排真实链路:挑选信号改变回执与排序;无信号时保持等权合同。""" + +from uuid import uuid4 + +import pytest +import test_上下文快照与索引写入 as 方法测试 + +from muse.上下文.接口 import 混合检索 +from muse.作者经验.接口 import 参考挑选反馈请求, 挑选候选 +from muse.共享.调用身份 import 用途 +from muse.基础设施.模型.嵌入 import 特征哈希嵌入器 + +pytestmark = pytest.mark.数据库 +方法环境 = 方法测试.方法环境 + + +def _确认方法(服务, 作者, 来源, 标题): + 请求 = 方法测试._提案(str(uuid4()), 标题, str(来源.source_id), owner=作者.作者) + 回执 = 服务.提出方法(作者, str(uuid4()), 请求) + 方法测试._确认(服务, 作者, 回执["target_ref"]) + 版本 = 服务.读取方法详情(作者, 请求.method_id)["versions"][0] + return 请求.method_id, str(版本["version_id"]) + + +@pytest.mark.case_id( + "NC-w32-a10019", + environment="隔离PG与真实检索索引;不证明文学效果", + given="两个已确认方法与新鲜索引,其一有真实挑选反馈", + when="经服务公开面检索", + then=["无信号回执保持等权合同;有信号启用重排并携带偏好分项与探索位"], + contract="docs/系统架构/新版设计/模块设计/B09-上下文.md#偏好重排通路", +) +def test_挑选信号改变检索回执且无信号保持等权__a10019(方法环境): + 装配, 作者, 服务, 来源, _, 库 = 方法环境 + _, 版本A = _确认方法(服务, 作者, 来源, "信息差打脸") + _, 版本B = _确认方法(服务, 作者, 来源, "信息差反转") + 服务.重建索引(作者) + + 基线 = 服务.检索方法(作者, "信息差", work_id="method-work") + assert "重排权重版本" not in 基线 + with 库[用途.生产].连接(只读=True) as 连: + 裸 = 混合检索( + 连, "信息差", 特征哈希嵌入器(), work_id="method-work", 数量=10, 作者=作者.作者 + ) + assert [(r["version_id"], r["得分"]) for r in 基线["结果"]] == [ + (r["version_id"], r["得分"]) for r in 裸["结果"] + ] + assert all("偏好" not in r for r in 基线["结果"]) + + 经验 = 装配.要求作者经验() + 经验.记录参考挑选反馈( + 作者, + "rerank-sel-1", + 参考挑选反馈请求( + query="信息差", + domain="combat", + candidates=(挑选候选(版本A, 1, 0.03), 挑选候选(版本B, 2, 0.02)), + selected=(版本A,), + index_generation=int(基线.get("index_generation", 0)), + ), + ) + 重排 = 服务.检索方法(作者, "信息差", work_id="method-work", 领域="combat") + assert 重排["重排权重版本"] == "preference-rerank-v1" + 行A = next(r for r in 重排["结果"] if r["version_id"] == 版本A) + 行B = next(r for r in 重排["结果"] if r["version_id"] == 版本B) + 基A = next(r for r in 基线["结果"] if r["version_id"] == 版本A) + assert 行A["偏好"]["选用次数"] == 1 and 行A["偏好"]["消费次数"] == 0 + assert 行A["得分"] > 基A["得分"] # 正偏好第三路严格加分 + # B 无任何挑选/消费信号:属未探索,标记探索位;A 已有信号不占探索位 + assert 行B["探索位"] is True and 行A["探索位"] is False diff --git a/web/src/功能/正文写作/选段改写.tsx b/web/src/功能/正文写作/选段改写.tsx index 9116599..2af3817 100644 --- a/web/src/功能/正文写作/选段改写.tsx +++ b/web/src/功能/正文写作/选段改写.tsx @@ -62,6 +62,7 @@ export function Ui选段改写({ const [refDomain, setRefDomain] = useState("combat"); const [refResults, setRefResults] = useState<检索结果行[]>([]); const [refGeneration, setRefGeneration] = useState(0); + const [refWeightVersion, setRefWeightVersion] = useState(""); const [refSelected, setRefSelected] = useState([]); const [refRejected, setRefRejected] = useState([]); const [refSignals, setRefSignals] = useState>({}); @@ -111,12 +112,24 @@ export function Ui选段改写({ setError(""); try { const { data } = await 客户端.GET("/api/v1/methods/search", { - params: { query: { q: refQuery.trim(), work_id: current.work_id, limit: 20 } }, + params: { + query: { + q: refQuery.trim(), + work_id: current.work_id, + limit: 20, + domain: refDomain, + }, + }, }); if (!data) throw new Error("检索未返回。"); - const 页 = data as unknown as { 结果: 检索结果行[]; index_generation?: number }; + const 页 = data as unknown as { + 结果: 检索结果行[]; + index_generation?: number; + 重排权重版本?: string; + }; setRefResults(页.结果 ?? []); setRefGeneration(Number(页.index_generation ?? 0)); + setRefWeightVersion(页.重排权重版本 ?? ""); setRefSelected([]); setRefRejected([]); refFeedbackDone.current = false; @@ -160,6 +173,7 @@ export function Ui选段改写({ work_id: current.work_id, quote: "", index_generation: refGeneration, + weight_version: refWeightVersion, 种类: "参考挑选", candidates: refResults.map((行, 序) => ({ method_version_id: 行.version_id, diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts index bbae693..a71e3eb 100644 --- a/web/src/接口/生成/类型.ts +++ b/web/src/接口/生成/类型.ts @@ -7330,6 +7330,11 @@ export interface components { * @default 0 */ index_generation: number; + /** + * Weight Version + * @default + */ + weight_version: string; /** * 种类 * @default 参考挑选 @@ -11979,6 +11984,7 @@ export interface operations { q: string; work_id?: string; limit?: number; + domain?: string; }; header?: never; path?: never;