diff --git a/docs/接口契约/生成/openapi.generated.json b/docs/接口契约/生成/openapi.generated.json index fd2a07b..19f3fde 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -6602,6 +6602,78 @@ } } }, + "/api/v1/inferred-preference-suggestions": { + "get": { + "tags": [ + "作者经验" + ], + "summary": "读推断建议", + "operationId": "read_inferred_preference_suggestions", + "parameters": [ + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 100, + "default": "", + "title": "Domain" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "title": "Response Read Inferred Preference Suggestions" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/compounding-metrics": { + "get": { + "tags": [ + "作者经验" + ], + "summary": "读复利度量", + "operationId": "read_compounding_metrics", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Read Compounding Metrics" + } + } + } + } + } + } + }, "/api/v1/improvements": { "post": { "tags": [ diff --git a/docs/系统架构/新版设计/模块设计/B04-知识方法.md b/docs/系统架构/新版设计/模块设计/B04-知识方法.md index 25b78a1..5b77e48 100644 --- a/docs/系统架构/新版设计/模块设计/B04-知识方法.md +++ b/docs/系统架构/新版设计/模块设计/B04-知识方法.md @@ -21,6 +21,7 @@ | 核对方法评测投影 | 同用途元数据连接和已封存材料 | 重验内容/结构/字段策略哈希;评测不直接读正式方法表 | | 读取消费统计 | 作者、方法版本范围 | 按版本聚合的实际消费计数,供 B09 偏好重排的强信号路;只读,不改消费台账,不把被检索或任务成功当作消费 | | 读取参考材料 | 作者、确切方法版本清单、两种用途 | 当前且已确认或启用版本的参考材料渲染与哈希,供参考改写冻结与依赖复检;不查启用凭据、不写消费 | +| 读取版本头 | 方法版本清单 | 版本→标题/类型/状态/是否当前的只读窄头,非法或缺失身份跳过;供 B07 推断建议渲染标题,不展开材料 | 所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。 diff --git a/docs/系统架构/新版设计/模块设计/B07-作者经验.md b/docs/系统架构/新版设计/模块设计/B07-作者经验.md index d8b0efb..2362b66 100644 --- a/docs/系统架构/新版设计/模块设计/B07-作者经验.md +++ b/docs/系统架构/新版设计/模块设计/B07-作者经验.md @@ -25,6 +25,8 @@ | 读取运行观察 / 列出运行观察 / 检索重复问题 | 当前作者、观察或任务身份、可选问题键 | 历史观察与按实际任务计数的重复问题,不生成效果判断 | | 记录参考挑选反馈 | 检索回执引用、领域标签、选中与拒绝的方法版本清单 | 只追加参考挑选反馈;进入数值信号轨,不直接生成文本偏好 | | 读取挑选信号 | 作者、可选领域与方法版本范围 | 选用/拒绝计数与位次聚合,供 B09 偏好重排消费;只读,不改台账 | +| 汇总推断偏好建议 | 作者、可选领域 | 确定性聚合的只读建议(含真实原话依据与内联证据);不落库,采纳走既有保存→确认链 | +| 读取复利度量 | 作者 | 台账统计:首稿采纳率、点踩率等;分母为零保持空值,不构成因果结论 | 所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。 @@ -36,7 +38,8 @@ | 模型.py | 作者经验稳定对象、外壳及内部状态 | | 存储.py | 作者经验所属记录的 SQL 与版本读写 | | 作者偏好.py | 捕获、确认、范围、冲突和撤回 | -| 反馈对.py | 原稿、改稿、作者原话及决策 | +| 反馈对.py | 原稿、改稿、作者原话及决策;参考挑选反馈与挑选信号读取 | +| 推断偏好.py | 确定性推断建议聚合与复利度量统计 | | 观察记录.py | 真实反馈对上的作者自报观察 | | 运行观察.py | 实际运行来源、业务回执核对、运行观察与重复问题检索 | | 改进提案.py | 具体目标变更、版本及内容哈希 | @@ -67,7 +70,7 @@ - **文本偏好轨**(muse_preference):topic/text/scope 的人可读语句,经偏好材料注入写手提示词,由作者裁决生命周期(pending→active→withdrawn),以偏好绑定进入冻结快照。文本轨不参与检索排序。 - **数值信号轨**(参考挑选反馈与 B04 消费统计的聚合):仅供 B09 方法检索的偏好重排产生偏好分,权重经人工审查版本化。数值轨不冒充作者偏好,不自动生成生效文本。 -推断偏好是数值轨到文本轨的受控翻译:确定性聚合脚本(不调用模型)从信号模式产出 origin=inferred 的 pending 偏好,卡片内联证据(如"近 30 次挑选中被选 12 次"),作者显式确认后才 active,确认沿用既有冲突管理合同。护栏:单条推断需 ≥5 个支撑事件才生成;每次呈现摘要 ≤5 条;v1 不做权重自动调参——重排权重只经人工审查的版本号变更,作者主权延伸到排序策略。 +推断偏好是数值轨到文本轨的受控翻译:确定性聚合(不调用模型)从参考挑选台账生成偏好建议;建议是只读呈现面,不自动落库,采纳经既有保存→审阅→确认链成为 origin=inferred 的 pending 偏好,确认沿用既有冲突管理合同,不自动长期生效。护栏:单版本 ≥5 个支撑事件才提出;每次至多返回 5 条,按证据强度降序;建议必须携带一条真实作者反馈原话作依据,保存时校验原话与该反馈一致;v1 不做权重自动调参——重排权重只经人工审查的版本号变更,作者主权延伸到排序策略。复利度量是台账的确定性统计(首稿采纳率、点踩率等):比率分母为零保持空值不混算零,只作趋势呈现,不构成因果结论。语义归纳升级(模型抽象偏好)须另立受控提案,产物同样只进 pending。 ### 具体方法改进与用途交接 diff --git a/src/muse/作者经验/存储.py b/src/muse/作者经验/存储.py index 25d7df4..6891668 100644 --- a/src/muse/作者经验/存储.py +++ b/src/muse/作者经验/存储.py @@ -132,6 +132,58 @@ class 经验存储: for r in 行 ] + def 读挑选明细(self, author, *, 领域="", 上限=5000): + """挑选台账的有界明细;供确定性推断聚合,不改台账。 + + 每条是一次反馈对一个方法版本的动作(选用或拒绝), + 带领域、作者原话与时间;按时间倒序,超出上限只取最近部分。 + """ + 行 = self.游标.execute( + """ + WITH ref AS ( + SELECT feedback_id, created_at, payload FROM muse_author_feedback + WHERE author_id=%s AND payload->>'种类'='参考挑选' + AND (%s='' OR payload->>'domain'=%s) + ), 动作 AS ( + SELECT feedback_id, created_at, payload->>'domain' AS domain, + payload->>'query' AS query, payload->>'quote' AS quote, + s AS mvid, 'selected' AS action + FROM ref CROSS JOIN LATERAL jsonb_array_elements_text(payload->'selected') AS s + UNION ALL + SELECT feedback_id, created_at, payload->>'domain' AS domain, + payload->>'query' AS query, payload->>'quote' AS quote, + r AS mvid, 'rejected' AS action + FROM ref CROSS JOIN LATERAL jsonb_array_elements_text(payload->'rejected') AS r + ) + SELECT feedback_id::text, created_at, domain, query, quote, mvid, action + FROM 动作 ORDER BY created_at DESC LIMIT %s + """, + (author, 领域, 领域, 上限), + ).fetchall() + return [ + { + "feedback_id": r["feedback_id"], + "created_at": r["created_at"], + "domain": r["domain"], + "query": r["query"], + "quote": r["quote"], + "method_version_id": r["mvid"], + "action": r["action"], + } + for r in 行 + ] + + def 读改写决定统计(self, author): + """改写对照反馈的决定分布;存量无种类字段的记录按改写对照计。""" + 行 = self.游标.execute( + "SELECT COALESCE(payload->>'decision','') AS decision, count(*) AS n " + "FROM muse_author_feedback " + "WHERE author_id=%s AND COALESCE(payload->>'种类','改写对照')='改写对照' " + "GROUP BY 1 ORDER BY 1", + (author,), + ).fetchall() + return {r["decision"]: int(r["n"]) for r in 行 if r["decision"]} + def 数据库标识(self): return self.连.execute( "SELECT current_database(),pg_postmaster_start_time()," diff --git a/src/muse/作者经验/接口.py b/src/muse/作者经验/接口.py index 83a8eeb..1f73cb2 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 ( 偏好决定, 偏好范围, @@ -211,6 +212,17 @@ class 作者经验服务: with self.数据库.连接(只读=True) as 连: return 读取挑选信号(连, 身份.作者, 领域=领域) + def 汇总推断偏好建议(self, 身份, *, 领域=""): + """确定性聚合的 pending 偏好建议;只读呈现,采纳走既有保存→确认链。""" + self._身份(身份) + with self.数据库.连接(只读=True) as 连: + return 汇总推断建议(连, 身份.作者, 领域=领域) + + def 读取复利度量(self, 身份): + self._身份(身份) + with self.数据库.连接(只读=True) as 连: + return 读取复利度量(连, 身份.作者) + def 保存偏好(self, 身份, 命令ID, 请求: 偏好请求): self._身份(身份) return self.正式.提交变更( @@ -312,6 +324,8 @@ __all__ = [ "挑选候选", "保存参考挑选", "读取挑选信号", + "汇总推断建议", + "读取复利度量", "经验错误", "读取偏好材料", ] diff --git a/src/muse/作者经验/推断偏好.py b/src/muse/作者经验/推断偏好.py new file mode 100644 index 0000000..8a5980e --- /dev/null +++ b/src/muse/作者经验/推断偏好.py @@ -0,0 +1,171 @@ +"""确定性推断聚合:从参考挑选台账提炼 pending 偏好建议;只读,不落库。 + +合同见模块设计/B07-作者经验.md「偏好双轨关系」: +- 聚合是确定性脚本,不调用模型;语义归纳升级须另立受控提案。 +- 护栏:单版本支撑事件 ≥5 才生成建议;每次至多返回 5 条; + 建议必须携带一条真实反馈原话作依据(推断偏好保存时原话必须一致)。 +- 建议只是呈现面:采纳走既有 保存偏好(origin=inferred)→审阅→确认 链, + 不自动生效、不自动落库、不代替作者裁决。 +""" + +from __future__ import annotations + +from typing import Any + +from muse.作者经验.存储 import 经验存储 + +_最少支撑事件 = 5 +_建议上限 = 5 +_明细上限 = 5000 + +_领域标签 = { + "combat": "打戏", + "emotion": "情感", + "dialogue": "对话", + "inner": "内心", + "environment": "环境", + "style": "文风", + "pacing": "节奏", + "other": "其他", +} + + +def 聚合挑选明细(明细: list[dict]) -> dict[tuple[str, str], dict[str, Any]]: + """把有界明细按(领域,方法版本)聚合;纯函数,供单测护栏。""" + 聚合: dict[tuple[str, str], dict[str, Any]] = {} + for 行 in 明细: + 键 = (行["domain"], 行["method_version_id"]) + 桶 = 聚合.setdefault( + 键, + {"选用次数": 0, "拒绝次数": 0, "选用依据": None, "拒绝依据": None}, + ) + if 行["action"] == "selected": + 桶["选用次数"] += 1 + if 桶["选用依据"] is None and 行["quote"].strip(): + 桶["选用依据"] = 行 # 明细按时间倒序,首个非空原话即最近一条 + else: + 桶["拒绝次数"] += 1 + if 桶["拒绝依据"] is None and 行["quote"].strip(): + 桶["拒绝依据"] = 行 + return 聚合 + + +def 生成建议( + 聚合: dict[tuple[str, str], dict[str, Any]], 标题表: dict[str, dict] +) -> list[dict[str, Any]]: + """从聚合桶生成偏好建议;纯函数。 + + 规则(v1 保守,两类): + - 选用倾向:支撑事件 ≥5 且 选用 ≥ 3×拒绝,且有带原话的选用依据; + - 回避倾向:支撑事件 ≥5 且 拒绝 ≥ 3×选用,且有带原话的拒绝依据。 + 无原话依据不生成——推断偏好保存时要求原话与真实反馈一致, + 没有依据的建议无法诚实落库。按支撑事件数降序,至多 5 条。 + """ + 建议: list[dict[str, Any]] = [] + for (领域, 版本), 桶 in 聚合.items(): + 选用, 拒绝 = 桶["选用次数"], 桶["拒绝次数"] + 事件 = 选用 + 拒绝 + if 事件 < _最少支撑事件: + continue + 标签 = _领域标签.get(领域, 领域) + 头 = 标题表.get(版本) or {} + 标题 = 头.get("title", 版本) + if 选用 >= 3 * 拒绝 and 桶["选用依据"] is not None: + 依据 = 桶["选用依据"] + 建议.append( + { + "topic": f"参考偏好:{领域}", + "text": ( + f"「{标签}」领域改写优先参考「{标题}」类技法:" + f"作者已选用 {选用} 次、拒绝 {拒绝} 次。" + ), + "quote": 依据["quote"], + "feedback_id": 依据["feedback_id"], + "method_version_id": 版本, + "domain": 领域, + "支撑事件": 事件, + "证据": { + "选用次数": 选用, + "拒绝次数": 拒绝, + "方法标题": 标题, + "measurement_source": "feedback_ledger", + "causality": "not_established", + }, + } + ) + elif 拒绝 >= 3 * 选用 and 桶["拒绝依据"] is not None: + 依据 = 桶["拒绝依据"] + 建议.append( + { + "topic": f"参考偏好:{领域}", + "text": ( + f"「{标签}」领域避免「{标题}」类技法,除非作者明确要求:" + f"作者已拒绝 {拒绝} 次、选用 {选用} 次。" + ), + "quote": 依据["quote"], + "feedback_id": 依据["feedback_id"], + "method_version_id": 版本, + "domain": 领域, + "支撑事件": 事件, + "证据": { + "选用次数": 选用, + "拒绝次数": 拒绝, + "方法标题": 标题, + "measurement_source": "feedback_ledger", + "causality": "not_established", + }, + } + ) + 建议.sort(key=lambda 条: (-条["支撑事件"], 条["topic"], 条["method_version_id"])) + return 建议[:_建议上限] + + +def 汇总推断建议(连, 作者: str, *, 领域: str = "") -> list[dict[str, Any]]: + """读取有界挑选明细并经 B04 版本头渲染标题;确定性、只读。""" + from muse.知识方法.接口 import 读取版本头 + + 明细 = 经验存储(连).读挑选明细(作者, 领域=领域, 上限=_明细上限) + if not 明细: + return [] + 聚合 = 聚合挑选明细(明细) + 标题表 = 读取版本头(连, tuple({版本 for _, 版本 in 聚合})) + return 生成建议(聚合, 标题表) + + +def 读取复利度量(连, 作者: str) -> dict[str, Any]: + """台账的确定性统计;比率分母为零时保持 None,不混算零。 + + 只是账本事实的呈现,不构成因果或质量结论。 + """ + 存储 = 经验存储(连) + 决定 = 存储.读改写决定统计(作者) + 总改写 = sum(决定.values()) + 采纳 = 决定.get("accept", 0) + 信号 = 存储.读挑选信号(作者) + 总选用 = sum(条["选用次数"] for 条 in 信号) + 总拒绝 = sum(条["拒绝次数"] for 条 in 信号) + 总挑选 = 总选用 + 总拒绝 + return { + "改写对照": { + **决定, + "总数": 总改写, + "首稿采纳率": (采纳 / 总改写) if 总改写 else None, + }, + "参考挑选": { + "选用次数": 总选用, + "拒绝次数": 总拒绝, + "点踩率": (总拒绝 / 总挑选) if 总挑选 else None, + "有信号版本数": len(信号), + }, + "measurement_source": "feedback_ledger", + "causality": "not_established", + "说明": "账本统计只作趋势参考,不构成因果结论;比率分母为零时保持空值。", + } + + +__all__ = [ + "聚合挑选明细", + "生成建议", + "汇总推断建议", + "读取复利度量", +] diff --git a/src/muse/接入/cli/入口.py b/src/muse/接入/cli/入口.py index e8d4930..8d84b56 100644 --- a/src/muse/接入/cli/入口.py +++ b/src/muse/接入/cli/入口.py @@ -117,6 +117,8 @@ def _解析参数(argv: list[str] | None) -> argparse.Namespace: "重复问题", "记录参考挑选", "挑选信号", + "推断建议", + "复利度量", ], ) 经验解析.add_argument("目标", nargs="?", help="改进身份或完整请求JSON") diff --git a/src/muse/接入/cli/经验命令.py b/src/muse/接入/cli/经验命令.py index 7fad3b8..cc8b49a 100644 --- a/src/muse/接入/cli/经验命令.py +++ b/src/muse/接入/cli/经验命令.py @@ -53,6 +53,10 @@ def 运行经验命令(app, 动作, 目标, *, 评测配置=None): return service.检索重复问题(identity, 问题键=目标) if 动作 == "挑选信号": return service.读取参考挑选信号(identity, 领域=目标 or "") + if 动作 == "推断建议": + return service.汇总推断偏好建议(identity, 领域=目标 or "") + if 动作 == "复利度量": + return service.读取复利度量(identity) if not 目标: raise 配置错误("此动作需要确切对象或请求JSON文件") if 动作 == "运行观察": diff --git a/src/muse/接入/http/路由/作者经验.py b/src/muse/接入/http/路由/作者经验.py index 7f34e06..4a7541c 100644 --- a/src/muse/接入/http/路由/作者经验.py +++ b/src/muse/接入/http/路由/作者经验.py @@ -14,7 +14,9 @@ from muse.接入.经验操作 import ( 参考挑选输入, 规则改进启停输入, 规则改进输入, + 读取复利度量视图, 读取挑选信号视图, + 读取推断建议视图, 读取运行来源, 运行来源请求, 运行观察保存请求, @@ -125,6 +127,22 @@ def 读挑选信号( return 读取挑选信号视图(app, 创作身份(作者, app.配置), 领域=domain) +@路由.get("/inferred-preference-suggestions", operation_id="read_inferred_preference_suggestions") +def 读推断建议( + request: Request, + 作者: 作者依赖, + domain: str = Query(default="", max_length=100), +) -> list[dict]: + app = request.app.state.装配 + return 读取推断建议视图(app, 创作身份(作者, app.配置), 领域=domain) + + +@路由.get("/compounding-metrics", operation_id="read_compounding_metrics") +def 读复利度量(request: Request, 作者: 作者依赖) -> dict: + app = request.app.state.装配 + return 读取复利度量视图(app, 创作身份(作者, app.配置)) + + @路由.post("/improvements", operation_id="propose_improvement") def 提出改进(body: 改进输入, request: Request, 作者: 作者依赖): svc, identity = _服务(request, 作者) diff --git a/src/muse/接入/经验操作.py b/src/muse/接入/经验操作.py index 1684cb7..712f9e3 100644 --- a/src/muse/接入/经验操作.py +++ b/src/muse/接入/经验操作.py @@ -92,3 +92,11 @@ def 保存参考挑选反馈(app, identity, request: 参考挑选输入): def 读取挑选信号视图(app, identity, *, 领域=""): return app.要求作者经验().读取参考挑选信号(identity, 领域=领域) + + +def 读取推断建议视图(app, identity, *, 领域=""): + return app.要求作者经验().汇总推断偏好建议(identity, 领域=领域) + + +def 读取复利度量视图(app, identity): + return app.要求作者经验().读取复利度量(identity) diff --git a/src/muse/知识方法/存储.py b/src/muse/知识方法/存储.py index 4e52be4..73c917d 100644 --- a/src/muse/知识方法/存储.py +++ b/src/muse/知识方法/存储.py @@ -198,6 +198,20 @@ class 方法存储: (version_id,), ).fetchone() + def 读取版本头集合(self, version_ids: list[str]) -> list[dict[str, Any]]: + if not version_ids: + return [] + 行 = self._查( + "SELECT v.version_id::text AS version_id, v.method_id::text AS method_id," + " v.version, m.current_version, m.state, m.type_id," + " COALESCE(p.request->>'title', m.title) AS title " + "FROM muse_method_version v JOIN muse_method m USING (method_id) " + "LEFT JOIN muse_method_proposal p ON p.proposal_id=v.proposal_id " + "WHERE v.version_id = ANY(%s::uuid[])", + (version_ids,), + ).fetchall() + return [dict(r) for r in 行] + def 按哈希读版本(self, method_id: str, content_hash: str) -> dict[str, Any] | None: return self._查( "SELECT version_id,version FROM muse_method_version " diff --git a/src/muse/知识方法/接口.py b/src/muse/知识方法/接口.py index 7055b36..0498c55 100644 --- a/src/muse/知识方法/接口.py +++ b/src/muse/知识方法/接口.py @@ -18,6 +18,7 @@ from muse.知识方法.方法卡 import ( 读取方法检索资料, 读取方法检索身份, 读取方法索引维护资料, + 读取版本头, 读取版本材料大小, ) from muse.知识方法.方法版本 import ( @@ -319,6 +320,7 @@ __all__ = [ "读取方法检索资料", "读取方法检索候选", "读取参考材料", + "读取版本头", "读取消费统计", "读取方法检索身份", "读取方法索引维护资料", diff --git a/src/muse/知识方法/方法卡.py b/src/muse/知识方法/方法卡.py index 68dddd5..26f0bb9 100644 --- a/src/muse/知识方法/方法卡.py +++ b/src/muse/知识方法/方法卡.py @@ -202,6 +202,31 @@ def 读取方法检索候选( } +def 读取版本头(连, version_ids) -> dict[str, dict[str, Any]]: + """版本→标题/类型/状态/是否当前的只读窄头;非法或缺失身份跳过,不报错。 + + 供 B07 推断建议渲染方法标题;不展开材料、不写消费、不做授权判断。 + """ + 合法: list[str] = [] + for 原始 in version_ids: + try: + UUID(str(原始)) + except (ValueError, TypeError, AttributeError): + continue + 合法.append(str(原始)) + 头 = {} + for 行 in 方法存储(连).读取版本头集合(合法): + 头[行["version_id"]] = { + "method_id": 行["method_id"], + "version": int(行["version"]), + "title": 行["title"], + "type_id": 行["type_id"], + "state": 行["state"], + "是当前版本": int(行["version"]) == int(行["current_version"]), + } + return 头 + + def 读取参考材料( 连, version_ids: tuple[str, ...], diff --git a/tests/单元/test_推断偏好聚合.py b/tests/单元/test_推断偏好聚合.py new file mode 100644 index 0000000..cb6c1db --- /dev/null +++ b/tests/单元/test_推断偏好聚合.py @@ -0,0 +1,84 @@ +"""推断偏好聚合的纯函数护栏:事件阈值、比例、原话依据、条数上限与排序。""" + +import pytest + +from muse.作者经验.推断偏好 import 生成建议, 聚合挑选明细 + + +def _明细(动作, 原话="", 时刻=0): + return { + "feedback_id": f"fb-{动作}-{时刻}", + "created_at": 时刻, + "domain": "combat", + "query": "机甲", + "quote": 原话, + "method_version_id": "v1", + "action": 动作, + } + + +@pytest.mark.case_id( + "NC-w32-a10020", + environment="离线", + given="构造的挑选明细(时间倒序)", + when="聚合明细并生成推断建议", + then=["分列计数、依据取最近非空原话、护栏与排序生效"], + contract="docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", +) +def test_聚合与建议护栏__a10020(): + # 明细按时间倒序(与存储读法一致):最近的原话在前 + 明细 = [ + _明细("selected", "最近的打戏原话", 9), + _明细("selected", "", 8), + _明细("selected", "较早的原话", 7), + _明细("selected", "", 6), + _明细("rejected", "", 5), + ] + 聚合 = 聚合挑选明细(明细) + 桶 = 聚合[("combat", "v1")] + assert 桶["选用次数"] == 4 and 桶["拒绝次数"] == 1 + assert 桶["选用依据"]["quote"] == "最近的打戏原话" + + 标题表 = {"v1": {"title": "机甲近身刀法"}} + 建议 = 生成建议(聚合, 标题表) + assert len(建议) == 1 + 条 = 建议[0] + assert 条["topic"] == "参考偏好:combat" + assert "机甲近身刀法" in 条["text"] and "选用 4 次" in 条["text"] + assert 条["quote"] == "最近的打戏原话" + assert 条["feedback_id"] == "fb-selected-9" + assert 条["证据"]["causality"] == "not_established" + + # 护栏1:支撑事件不足 5 不生成 + assert 生成建议(聚合挑选明细(明细[:3]), 标题表) == [] + # 护栏2:选用未达 3 倍拒绝不生成(3选2拒=5事件但 3 < 3*2) + 胶着 = [ + _明细("selected", "原话", 5), + _明细("selected", "", 4), + _明细("selected", "", 3), + _明细("rejected", "", 2), + _明细("rejected", "", 1), + ] + assert 生成建议(聚合挑选明细(胶着), 标题表) == [] + # 护栏3:无原话依据不生成 + 无原话 = [_明细("selected", "", i) for i in range(5)] + assert 生成建议(聚合挑选明细(无原话), 标题表) == [] + # 回避倾向:拒绝 ≥5 且 ≥3 倍选用,带拒绝原话 + 回避 = [_明细("rejected", "不要这种花哨写法", 9)] + [_明细("rejected", "", i) for i in range(4)] + 回避建议 = 生成建议(聚合挑选明细(回避), 标题表) + assert len(回避建议) == 1 and "避免" in 回避建议[0]["text"] + assert 回避建议[0]["quote"] == "不要这种花哨写法" + + # 护栏4:至多 5 条,按支撑事件降序 + 多版本 = {} + for 序 in range(7): + vid = f"v{序}" + 行 = [ + {**_明细("selected", f"原话{序}", 时刻), "method_version_id": vid} + for 时刻 in range(5 + 序) + ] + 多版本.update(聚合挑选明细(行)) + 全部 = 生成建议(多版本, {f"v{序}": {"title": f"方法{序}"} for 序 in range(7)}) + assert len(全部) == 5 + 事件数 = [条["支撑事件"] for 条 in 全部] + assert 事件数 == sorted(事件数, reverse=True) diff --git a/tests/用例清单.json b/tests/用例清单.json index 3cd1db8..c322460 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -27431,6 +27431,95 @@ "数据库" ] }, + { + "case_id": "NC-w32-a10020", + "environment": "离线", + "given": "构造的挑选明细(时间倒序)", + "when": "聚合明细并生成推断建议", + "then": [ + "分列计数、依据取最近非空原话、护栏与排序生效" + ], + "contract": "docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", + "file": "tests/单元/test_推断偏好聚合.py", + "symbol": "test_聚合与建议护栏__a10020", + "parameter_ids": [], + "node_ids": [ + "tests/单元/test_推断偏好聚合.py::test_聚合与建议护栏__a10020" + ], + "fixtures": [ + "request", + "测试资源接缝", + "源码资源", + "离线防护" + ], + "markers": [] + }, + { + "case_id": "NC-w32-a10021", + "environment": "隔离PG;实际S01/B04/B07链路,不证明文学效果", + "given": "同一方法版本的五条真实挑选反馈(四选一拒,其一有原话)", + "when": "汇总建议、采纳为推断偏好、审阅确认并消费", + "then": [ + "建议带真实原话依据;采纳后pending,确认后active可消费" + ], + "contract": "docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", + "file": "tests/集成/test_推断偏好.py", + "symbol": "test_建议采纳确认链__a10021", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_推断偏好.py::test_建议采纳确认链__a10021" + ], + "fixtures": [ + "request", + "tmp_path", + "tmp_path_factory", + "内置种子方案", + "内置结构测试库", + "参考环境", + "数据库底座", + "测试资源接缝", + "源码资源", + "生成环境", + "离线防护", + "隔离数据库URL" + ], + "markers": [ + "数据库" + ] + }, + { + "case_id": "NC-w32-a10022", + "environment": "隔离PG;实际S01/B04/B07链路,不证明文学效果", + "given": "支撑事件不足门槛的挑选台账", + "when": "汇总建议并读取复利度量", + "then": [ + "不足5事件不生成建议;度量比率的空值与已知值分列" + ], + "contract": "docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", + "file": "tests/集成/test_推断偏好.py", + "symbol": "test_门槛与度量空值语义__a10022", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_推断偏好.py::test_门槛与度量空值语义__a10022" + ], + "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..b37e2d4 --- /dev/null +++ b/tests/集成/test_推断偏好.py @@ -0,0 +1,112 @@ +"""推断偏好建议的真实链路:台账聚合→建议→采纳为pending→确认active→可消费;复利度量空值语义。""" + +from uuid import uuid4 + +import pytest +import test_参考改写 as 改写测试 + +from muse.作者经验.接口 import ( + 偏好决定, + 偏好范围, + 偏好请求, + 参考挑选反馈请求, + 挑选候选, + 读取偏好材料, +) + +pytestmark = pytest.mark.数据库 +生成环境 = 改写测试.生成环境 +参考环境 = 改写测试.参考环境 + + +def _挑选请求(版本, selected, rejected, quote=""): + return 参考挑选反馈请求( + query="机甲近身刀法", + domain="combat", + candidates=(挑选候选(版本, 1, 0.03),), + selected=selected, + rejected=rejected, + quote=quote, + ) + + +@pytest.mark.case_id( + "NC-w32-a10021", + environment="隔离PG;实际S01/B04/B07链路,不证明文学效果", + given="同一方法版本的五条真实挑选反馈(四选一拒,其一有原话)", + when="汇总建议、采纳为推断偏好、审阅确认并消费", + then=["建议带真实原话依据;采纳后pending,确认后active可消费"], + contract="docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", +) +def test_建议采纳确认链__a10021(参考环境): + env = 参考环境 + 经验 = env["装配"].要求作者经验() + 作者 = env["作者"] + 版本 = env["参考版本"] + 经验.记录参考挑选反馈(作者, "infer-1", _挑选请求(版本, (版本,), (), "这类受击反馈多来点")) + for 序 in (2, 3, 4): + 经验.记录参考挑选反馈(作者, f"infer-{序}", _挑选请求(版本, (版本,), ())) + 经验.记录参考挑选反馈(作者, "infer-5", _挑选请求(版本, (), (版本,))) + + 建议 = 经验.汇总推断偏好建议(作者) + assert len(建议) == 1 + 条 = 建议[0] + assert 条["quote"] == "这类受击反馈多来点" + 依据 = 经验.读取反馈(作者, 条["feedback_id"]) + assert 依据["payload"]["quote"] == 条["quote"] + assert 依据["payload"]["种类"] == "参考挑选" + assert 条["证据"]["选用次数"] == 4 and 条["证据"]["拒绝次数"] == 1 + + saved = 经验.保存偏好( + 作者, + str(uuid4()), + 偏好请求( + preference_id=str(uuid4()), + topic=条["topic"], + text=条["text"], + quote=条["quote"], + scope=偏好范围(), + origin="inferred", + feedback_id=条["feedback_id"], + ), + )["results"][0] + assert saved["state"] == "pending" + + 定位 = 偏好决定(saved["preference_id"], saved["revision"], saved["content_hash"]) + 审阅 = 经验.打开偏好审阅(作者, 定位) + confirmed = 经验.确认偏好( + 作者, str(uuid4()), 定位, 审阅ID=审阅["review_id"], 审阅哈希=审阅["review_hash"] + )["results"][0] + assert confirmed["state"] == "active" + with env["库"].连接(只读=True) as 连: + 材料 = 读取偏好材料(连, 作者.作者, "gen-work") + assert any(条["text"] == 项["text"] for 项 in 材料) + + +@pytest.mark.case_id( + "NC-w32-a10022", + environment="隔离PG;实际S01/B04/B07链路,不证明文学效果", + given="支撑事件不足门槛的挑选台账", + when="汇总建议并读取复利度量", + then=["不足5事件不生成建议;度量比率的空值与已知值分列"], + contract="docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", +) +def test_门槛与度量空值语义__a10022(参考环境): + env = 参考环境 + 经验 = env["装配"].要求作者经验() + 作者 = env["作者"] + 版本 = env["参考版本"] + 经验.记录参考挑选反馈(作者, "few-1", _挑选请求(版本, (版本,), ())) + 经验.记录参考挑选反馈(作者, "few-2", _挑选请求(版本, (), (版本,))) + + assert 经验.汇总推断偏好建议(作者) == [] + 度量 = 经验.读取复利度量(作者) + assert 度量["改写对照"]["总数"] == 0 + assert 度量["改写对照"]["首稿采纳率"] is None # 分母为零保持空值,不混算零 + assert 度量["参考挑选"]["选用次数"] == 1 + assert 度量["参考挑选"]["拒绝次数"] == 1 + assert 度量["参考挑选"]["点踩率"] == 0.5 + assert 度量["causality"] == "not_established" + + # 领域过滤:非 combat 领域无建议 + assert 经验.汇总推断偏好建议(作者, 领域="emotion") == [] diff --git a/web/src/功能/作者经验/偏好页.tsx b/web/src/功能/作者经验/偏好页.tsx index 123d367..0db4992 100644 --- a/web/src/功能/作者经验/偏好页.tsx +++ b/web/src/功能/作者经验/偏好页.tsx @@ -9,14 +9,23 @@ import { 打开审阅, 确认偏好, 撤回偏好, + 列出推断建议, + 读取复利度量, + 采纳建议, type 偏好行, type 偏好审阅, type 偏好定位, + type 建议行, } from "./数据"; export function Ui偏好页() { const cache = useQueryClient(); const rows = useQuery({ queryKey: ["作者偏好"], queryFn: 列出偏好 }); + const suggestions = useQuery({ + queryKey: ["推断偏好建议"], + queryFn: () => 列出推断建议(), + }); + const metrics = useQuery({ queryKey: ["复利度量"], queryFn: 读取复利度量 }); const works = useQuery({ queryKey: ["作品列表"], queryFn: ({ signal }) => 列出作品(signal), @@ -64,6 +73,22 @@ export function Ui偏好页() { String( works.data?.find((w) => w.work_id === id)?.content.名称 ?? "指定作品", ); + async function 采纳(建议: 建议行) { + setBusy(true); + setMessage(""); + try { + await 采纳建议(建议); + setMessage("已保存为待确认偏好;在下方列表中审阅后才会生效。"); + await cache.invalidateQueries({ queryKey: ["作者偏好"] }); + await cache.invalidateQueries({ queryKey: ["推断偏好建议"] }); + } catch (e) { + setMessage((e as Error).message); + } finally { + setBusy(false); + } + } + const 百分比 = (值: number | null) => + 值 === null ? "暂无数据" : `${Math.round(值 * 100)}%`; return (

作者经验

@@ -128,6 +153,46 @@ export function Ui偏好页() {

{message}

+
+

复利度量

+ {metrics.data ? ( + <> +

+ 首稿采纳率 {百分比(metrics.data.改写对照.首稿采纳率)}(改写对照{" "} + {metrics.data.改写对照.总数} 条) · 参考挑选点踩率{" "} + {百分比(metrics.data.参考挑选.点踩率)}(选用{" "} + {metrics.data.参考挑选.选用次数} 次 / 拒绝{" "} + {metrics.data.参考挑选.拒绝次数} 次) +

+

{metrics.data.说明}

+ + ) : ( +

正在读取度量…

+ )} +
+
+

推断建议

+

+ 来自参考挑选台账的确定性统计(单版本 ≥5 次支撑事件才提出)。 + 采纳只保存为待确认偏好,经你确认后才会生效。 +

+ {suggestions.data?.length === 0 &&

暂无达到门槛的建议。

} + {suggestions.data?.map((建议) => ( +
+

{建议.证据.方法标题}

+

{建议.text}

+

+ 依据:选用 {建议.证据.选用次数} 次 · 拒绝 {建议.证据.拒绝次数}{" "} + 次 · 支撑事件 {建议.支撑事件} +

+
{建议.quote}
+ +
+ ))} + {suggestions.error &&

{suggestions.error.message}

} +
{rows.error &&

{rows.error.message}

} {rows.isPending &&

正在读取偏好…

} {rows.data?.length === 0 &&

还没有保存偏好。

} diff --git a/web/src/功能/作者经验/数据.ts b/web/src/功能/作者经验/数据.ts index 88645fc..e6658ea 100644 --- a/web/src/功能/作者经验/数据.ts +++ b/web/src/功能/作者经验/数据.ts @@ -285,3 +285,66 @@ export async function 读取观察(improvement_id: string) { }), ); } + +export interface 建议行 { + topic: string; + text: string; + quote: string; + feedback_id: string; + method_version_id: string; + domain: string; + 支撑事件: number; + 证据: { + 选用次数: number; + 拒绝次数: number; + 方法标题: string; + measurement_source: string; + causality: string; + }; +} +export interface 复利度量 { + 改写对照: { + 总数: number; + 首稿采纳率: number | null; + [决定: string]: number | null; + }; + 参考挑选: { + 选用次数: number; + 拒绝次数: number; + 点踩率: number | null; + 有信号版本数: number; + }; + measurement_source: string; + causality: string; + 说明: string; +} +export async function 列出推断建议(domain = "") { + return 取<建议行[]>( + await 客户端.GET("/api/v1/inferred-preference-suggestions", { + params: { query: { domain } }, + }), + ); +} +export async function 读取复利度量() { + return 取<复利度量>(await 客户端.GET("/api/v1/compounding-metrics")); +} +export async function 采纳建议(建议: 建议行, workId = "") { + return 取<{ results: 偏好行[] }>( + await 客户端.POST("/api/v1/preferences", { + body: { + command_id: crypto.randomUUID(), + preference: { + preference_id: crypto.randomUUID(), + topic: 建议.topic, + text: 建议.text, + quote: 建议.quote, + scope: { kind: workId ? "work" : "author", work_id: workId, task_id: "" }, + origin: "inferred", + duration: "persistent", + feedback_id: 建议.feedback_id, + expected_revision: 0, + }, + }, + }), + ); +} diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts index a71e3eb..c6d81f3 100644 --- a/web/src/接口/生成/类型.ts +++ b/web/src/接口/生成/类型.ts @@ -2051,6 +2051,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/inferred-preference-suggestions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 读推断建议 */ + get: operations["read_inferred_preference_suggestions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/compounding-metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 读复利度量 */ + get: operations["read_compounding_metrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/improvements": { parameters: { query?: never; @@ -12630,6 +12664,61 @@ export interface operations { }; }; }; + read_inferred_preference_suggestions: { + parameters: { + query?: { + domain?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }[]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_compounding_metrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; propose_improvement: { parameters: { query?: never; diff --git a/web/tests/交互/作者偏好.test.tsx b/web/tests/交互/作者偏好.test.tsx index b990ad7..603f4f7 100644 --- a/web/tests/交互/作者偏好.test.tsx +++ b/web/tests/交互/作者偏好.test.tsx @@ -16,6 +16,9 @@ const api = vi.hoisted(() => ({ review: vi.fn(), confirm: vi.fn(), revoke: vi.fn(), + suggest: vi.fn(), + metrics: vi.fn(), + adopt: vi.fn(), })); vi.mock("../../src/功能/作者经验/数据", () => ({ 列出偏好: api.list, @@ -23,6 +26,9 @@ vi.mock("../../src/功能/作者经验/数据", () => ({ 打开审阅: api.review, 确认偏好: api.confirm, 撤回偏好: api.revoke, + 列出推断建议: () => api.suggest(), + 读取复利度量: () => api.metrics(), + 采纳建议: api.adopt, })); vi.mock("../../src/功能/作品规划/数据", () => ({ 列出作品: async () => [] })); function row(id = "pending", state = "pending", text = "用动作显现心理。") { @@ -67,6 +73,15 @@ beforeEach(() => { api.confirm.mockResolvedValue({}); api.revoke.mockResolvedValue({}); api.save.mockResolvedValue({ results: [{ state: "active" }] }); + api.suggest.mockResolvedValue([]); + api.metrics.mockResolvedValue({ + 改写对照: { 总数: 0, 首稿采纳率: null }, + 参考挑选: { 选用次数: 0, 拒绝次数: 0, 点踩率: null, 有信号版本数: 0 }, + measurement_source: "feedback_ledger", + causality: "not_established", + 说明: "", + }); + api.adopt.mockResolvedValue({ results: [{ state: "pending" }] }); }); afterEach(cleanup); @@ -140,3 +155,37 @@ it("NC-ui-preference-scope:任务要求不被编辑成长期范围", async () ), ); }); + +// @muse-case {"case_id":"NC-ui-preference-infer","environment":"离线界面和传输替身","given":"确定性聚合产出的推断偏好建议","when":"作者点击采纳","then":["以建议原话与真实反馈保存为待确认偏好,不自动生效"],"contract":"docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系"} +it("NC-ui-preference-infer:推断建议采纳为待确认偏好", async () => { + api.list.mockResolvedValue([]); + api.suggest.mockResolvedValue([ + { + topic: "参考偏好:combat", + text: "「打戏」领域改写优先参考「机甲近身刀法」类技法。", + quote: "这类受击反馈多来点", + feedback_id: "fb-1", + method_version_id: "v1", + domain: "combat", + 支撑事件: 6, + 证据: { + 选用次数: 5, + 拒绝次数: 1, + 方法标题: "机甲近身刀法", + measurement_source: "feedback_ledger", + causality: "not_established", + }, + }, + ]); + mount(); + const button = await screen.findByRole("button", { + name: "采纳为待确认偏好", + }); + fireEvent.click(button); + await waitFor(() => + expect(api.adopt).toHaveBeenCalledWith( + expect.objectContaining({ feedback_id: "fb-1", topic: "参考偏好:combat" }), + ), + ); + await screen.findByText("已保存为待确认偏好;在下方列表中审阅后才会生效。"); +});