Files
AgentLab/backend/app/chat/questions.py
T
ThearchyHelios 0d9f1d9339 用户看得到的列表补决胜键(复核 R-a9):按名称、时间等可能并列的排序后面补上同一个模型的 id,并列时两种库排出来一样
- 实测 /api/evals/calibers、/api/schedules/trigger-options 按 Workflow.name 排序遇同名并列时 SQLite、PostgreSQL 顺序不同。用 AST 逐个过了一遍 order_by:最后一个排序键是 created_at / updated_at / last_active_at / at / name / 各种 *_at 之类、不唯一的列,又不带 DISTINCT、GROUP BY 的,补「, 模型.id」(降序补 id.desc()),共 104 处,分布在 api、apps、auth、blueprints、chat、data、engine、eval、ledger、look、memory、process、providers、scheduler(runs.py 里的三处在上一个提交里)
- 按唯一列(seq、version、唯一的 name)排的不动;按聚合、coalesce 之类表达式排的说明见 db/postgres
2026-10-19 12:07:06 +08:00

209 lines
11 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.
"""问过的问题和它们的聚类:全站只有这一份(docs/design/next-round/00-总方案.md X2)。
同一个问题被反复问,就该长成模板里的固定一节、沉淀成口径——探索问题聚类(/governance/exploratory-clusters)、以后口径
账本的「可沉淀的问题」、评测的问题重复度(E3)量的都是这件事,各算一份的话几个数对不上,所以取问题和聚类只写在这里。
**取什么。** 只取问题原文,不取整份输入:以前把运行的整份 input(含 history)拿去聚,同一段对话里前几轮的问答会把不同的
问题聚成一类,重复度偏高。
- 问数据:一轮一条(conversation_turns.question,用户原本问的那句话)。同一轮里的自动重来、用户点的「重试」只是换了这一轮
挂着的运行,不多出一条——按运行数的话,答坏了自动重来一次就被算成问了两遍。没挂运行的轮次(只回了一句、助手反问)
也算:问题照样被问过。
- 别的探索运行(画布试跑、蓝图试运行、接口……):输入里有 question 这个字段的取它;没有的不收(输入里别的字段不是问题)。
问数据的运行由上面一轮一条代表,这里不再数。
**不收什么。** 系统派生的运行(评测、口径对账、口径召回:engine/origin.counted_runs)不收,评测题不能把聚类刷高;
这个人读不了的只给拥有者的运行(auth/access.hidden_runs)不收;正式运行不收(探索才谈得上「沉淀」)。
聚类用 memory/embeddings 的向量贪心聚:按时间从新到旧,和某一类的种子足够像就并进去,否则自立一类。本地向量是词频
哈希、语义能力弱,结果是下限——调用方据 embedder 照实写明用的是哪个向量模型。
**类的 id 由成员定。** 每个问题有稳定的 id(Question.key:问数据是那一轮,别的探索运行是那次运行),一类的 id
(QuestionCluster.id)是成员的 id 排好之后取的哈希:同一批问题两次读取,类的 id 一样。以前按种子问题的原文算,种子是一类里
最新的那一句,同一批问题时间相同的几句先后不定、来了一句新的问法种子就换了,「不是口径」按 id 记就失效了。同一时刻问的
几句按 id 排出先后(recent_questions),不靠库里读出来的先后。来了新成员 id 照样会变:认证队列按成员记「不是口径」
(ledger/certify),不只按 id。
**向量只取一次。** 同一批问题会被聚很多遍:评测页「口径命中与重复度」一次打开,/api/evals/signals 按窗口调 compute_stats
(缺省 6 个窗口,每个都聚一次),页面另调 /api/ledger/stats;认证队列、治理页的探索问题聚类也各聚一遍。配了远程向量模型时
每聚一遍都把几百句问题重新发一次,页面慢、还花钱。所以问题原文 → 向量在进程里留一份(_vectors:最多 VECTOR_CACHE_MAX 句,
最近用到的留着),同一句话只取一次;一次里重复的问法(聚类量的正是这个)也只发一句。缓存认的是「哪一个 embedder、哪一个取向量
的函数」算的:设置页换了向量模型(memory/embeddings.configure 换了对象)就整份丢掉,不把两个向量空间的坐标混着比。取向量按事件
循环串行:页面并行取的两个接口不会把同一批问题各发一遍。
"""
from __future__ import annotations
import asyncio
import hashlib
import weakref
from collections import OrderedDict
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from sqlalchemy import func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
#: 缺省的相似度阈值(同治理接口一直以来的 0.62)
DEFAULT_THRESHOLD = 0.62
#: 进程里最多留几句问题的向量:取问题的地方一次最多 500 句(ledger/stats、治理接口的上限),留两批的量,窗口来回切不用重取
VECTOR_CACHE_MAX = 1000
@dataclass(frozen=True)
class Question:
"""问过的一个问题。turn_id 是问数据的那一轮(别的探索运行没有);run_id 是它挂着的运行(只回了一句的轮次没有)。"""
text: str
at: datetime | None
run_id: str | None = None
turn_id: str | None = None
workflow: str | None = None
origin: str | None = None
@property
def key(self) -> str:
"""稳定的 id:问数据的那一轮(turn:…),别的探索运行是那次运行(run:…);两样都没有的按原文和时间。"""
if self.turn_id:
return f"turn:{self.turn_id}"
if self.run_id:
return f"run:{self.run_id}"
stamp = self.at.isoformat() if self.at is not None else ""
return "text:" + hashlib.sha256(f"{self.text}\n{stamp}".encode()).hexdigest()[:16]
@dataclass
class QuestionCluster:
"""一类问题:members 按时间从新到旧,第一个是种子。"""
members: list[Question] = field(default_factory=list)
@property
def count(self) -> int:
return len(self.members)
@property
def keys(self) -> list[str]:
"""成员的稳定 id,排好的。"""
return sorted(q.key for q in self.members)
@property
def id(self) -> str:
"""类的 id:成员的稳定 id 排好之后取哈希(见模块说明)。同一批问题两次读取一样。"""
return hashlib.sha256("\n".join(self.keys).encode()).hexdigest()[:16]
async def recent_questions(session: AsyncSession, principal: Any, *, limit: int) -> list[Question]:
"""最近问过的问题,从新到旧,最多 limit 条(取法见模块开头)。principal 用来去掉这个人读不了的只给拥有者的运行。"""
from app.auth.access import hidden_runs
from app.db.models import Conversation, ConversationTurn, Run
from app.db.portable import json_is_string
from app.engine.origin import counted_runs
hidden = hidden_runs(principal)
turns = (await session.execute(
select(ConversationTurn.id, ConversationTurn.question, ConversationTurn.created_at, ConversationTurn.run_id,
Run.workflow_name)
.join(Conversation, Conversation.id == ConversationTurn.conversation_id)
.outerjoin(Run, Run.id == ConversationTurn.run_id)
.where(Conversation.kind == "chat", func.trim(ConversationTurn.question) != "", counted_runs(), not_(hidden))
.order_by(ConversationTurn.created_at.desc(), ConversationTurn.id.desc()).limit(limit)
)).all()
runs = (await session.execute(
select(Run.id, Run.input, Run.workflow_name, Run.created_at, Run.origin_kind)
.where(Run.run_class != "formal", func.coalesce(Run.origin_kind, "") != "chat", counted_runs(), not_(hidden),
json_is_string(Run.input, "question"))
.order_by(Run.created_at.desc(), Run.id.desc()).limit(limit)
)).all()
out = [Question(text=question.strip(), at=at, run_id=run_id, turn_id=turn_id, workflow=workflow, origin="chat")
for turn_id, question, at, run_id, workflow in turns]
for run_id, payload, workflow, at, origin in runs:
text = (payload or {}).get("question") if isinstance(payload, dict) else None
if isinstance(text, str) and text.strip():
out.append(Question(text=text.strip(), at=at, run_id=run_id, workflow=workflow, origin=origin))
# 同一时刻问的几句按稳定的 id 排:库里读出来的先后不定,种子、聚类的结果就跟着变(见模块说明)
out.sort(key=lambda q: (q.at.timestamp() if q.at is not None else 0.0, q.key), reverse=True)
return out[:limit]
#: 问题原文 → 向量(最近用到的在后)和它们是谁算的(见模块说明「向量只取一次」)
_VECTORS: "OrderedDict[str, Any]" = OrderedDict()
_VECTORS_OF: tuple[Any, Any] | None = None
_VECTOR_LOCKS: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary()
def _vector_lock() -> asyncio.Lock:
"""取向量的锁,按事件循环各一把(asyncio.Lock 不能跨循环用)。"""
loop = asyncio.get_running_loop()
lock = _VECTOR_LOCKS.get(loop)
if lock is None:
lock = _VECTOR_LOCKS[loop] = asyncio.Lock()
return lock
def _owner() -> tuple[Any, Any]:
from app.memory import embeddings
return embeddings.get_embedder(), embeddings.embed_texts
def _same_owner(a: tuple[Any, Any] | None, b: tuple[Any, Any]) -> bool:
return a is not None and a[0] is b[0] and a[1] is b[1]
async def _vectors(texts: Sequence[str]) -> list[Any]:
"""这些问题的向量(顺序同 texts):进程里有的直接用,没有的去重后一次取齐、留下来。"""
global _VECTORS_OF
async with _vector_lock():
owner = _owner()
if not _same_owner(_VECTORS_OF, owner):
_VECTORS.clear()
_VECTORS_OF = owner
got = {t: _VECTORS[t] for t in texts if t in _VECTORS}
missing = [t for t in dict.fromkeys(texts) if t not in got]
if missing:
fresh = await owner[1](missing)
got.update(zip(missing, fresh))
# 取的这一会儿设置页换了向量模型:这一批照用(同一次聚类里坐标是一致的),不留给下一次
if not _same_owner(_VECTORS_OF, _owner()):
return [got[t] for t in texts]
for t in missing:
_VECTORS[t] = got[t]
for t in dict.fromkeys(texts):
_VECTORS.move_to_end(t)
while len(_VECTORS) > VECTOR_CACHE_MAX:
_VECTORS.popitem(last=False)
return [got[t] for t in texts]
async def cluster_questions(questions: Sequence[Question], *, threshold: float = DEFAULT_THRESHOLD,
) -> list[QuestionCluster]:
"""向量贪心聚类(顺序同 questions:从新到旧)。向量取自 _vectors:同一句话在进程里只取一次。"""
from app.memory import embeddings
if not questions:
return []
vectors = await _vectors([q.text for q in questions])
clusters: list[tuple[int, QuestionCluster]] = []
for i, question in enumerate(questions):
for seed, cluster in clusters:
if embeddings.cosine(vectors[i], vectors[seed]) >= threshold:
cluster.members.append(question)
break
else:
clusters.append((i, QuestionCluster(members=[question])))
return [cluster for _, cluster in clusters]
def embedder() -> str:
"""聚类用的是哪个向量模型(memory/embeddings.embedder_id):本地哈希的结果只是下限,调用方照实写出来。"""
from app.memory import embeddings
return embeddings.embedder_id()
__all__ = ["DEFAULT_THRESHOLD", "VECTOR_CACHE_MAX", "Question", "QuestionCluster", "cluster_questions", "embedder",
"recent_questions"]