Files
AskaEth c907a34510 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 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
2026-10-06 09:03:55 +08:00

122 lines
5.3 KiB
Go
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.
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
}