Files
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

366 lines
14 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.
from __future__ import annotations
import hashlib
import math
import os
import re
from typing import Any, NamedTuple, Sequence
import numpy as np
_DIM = 512
_TOKEN_RE = re.compile(r"[a-zA-Z0-9_]+|[一-鿿]")
def _tokens(text: str) -> list[str]:
"""英文按词、中文按字切,再补 2-gram。
中文没有空格分词,只用单字召回太糊;加上相邻二字组合能显著提升短语匹配。
"""
raw = _TOKEN_RE.findall(text.lower())
grams = [a + b for a, b in zip(raw, raw[1:]) if len(a) == 1 and len(b) == 1]
return raw + grams
def _hash_index(token: str) -> int:
return int.from_bytes(hashlib.blake2b(token.encode(), digest_size=4).digest(), "big") % _DIM
class LocalEmbedder:
"""特征哈希 + TF 的本地向量器。
不联网、不下模型、零配置就能用。语义泛化能力当然不如真 embedding 模型,
所以检索侧用它和 BM25 做混合,把关键词精确匹配的能力补回来。
"""
dim = _DIM
name = "local-hashing"
def embed(self, text: str) -> np.ndarray:
vec = np.zeros(_DIM, dtype=np.float32)
toks = _tokens(text)
if not toks:
return vec
for tok in toks:
vec[_hash_index(tok)] += 1.0
# 次线性缩放,压掉高频词的统治力
vec = np.sign(vec) * np.log1p(np.abs(vec))
norm = np.linalg.norm(vec)
return vec / norm if norm > 0 else vec
def embed_many(self, texts: Sequence[str]) -> np.ndarray:
return np.vstack([self.embed(t) for t in texts]) if texts else np.zeros((0, _DIM), np.float32)
class OpenAIEmbedder:
"""任何讲 OpenAI /v1/embeddings 的服务:官方 API、也可以是本地起的
LM Studio / Ollama / vLLM / TEI。区别只在 base_url。"""
name = "openai"
def __init__(self, model: str = "text-embedding-3-small", api_key: str | None = None,
base_url: str | None = None) -> None:
from langchain_openai import OpenAIEmbeddings
self.model = model
self.base_url = base_url
kwargs: dict[str, Any] = {
"model": model,
# 本地服务通常不校验 key,但 SDK 要求非空
"api_key": api_key or os.environ.get("OPENAI_API_KEY") or "not-needed",
"base_url": base_url,
# 远端接口单次批量有上限(OpenAI 是 2048),langchain 按这个分块发
"chunk_size": 512,
}
if base_url:
# langchain 默认先把文本分词、发 token 数组过去——OpenAI 官方认,
# 但 LM Studio 这类本地服务只收字符串,会报
# "'input' field must be a string or an array of strings"。
# 关掉之后发原文。我们的片段都在 800 字上下,也用不着那套长度保护
kwargs["check_embedding_ctx_length"] = False
self._impl = OpenAIEmbeddings(**kwargs)
# 维度必须问出来,不能按模型名猜。本地部署的模型五花八门——
# bge-m3 是 1024、Qwen3-Embedding-4B 是 2560,猜错的后果是所有存量
# 向量都被判成"和当前 embedder 对不上",检索整体退回关键词
self.dim = len(self._impl.embed_query("dim probe"))
async def aembed(self, text: str) -> np.ndarray:
vec = await self._impl.aembed_query(text)
return np.array(vec, dtype=np.float32)
async def aembed_many(self, texts: Sequence[str]) -> np.ndarray:
vecs = await self._impl.aembed_documents(list(texts))
return np.array(vecs, dtype=np.float32)
_local = LocalEmbedder()
#: 设置表里存 embedding 配置的键。和 Copilot 模型同一套路子
EMBEDDING_SETTING_KEY = "embedding"
#: 进程内缓存。get_embedder 在检索热路径上被反复调用,不该每次都去读库
_cached: tuple[str, Any] | None = None
#: 保存的配置为什么没生效。启动时本机的模型服务还没起就会这样:整个进程一直用
#: 本地哈希,服务后来起了也不会自己连上。以前只在服务端日志里留一句,界面上看着
#: 一切正常,检索却一直是纯关键词
_unavailable: str | None = None
class EmbedderUnavailable(RuntimeError):
"""配了远端 embedder 但建不起来。message 直接给用户看。"""
def _make(kind: str, model: str, base_url: str = "", *,
strict: bool) -> "LocalEmbedder | OpenAIEmbedder":
if kind != "openai":
return _local
try:
return OpenAIEmbedder(model=model or "text-embedding-3-small",
base_url=base_url or None)
except Exception as e: # noqa: BLE001
if strict:
# 人明确选了远端就不能偷偷退回本地:他会以为自己在用语义检索,
# 而实际拿到的是哈希词袋——搜不出同义表达时完全无从怀疑到这里
where = base_url or "api.openai.com"
raise EmbedderUnavailable(
f"无法使用 {where} 上的向量模型 "
f"{model or 'text-embedding-3-small'}:{_why(e)}。"
f"请检查服务是否可用、模型名是否正确;使用官方接口时还需检查 OPENAI_API_KEY。"
) from e
return _local
def configure(kind: str, model: str = "", base_url: str = "") -> None:
"""切换当前 embedder。设置页保存后调用,也用于测试。建不起来会抛。"""
global _cached, _unavailable
_cached = (f"{kind}:{model}:{base_url}", _make(kind, model, base_url, strict=True))
_unavailable = None
def unavailable_reason() -> str | None:
"""保存的配置没能生效的原因;生效了(或还没试过)是 None。"""
return _unavailable
def get_embedder() -> "LocalEmbedder | OpenAIEmbedder":
"""当前 embedder。
优先级:进程内已配置 > 环境变量兜底 > 本地。设置页写库后会调 configure,
所以这里不再每次读库——检索热路径上一次查询要走几百次。
以前这里只认 AGENTLAB_USE_OPENAI_EMBEDDINGS 一个环境变量:没有界面、
没人会发现,于是所有人都在用那个没有语义能力的哈希向量,而 alpha 默认
0.5 还给了它一半权重。
"""
if _cached is not None:
return _cached[1]
if os.environ.get("AGENTLAB_USE_OPENAI_EMBEDDINGS") and os.environ.get("OPENAI_API_KEY"):
return _make("openai", "", strict=False)
return _local
def embedder_id() -> str:
"""当前 embedder 的身份。存进 chunk 那一列,检索时拿它判断存量向量还能不能用。"""
emb = get_embedder()
name = getattr(emb, "name", "unknown")
model = getattr(emb, "model", "")
return f"{name}:{model}" if model else name
def embedder_dim() -> int:
return int(getattr(get_embedder(), "dim", _DIM))
def has_semantics() -> bool:
"""当前 embedder 有没有语义泛化能力。
LocalEmbedder 是词频哈希:它和 BM25 吃同一批词,给它权重等于把关键词
信号打个折再加回自己身上,同义改写一条都召不回。
"""
return getattr(get_embedder(), "name", "") != "local-hashing"
def _why(e: BaseException) -> str:
from app.core.errors import explain
return explain(e)[0]
def default_alpha() -> float:
"""向量那一路默认占多少权重。
实测(backend/tests/test_retrieval_quality.py,24 篇语料 / 16 问):
本地哈希向量下 alpha=0 的 hit@1 是 88%,alpha 一旦大于 0 就掉到 81%
——那一路不是"弱一点",是纯噪音。所以没有语义能力时默认给 0。
"""
return 0.5 if has_semantics() else 0.0
def usable(embed_model: str | None, embed_dim: int | None) -> bool:
"""这条存量向量还能不能和当前查询比对。
维度对不上 cosine 会直接抛 ValueError(shapes (1536,) and (512,) not
aligned),所以这不是"质量差一点",是会 500。模型名也要比:同维度不同
模型的向量空间没有可比性,算出来的相似度是随机数。
存量数据这两列是空的(迁移回填的默认值),一律当成不可用——保守,
但总比拿旧模型的向量冒充当前空间里的坐标强。
"""
return bool(embed_model) and embed_model == embedder_id() and embed_dim == embedder_dim()
async def load_setting(session: Any) -> None:
"""从库里读 embedding 配置并生效。应用启动时调一次。"""
from app.db.models import Setting
global _unavailable
row = await session.get(Setting, EMBEDDING_SETTING_KEY)
saved = (row.value if row else {}) or {}
kind = saved.get("kind")
if kind:
try:
configure(kind, saved.get("model", ""), saved.get("base_url", ""))
except EmbedderUnavailable as e:
_unavailable = str(e)
raise
async def embed_text(text: str) -> np.ndarray:
emb = get_embedder()
if isinstance(emb, OpenAIEmbedder):
return await emb.aembed(text)
return emb.embed(text)
async def embed_texts(texts: Sequence[str]) -> np.ndarray:
emb = get_embedder()
if isinstance(emb, OpenAIEmbedder):
return await emb.aembed_many(texts)
return emb.embed_many(texts)
def to_blob(vec: np.ndarray) -> bytes:
return vec.astype(np.float32).tobytes()
def from_blob(blob: bytes | None, dim: int | None = None) -> np.ndarray | None:
if not blob:
return None
vec = np.frombuffer(blob, dtype=np.float32)
if dim and vec.shape[0] != dim:
return None
return vec
def cosine(a: np.ndarray, b: np.ndarray) -> float:
na, nb = np.linalg.norm(a), np.linalg.norm(b)
if na == 0 or nb == 0:
return 0.0
return float(np.dot(a, b) / (na * nb))
# --------------------------------------------------------------------------
# BM25:关键词侧的一半
# --------------------------------------------------------------------------
class BM25:
def __init__(self, corpus: Sequence[str], k1: float = 1.5, b: float = 0.75) -> None:
self.k1, self.b = k1, b
self.docs = [_tokens(d) for d in corpus]
self.n = len(self.docs)
self.avg_len = sum(len(d) for d in self.docs) / self.n if self.n else 0.0
self.df: dict[str, int] = {}
for doc in self.docs:
for tok in set(doc):
self.df[tok] = self.df.get(tok, 0) + 1
def score(self, query: str) -> list[float]:
q_tokens = _tokens(query)
scores = [0.0] * self.n
for tok in q_tokens:
df = self.df.get(tok, 0)
if df == 0:
continue
idf = math.log(1 + (self.n - df + 0.5) / (df + 0.5))
for i, doc in enumerate(self.docs):
tf = doc.count(tok)
if tf == 0:
continue
denom = tf + self.k1 * (1 - self.b + self.b * len(doc) / (self.avg_len or 1))
scores[i] += idf * (tf * (self.k1 + 1)) / denom
return scores
class Ranked(NamedTuple):
"""一条混合排序的结果。contrib 两项之和就是 score。"""
index: int
score: float
#: 两路的原始分:余弦、BM25
signals: dict[str, float]
#: 两路各自对总分的贡献:归一化之后乘上 α / 1-α
contrib: dict[str, float]
def hybrid_contrib(
query: str,
texts: Sequence[str],
vectors: Sequence[np.ndarray | None],
query_vec: np.ndarray,
*,
alpha: float = 0.5,
keyword_scores: Sequence[float] | None = None,
) -> list[Ranked]:
"""向量相似度和 BM25 各归一化后加权合并,并交出每一路的贡献。
纯向量会漏掉精确的专有名词,纯 BM25 又抓不住同义表达,
混合排序在本地小知识库上稳定优于任何一个单独使用。
keyword_scores 传进来就不再自己算 BM25。倒排索引那条路必须传:它按**全集**
统计(N、avg_len、df)算好了分,而这里只拿到候选子集——在子集上重算,
df 和平均长度全是另一套数,排出来的名次和全表扫不一样。真踩过,
test_the_index_ranks_the_same_as_a_full_scan 就是为此写的。
贡献要在这里算:归一化是在整个候选集上做的,调用方只拿得到前几条,
复现不了。
"""
if not texts:
return []
kw_scores = list(keyword_scores) if keyword_scores is not None else BM25(texts).score(query)
vec_scores = [cosine(query_vec, v) if v is not None else 0.0 for v in vectors]
def _norm(xs: list[float]) -> list[float]:
lo, hi = min(xs), max(xs)
if hi - lo < 1e-9:
# 全都一样分,说明这一路信号区分不出高下——那是"都一样好",
# 不是"都一样差"。返回 0 会让候选只有一条时它的合并分恒为 0,
# 被 min_score 过滤掉,于是新建 scope 写入第一条后永远搜不到,
# 而"先写一条、下一轮再 recall"正是 agent 最常见的用法。
return [1.0 for _ in xs]
return [(x - lo) / (hi - lo) for x in xs]
kw_n, vec_n = _norm(kw_scores), _norm(vec_scores)
ranked: list[Ranked] = []
for i in range(len(texts)):
by_vec, by_kw = alpha * vec_n[i], (1 - alpha) * kw_n[i]
ranked.append(Ranked(i, by_vec + by_kw, {"vector": vec_scores[i], "keyword": kw_scores[i]},
{"vector": by_vec, "keyword": by_kw}))
ranked.sort(key=lambda r: r.score, reverse=True)
return ranked
def hybrid_rank(
query: str,
texts: Sequence[str],
vectors: Sequence[np.ndarray | None],
query_vec: np.ndarray,
*,
alpha: float = 0.5,
keyword_scores: Sequence[float] | None = None,
) -> list[tuple[int, float, dict[str, float]]]:
"""同 hybrid_contrib,只要 (下标, 总分, 原始分)。"""
return [(r.index, r.score, r.signals) for r in hybrid_contrib(
query, texts, vectors, query_vec, alpha=alpha, keyword_scores=keyword_scores)]