Files
AgentLab/backend/app/memory/rerank.py
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

113 lines
4.7 KiB
Python
Raw 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!r}")
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.api.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