feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)

前提由 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 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
This commit is contained in:
AskaEth committed 2026-10-06 09:03:55 +08:00
1 parent 99a644d545
commit c907a34510
10 files changed
+1429 -16

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
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
}