mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:41:57 +00:00
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 并注明理由
113 lines
4.7 KiB
Python
113 lines
4.7 KiB
Python
"""重排:让模型把初筛结果重新排一遍。
|
||
|
||
混合检索给的是"大致相关"——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
|