前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
122 lines
5.3 KiB
Go
122 lines
5.3 KiB
Go
package flowgraph
|
||
|
||
// 本文件是**按路批量查图**的读侧入口(切片 CODE-TC;迁移 0023 的 `lane_turn_id`)。
|
||
//
|
||
// 它补的是契约 v0.76 ① `toolCalls[]` 一直缺的那一件东西:v0.77 起每路各落一行、图上
|
||
// 带 `turnId`,但读侧只能按**图 id**(`turn_id` 列 = `trace.FlowGraph.ID`)单轮取,
|
||
// 而消息面上的 `chat_messages.turn_id` 是**路标识**(`turn-<hex>`)—— 两者不同源,
|
||
// 按图 id 查不到。本文件给出「一次把一页消息的路标识换成图」的那个批量入口。
|
||
//
|
||
// 三条口径:
|
||
//
|
||
// - **一次批量 SQL**:调用方给一页消息的路标识集合,本方法拼一条 `IN (...)` 查完,
|
||
// 绝不按消息逐条查(N+1)。集合超过 `maxTurnIDParams` 才分块(见该常量)。
|
||
// - **查不到就是查不到**:缺席的路标识不会出现在返回的 map 里,调用方据此让整个
|
||
// `toolCalls` 字段**缺席**;本方法绝不回「空图」占位(那也是编造)。
|
||
// - **只读**:不写库、不改图、不碰保留期。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// maxTurnIDParams 是一次 `IN` 查询里最多绑定的路标识个数(分块上限)。
|
||
//
|
||
// 依据:① 上游 `GET /api/v1/sessions/{id}/messages` 的一页被 history 的
|
||
// `maxPageLimit = 200`(`internal/runtime/history/store.go`)钳住,因此一页最多 200 个
|
||
// 不同路标识 ⇒ **正常情况下只跑一次**(200 + 1 个绑定参数);② SQLite 的绑定参数上限
|
||
// 在不同构建里是 999 或 32766,201 离两者都还有一个量级余量。分块只在日后下游放宽
|
||
// 页大小时才生效,使「按页内集合批量查」不会某天突然撞上参数上限而让整页读接口报错。
|
||
const maxTurnIDParams = 200
|
||
|
||
// GraphsByTurnID 按**路标识**批量取图:返回 `turnID → 该路的图` 的映射。
|
||
//
|
||
// `laneTurnIDs` 是消息行的 `turn_id` 集合(迁移 0018),本方法只认 `lane_turn_id`
|
||
// (迁移 0023)—— 两者同源,因此这是一次等值索引查询(`idx_flow_graphs_lane_turn_id`)。
|
||
//
|
||
// 语义:
|
||
// - 空/全空白的集合、空会话 ⇒ 返回**空 map**(不发查询)。空 map 是「没东西可查」,
|
||
// 与「查了但没有」对调用方是同一件事:没有可用作回看依据的图。
|
||
// - 查不到的路标识**不出现在 map 里**(不塞 `nil` 占位):调用方必须据此让
|
||
// `toolCalls` 整体缺席,绝不回空数组。
|
||
// - 重复的标识只查一次;结果 map 的键是**查询用的原值**。
|
||
// - 一行 JSON 解不开 ⇒ 返回错误(与 `Graph` / `Latest` 同一口径:读不懂就如实报错,
|
||
// 绝不把坏行当成「这一轮没有图」)。调用方决定是整页失败还是只放弃回看面。
|
||
func (s *SQLStore) GraphsByTurnID(ctx context.Context, sessionID string, laneTurnIDs []string) (map[string]*trace.FlowGraph, error) {
|
||
if s == nil || s.db == nil {
|
||
return nil, fmt.Errorf("flowgraph: query rounds by lane: %w", ErrNilDB)
|
||
}
|
||
ids := uniqueNonBlank(laneTurnIDs)
|
||
if strings.TrimSpace(sessionID) == "" || len(ids) == 0 {
|
||
return map[string]*trace.FlowGraph{}, nil
|
||
}
|
||
out := make(map[string]*trace.FlowGraph, len(ids))
|
||
for start := 0; start < len(ids); start += maxTurnIDParams {
|
||
end := start + maxTurnIDParams
|
||
if end > len(ids) {
|
||
end = len(ids)
|
||
}
|
||
if err := s.queryLaneChunk(ctx, sessionID, ids[start:end], out); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// queryLaneChunk 查一块路标识并把解出来的图并进 out。
|
||
func (s *SQLStore) queryLaneChunk(ctx context.Context, sessionID string, ids []string, out map[string]*trace.FlowGraph) error {
|
||
args := make([]any, 0, len(ids)+1)
|
||
args = append(args, sessionID)
|
||
placeholders := make([]string, 0, len(ids))
|
||
for _, id := range ids {
|
||
placeholders = append(placeholders, "?")
|
||
args = append(args, id)
|
||
}
|
||
query := `SELECT lane_turn_id, graph_json FROM flow_graphs
|
||
WHERE session_id = ? AND lane_turn_id IN (` + strings.Join(placeholders, ", ") + `)`
|
||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return fmt.Errorf("flowgraph: 按路批量查图(%s, %d 路): %w", sessionID, len(ids), err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
for rows.Next() {
|
||
var (
|
||
laneID string
|
||
payload string
|
||
)
|
||
if err := rows.Scan(&laneID, &payload); err != nil {
|
||
return fmt.Errorf("flowgraph: 读按路查图行(%s): %w", sessionID, err)
|
||
}
|
||
g, err := decodeGraph(sessionID, laneID, payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 键取**行里那一列**而不是查询用的值:两者同值(查询就是按等值匹配),
|
||
// 但用行值使 map 的键与「库里真实存了什么」永远一致。
|
||
out[laneID] = g
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return fmt.Errorf("flowgraph: 遍历按路查图行(%s): %w", sessionID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// uniqueNonBlank 去掉空白项与重复项,保持首次出现的顺序(查询参数因此稳定可复现)。
|
||
func uniqueNonBlank(in []string) []string {
|
||
seen := make(map[string]bool, len(in))
|
||
out := make([]string, 0, len(in))
|
||
for _, raw := range in {
|
||
id := strings.TrimSpace(raw)
|
||
if id == "" || seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
out = append(out, id)
|
||
}
|
||
return out
|
||
}
|