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-`)—— 两者不同源, // 按图 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 }