Files
AgentLab/backend/app/memory/rerank.py
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

113 lines
4.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""重排:让模型把初筛结果重新排一遍。
混合检索给的是"大致相关"——BM25 看词频,向量看空间距离,两者都不读句子。
重排让模型把 query 和每个候选放在一起判断,这是检索链路上单点收益最大的一步。
用已配置的对话模型做,不引入本地 cross-encoder:后者要下模型权重,和这个
项目"零配置能跑起来"的基线冲突(同理不照抄 RAGFlow 那套 Elasticsearch +
Infinity,官方建议 32GB 内存起)。代价是每次检索多一次模型调用,所以默认关闭。
失败一律退回原顺序:重排是增强,不该因为模型抽风或没配 key 就让整个检索挂掉。
"""
from __future__ import annotations
import json
import re
from typing import Any
_PROMPT = """给下面每个片段打分:它对回答这个问题有多大帮助。
问题:{query}
片段:
{candidates}
只输出一行 JSON,形如 {{"scores": [{example}]}}——{n} 个 0 到 10 的整数,
按片段顺序一一对应。不要输出任何别的东西。
0 表示完全无关,10 表示直接回答了问题。"""
class Unusable(ValueError):
"""回复用不了。message 就是要交给用户的那句话。"""
def _parse_scores(text: str, n: int) -> list[float]:
"""从模型回复里抠出分数数组。用不了就抛 Unusable,message 说清楚是哪种用不了。
模型偶尔会加围栏或者说两句废话,所以不直接 json.loads 整段;
但也不做过度容错——解析不出来就退回原顺序,比按半截数据重排安全。
"""
if not text.strip():
# 实测 deepseek-v4-pro:失败那次 out_tokens 正好等于 max_tokens,
# 输出预算被耗光后正文什么都不剩。报"解析不出来"会把人引去查 JSON 格式,
# 而该调的是 max_tokens——llm.py 里对同一个现象也是这么说的
raise Unusable("模型没有输出正文,可能是输出 token 预算已用完。请调大节点的「最大输出 token」")
match = re.search(r"\{[^{}]*\"scores\"\s*:\s*\[[^\]]*\][^{}]*\}", text, re.S)
if not match:
raise Unusable(f"模型回复中找不到 scores:{text.strip()[:80]}")
try:
scores = json.loads(match.group(0)).get("scores")
except json.JSONDecodeError as e:
raise Unusable(f"scores 不是合法 JSON:{e}") from e
if not isinstance(scores, list) or len(scores) != n:
# 个数对不上就没法一一对应,硬凑只会把顺序搅乱
raise Unusable(f"模型给出 {len(scores) if isinstance(scores, list) else '?'} 个分数,"
f"候选有 {n} 个,数量不一致")
out: list[float] = []
for s in scores:
if not isinstance(s, (int, float)):
raise Unusable(f"分数中含有非数字:「{s}」")
out.append(float(s))
return out
async def rerank(
model: Any, query: str, hits: list[dict[str, Any]], *, top_n: int,
on_note: Any = None,
) -> list[dict[str, Any]]:
"""把 hits 重排后取前 top_n。失败原样返回前 top_n。
model 是任意带 ainvoke 的 LangChain 模型;传 None 表示不重排。
"""
if not hits or model is None or len(hits) <= 1:
return hits[:top_n]
listing = "\n\n".join(
# 截断是必要的:初筛 20 条 × 每条 800 字会把 prompt 撑到两万字,
# 而判断相关性用不着整段
f"[{i + 1}] {h.get('content', '')[:400]}"
for i, h in enumerate(hits)
)
prompt = _PROMPT.format(
query=query, candidates=listing, n=len(hits),
example=", ".join(["8"] * min(len(hits), 3)) + (", …" if len(hits) > 3 else ""),
)
try:
reply = await model.ainvoke(prompt)
text = reply if isinstance(reply, str) else getattr(reply, "content", "")
if isinstance(text, list): # Anthropic 的块状 content
text = "".join(b.get("text", "") for b in text if isinstance(b, dict))
scores = _parse_scores(str(text), len(hits))
except Unusable as e:
if on_note:
on_note(f"重排不可用,已按初筛顺序返回:{e}")
return hits[:top_n]
except Exception as e: # noqa: BLE001
if on_note:
from app.core.errors import explain
on_note(f"重排失败,已按初筛顺序返回:{explain(e)[0]}")
return hits[:top_n]
ranked = sorted(zip(hits, scores), key=lambda t: t[1], reverse=True)
out: list[dict[str, Any]] = []
for hit, score in ranked[:top_n]:
item = dict(hit)
item["rerank_score"] = score
# 初筛分保留:重排把它挪到第几位、原来是第几位,事后要能对照
item.setdefault("signals", {})
out.append(item)
return out