feat(作者经验): 复利机制批次4——确定性推断偏好建议、采纳确认链与复利度量统计

This commit is contained in:
zizi 2026-09-19 01:12:15 +08:00
parent f0a61286df
commit 2d0410d053
20 changed files with 939 additions and 2 deletions

View File

@ -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": { "/api/v1/improvements": {
"post": { "post": {
"tags": [ "tags": [

View File

@ -21,6 +21,7 @@
| 核对方法评测投影 | 同用途元数据连接和已封存材料 | 重验内容/结构/字段策略哈希;评测不直接读正式方法表 | | 核对方法评测投影 | 同用途元数据连接和已封存材料 | 重验内容/结构/字段策略哈希;评测不直接读正式方法表 |
| 读取消费统计 | 作者、方法版本范围 | 按版本聚合的实际消费计数,供 B09 偏好重排的强信号路;只读,不改消费台账,不把被检索或任务成功当作消费 | | 读取消费统计 | 作者、方法版本范围 | 按版本聚合的实际消费计数,供 B09 偏好重排的强信号路;只读,不改消费台账,不把被检索或任务成功当作消费 |
| 读取参考材料 | 作者、确切方法版本清单、两种用途 | 当前且已确认或启用版本的参考材料渲染与哈希,供参考改写冻结与依赖复检;不查启用凭据、不写消费 | | 读取参考材料 | 作者、确切方法版本清单、两种用途 | 当前且已确认或启用版本的参考材料渲染与哈希,供参考改写冻结与依赖复检;不查启用凭据、不写消费 |
| 读取版本头 | 方法版本清单 | 版本→标题/类型/状态/是否当前的只读窄头,非法或缺失身份跳过;供 B07 推断建议渲染标题,不展开材料 |
所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。 所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。

View File

@ -25,6 +25,8 @@
| 读取运行观察 / 列出运行观察 / 检索重复问题 | 当前作者、观察或任务身份、可选问题键 | 历史观察与按实际任务计数的重复问题,不生成效果判断 | | 读取运行观察 / 列出运行观察 / 检索重复问题 | 当前作者、观察或任务身份、可选问题键 | 历史观察与按实际任务计数的重复问题,不生成效果判断 |
| 记录参考挑选反馈 | 检索回执引用、领域标签、选中与拒绝的方法版本清单 | 只追加参考挑选反馈;进入数值信号轨,不直接生成文本偏好 | | 记录参考挑选反馈 | 检索回执引用、领域标签、选中与拒绝的方法版本清单 | 只追加参考挑选反馈;进入数值信号轨,不直接生成文本偏好 |
| 读取挑选信号 | 作者、可选领域与方法版本范围 | 选用/拒绝计数与位次聚合,供 B09 偏好重排消费;只读,不改台账 | | 读取挑选信号 | 作者、可选领域与方法版本范围 | 选用/拒绝计数与位次聚合,供 B09 偏好重排消费;只读,不改台账 |
| 汇总推断偏好建议 | 作者、可选领域 | 确定性聚合的只读建议(含真实原话依据与内联证据);不落库,采纳走既有保存→确认链 |
| 读取复利度量 | 作者 | 台账统计:首稿采纳率、点踩率等;分母为零保持空值,不构成因果结论 |
所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。 所有接口在本模块的接口.py 暴露。输入输出类型由同一公开表面导出,外部不导入私有模型或 SQL。
@ -36,7 +38,8 @@
| 模型.py | 作者经验稳定对象、外壳及内部状态 | | 模型.py | 作者经验稳定对象、外壳及内部状态 |
| 存储.py | 作者经验所属记录的 SQL 与版本读写 | | 存储.py | 作者经验所属记录的 SQL 与版本读写 |
| 作者偏好.py | 捕获、确认、范围、冲突和撤回 | | 作者偏好.py | 捕获、确认、范围、冲突和撤回 |
| 反馈对.py | 原稿、改稿、作者原话及决策 | | 反馈对.py | 原稿、改稿、作者原话及决策;参考挑选反馈与挑选信号读取 |
| 推断偏好.py | 确定性推断建议聚合与复利度量统计 |
| 观察记录.py | 真实反馈对上的作者自报观察 | | 观察记录.py | 真实反馈对上的作者自报观察 |
| 运行观察.py | 实际运行来源、业务回执核对、运行观察与重复问题检索 | | 运行观察.py | 实际运行来源、业务回执核对、运行观察与重复问题检索 |
| 改进提案.py | 具体目标变更、版本及内容哈希 | | 改进提案.py | 具体目标变更、版本及内容哈希 |
@ -67,7 +70,7 @@
- **文本偏好轨**(muse_preference):topic/text/scope 的人可读语句,经偏好材料注入写手提示词,由作者裁决生命周期(pending→active→withdrawn),以偏好绑定进入冻结快照。文本轨不参与检索排序。 - **文本偏好轨**(muse_preference):topic/text/scope 的人可读语句,经偏好材料注入写手提示词,由作者裁决生命周期(pending→active→withdrawn),以偏好绑定进入冻结快照。文本轨不参与检索排序。
- **数值信号轨**(参考挑选反馈与 B04 消费统计的聚合):仅供 B09 方法检索的偏好重排产生偏好分,权重经人工审查版本化。数值轨不冒充作者偏好,不自动生成生效文本。 - **数值信号轨**(参考挑选反馈与 B04 消费统计的聚合):仅供 B09 方法检索的偏好重排产生偏好分,权重经人工审查版本化。数值轨不冒充作者偏好,不自动生成生效文本。
推断偏好是数值轨到文本轨的受控翻译:确定性聚合脚本(不调用模型)从信号模式产出 origin=inferred 的 pending 偏好,卡片内联证据(如"近 30 次挑选中被选 12 次"),作者显式确认后才 active,确认沿用既有冲突管理合同。护栏:单条推断需 ≥5 个支撑事件才生成;每次呈现摘要 ≤5 条;v1 不做权重自动调参——重排权重只经人工审查的版本号变更,作者主权延伸到排序策略。 推断偏好是数值轨到文本轨的受控翻译:确定性聚合(不调用模型)从参考挑选台账生成偏好建议;建议是只读呈现面,不自动落库,采纳经既有保存→审阅→确认链成为 origin=inferred 的 pending 偏好,确认沿用既有冲突管理合同,不自动长期生效。护栏:单版本 ≥5 个支撑事件才提出;每次至多返回 5 条,按证据强度降序;建议必须携带一条真实作者反馈原话作依据,保存时校验原话与该反馈一致;v1 不做权重自动调参——重排权重只经人工审查的版本号变更,作者主权延伸到排序策略。复利度量是台账的确定性统计(首稿采纳率、点踩率等):比率分母为零保持空值不混算零,只作趋势呈现,不构成因果结论。语义归纳升级(模型抽象偏好)须另立受控提案,产物同样只进 pending。
### 具体方法改进与用途交接 ### 具体方法改进与用途交接

View File

@ -132,6 +132,58 @@ class 经验存储:
for r in 行 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): def 数据库标识(self):
return self.连.execute( return self.连.execute(
"SELECT current_database(),pg_postmaster_start_time()," "SELECT current_database(),pg_postmaster_start_time(),"

View File

@ -10,6 +10,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 (
偏好决定, 偏好决定,
偏好范围, 偏好范围,
@ -211,6 +212,17 @@ class 作者经验服务:
with self.数据库.连接(只读=True) as 连: with self.数据库.连接(只读=True) as 连:
return 读取挑选信号(连, 身份.作者, 领域=领域) return 读取挑选信号(连, 身份.作者, 领域=领域)
def 汇总推断偏好建议(self, 身份, *, 领域=""):
"""确定性聚合的 pending 偏好建议;只读呈现,采纳走既有保存→确认链。"""
self._身份(身份)
with self.数据库.连接(只读=True) as 连:
return 汇总推断建议(连, 身份.作者, 领域=领域)
def 读取复利度量(self, 身份):
self._身份(身份)
with self.数据库.连接(只读=True) as 连:
return 读取复利度量(连, 身份.作者)
def 保存偏好(self, 身份, 命令ID, 请求: 偏好请求): def 保存偏好(self, 身份, 命令ID, 请求: 偏好请求):
self._身份(身份) self._身份(身份)
return self.正式.提交变更( return self.正式.提交变更(
@ -312,6 +324,8 @@ __all__ = [
"挑选候选", "挑选候选",
"保存参考挑选", "保存参考挑选",
"读取挑选信号", "读取挑选信号",
"汇总推断建议",
"读取复利度量",
"经验错误", "经验错误",
"读取偏好材料", "读取偏好材料",
] ]

View File

@ -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__ = [
"聚合挑选明细",
"生成建议",
"汇总推断建议",
"读取复利度量",
]

View File

@ -117,6 +117,8 @@ def _解析参数(argv: list[str] | None) -> argparse.Namespace:
"重复问题", "重复问题",
"记录参考挑选", "记录参考挑选",
"挑选信号", "挑选信号",
"推断建议",
"复利度量",
], ],
) )
经验解析.add_argument("目标", nargs="?", help="改进身份或完整请求JSON") 经验解析.add_argument("目标", nargs="?", help="改进身份或完整请求JSON")

View File

@ -53,6 +53,10 @@ def 运行经验命令(app, 动作, 目标, *, 评测配置=None):
return service.检索重复问题(identity, 问题键=目标) return service.检索重复问题(identity, 问题键=目标)
if 动作 == "挑选信号": if 动作 == "挑选信号":
return service.读取参考挑选信号(identity, 领域=目标 or "") return service.读取参考挑选信号(identity, 领域=目标 or "")
if 动作 == "推断建议":
return service.汇总推断偏好建议(identity, 领域=目标 or "")
if 动作 == "复利度量":
return service.读取复利度量(identity)
if not 目标: if not 目标:
raise 配置错误("此动作需要确切对象或请求JSON文件") raise 配置错误("此动作需要确切对象或请求JSON文件")
if 动作 == "运行观察": if 动作 == "运行观察":

View File

@ -14,7 +14,9 @@ from muse.接入.经验操作 import (
参考挑选输入, 参考挑选输入,
规则改进启停输入, 规则改进启停输入,
规则改进输入, 规则改进输入,
读取复利度量视图,
读取挑选信号视图, 读取挑选信号视图,
读取推断建议视图,
读取运行来源, 读取运行来源,
运行来源请求, 运行来源请求,
运行观察保存请求, 运行观察保存请求,
@ -125,6 +127,22 @@ def 读挑选信号(
return 读取挑选信号视图(app, 创作身份(作者, app.配置), 领域=domain) 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") @路由.post("/improvements", operation_id="propose_improvement")
def 提出改进(body: 改进输入, request: Request, 作者: 作者依赖): def 提出改进(body: 改进输入, request: Request, 作者: 作者依赖):
svc, identity = _服务(request, 作者) svc, identity = _服务(request, 作者)

View File

@ -92,3 +92,11 @@ def 保存参考挑选反馈(app, identity, request: 参考挑选输入):
def 读取挑选信号视图(app, identity, *, 领域=""): def 读取挑选信号视图(app, identity, *, 领域=""):
return app.要求作者经验().读取参考挑选信号(identity, 领域=领域) return app.要求作者经验().读取参考挑选信号(identity, 领域=领域)
def 读取推断建议视图(app, identity, *, 领域=""):
return app.要求作者经验().汇总推断偏好建议(identity, 领域=领域)
def 读取复利度量视图(app, identity):
return app.要求作者经验().读取复利度量(identity)

View File

@ -198,6 +198,20 @@ class 方法存储:
(version_id,), (version_id,),
).fetchone() ).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: def 按哈希读版本(self, method_id: str, content_hash: str) -> dict[str, Any] | None:
return self._查( return self._查(
"SELECT version_id,version FROM muse_method_version " "SELECT version_id,version FROM muse_method_version "

View File

@ -18,6 +18,7 @@ from muse.知识方法.方法卡 import (
读取方法检索资料, 读取方法检索资料,
读取方法检索身份, 读取方法检索身份,
读取方法索引维护资料, 读取方法索引维护资料,
读取版本头,
读取版本材料大小, 读取版本材料大小,
) )
from muse.知识方法.方法版本 import ( from muse.知识方法.方法版本 import (
@ -319,6 +320,7 @@ __all__ = [
"读取方法检索资料", "读取方法检索资料",
"读取方法检索候选", "读取方法检索候选",
"读取参考材料", "读取参考材料",
"读取版本头",
"读取消费统计", "读取消费统计",
"读取方法检索身份", "读取方法检索身份",
"读取方法索引维护资料", "读取方法索引维护资料",

View File

@ -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 读取参考材料( def 读取参考材料(
连, 连,
version_ids: tuple[str, ...], version_ids: tuple[str, ...],

View File

@ -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)

View File

@ -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", "case_id": "NC-work-current-binding",
"environment": "隔离 PostgreSQL;结构升级为明确的已完成状态夹具,不代替作者升级旅程", "environment": "隔离 PostgreSQL;结构升级为明确的已完成状态夹具,不代替作者升级旅程",

View File

@ -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") == []

View File

@ -9,14 +9,23 @@ import {
打开审阅, 打开审阅,
确认偏好, 确认偏好,
撤回偏好, 撤回偏好,
列出推断建议,
读取复利度量,
采纳建议,
type 偏好行, type 偏好行,
type 偏好审阅, type 偏好审阅,
type 偏好定位, type 偏好定位,
type 建议行,
} from "./数据"; } from "./数据";
export function Ui偏好页() { export function Ui偏好页() {
const cache = useQueryClient(); const cache = useQueryClient();
const rows = useQuery({ queryKey: ["作者偏好"], queryFn: 列出偏好 }); const rows = useQuery({ queryKey: ["作者偏好"], queryFn: 列出偏好 });
const suggestions = useQuery({
queryKey: ["推断偏好建议"],
queryFn: () => 列出推断建议(),
});
const metrics = useQuery({ queryKey: ["复利度量"], queryFn: 读取复利度量 });
const works = useQuery({ const works = useQuery({
queryKey: ["作品列表"], queryKey: ["作品列表"],
queryFn: ({ signal }) => 列出作品(signal), queryFn: ({ signal }) => 列出作品(signal),
@ -64,6 +73,22 @@ export function Ui偏好页() {
String( String(
works.data?.find((w) => w.work_id === id)?.content.名称 ?? "指定作品", 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 ( return (
<main className="content"> <main className="content">
<p className="eyebrow">作者经验</p> <p className="eyebrow">作者经验</p>
@ -128,6 +153,46 @@ export function Ui偏好页() {
</form> </form>
<p role="status">{message}</p> <p role="status">{message}</p>
</section> </section>
<section className="paper-panel" aria-label="复利度量">
<h2>复利度量</h2>
{metrics.data ? (
<>
<p>
首稿采纳率 {百分比(metrics.data.改写对照.首稿采纳率)}(改写对照{" "}
{metrics.data.改写对照.总数} 条) · 参考挑选点踩率{" "}
{百分比(metrics.data.参考挑选.点踩率)}(选用{" "}
{metrics.data.参考挑选.选用次数} 次 / 拒绝{" "}
{metrics.data.参考挑选.拒绝次数} 次)
</p>
<p className="muted">{metrics.data.说明}</p>
</>
) : (
<p>正在读取度量…</p>
)}
</section>
<section className="paper-panel" aria-label="推断偏好建议">
<h2>推断建议</h2>
<p className="muted">
来自参考挑选台账的确定性统计(单版本 ≥5 次支撑事件才提出)。
采纳只保存为待确认偏好,经你确认后才会生效。
</p>
{suggestions.data?.length === 0 && <p>暂无达到门槛的建议。</p>}
{suggestions.data?.map((建议) => (
<article key={建议.method_version_id + 建议.topic + 建议.text}>
<h3>{建议.证据.方法标题}</h3>
<p>{建议.text}</p>
<p className="muted">
依据:选用 {建议.证据.选用次数} 次 · 拒绝 {建议.证据.拒绝次数}{" "}
次 · 支撑事件 {建议.支撑事件}
</p>
<blockquote>{建议.quote}</blockquote>
<button disabled={busy} onClick={() => void 采纳(建议)}>
采纳为待确认偏好
</button>
</article>
))}
{suggestions.error && <p role="alert">{suggestions.error.message}</p>}
</section>
{rows.error && <p role="alert">{rows.error.message}</p>} {rows.error && <p role="alert">{rows.error.message}</p>}
{rows.isPending && <p>正在读取偏好…</p>} {rows.isPending && <p>正在读取偏好…</p>}
{rows.data?.length === 0 && <p>还没有保存偏好。</p>} {rows.data?.length === 0 && <p>还没有保存偏好。</p>}

View File

@ -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,
},
},
}),
);
}

View File

@ -2051,6 +2051,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/improvements": {
parameters: { parameters: {
query?: never; 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: { propose_improvement: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -16,6 +16,9 @@ const api = vi.hoisted(() => ({
review: vi.fn(), review: vi.fn(),
confirm: vi.fn(), confirm: vi.fn(),
revoke: vi.fn(), revoke: vi.fn(),
suggest: vi.fn(),
metrics: vi.fn(),
adopt: vi.fn(),
})); }));
vi.mock("../../src/功能/作者经验/数据", () => ({ vi.mock("../../src/功能/作者经验/数据", () => ({
列出偏好: api.list, 列出偏好: api.list,
@ -23,6 +26,9 @@ vi.mock("../../src/功能/作者经验/数据", () => ({
打开审阅: api.review, 打开审阅: api.review,
确认偏好: api.confirm, 确认偏好: api.confirm,
撤回偏好: api.revoke, 撤回偏好: api.revoke,
列出推断建议: () => api.suggest(),
读取复利度量: () => api.metrics(),
采纳建议: api.adopt,
})); }));
vi.mock("../../src/功能/作品规划/数据", () => ({ 列出作品: async () => [] })); vi.mock("../../src/功能/作品规划/数据", () => ({ 列出作品: async () => [] }));
function row(id = "pending", state = "pending", text = "用动作显现心理。") { function row(id = "pending", state = "pending", text = "用动作显现心理。") {
@ -67,6 +73,15 @@ beforeEach(() => {
api.confirm.mockResolvedValue({}); api.confirm.mockResolvedValue({});
api.revoke.mockResolvedValue({}); api.revoke.mockResolvedValue({});
api.save.mockResolvedValue({ results: [{ state: "active" }] }); 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); 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("已保存为待确认偏好;在下方列表中审阅后才会生效。");
});