flzxsqc-demo 1.4.13 → 1.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ # globalContext 数据筛选参数处理链路
2
+
3
+ 本文说明前端提交 `globalContext.digitalAnalysis` 后,后端如何把数据筛选条件融入分析任务的工具调用参数中。重点覆盖数据从前端创建任务、后端会话保存、工具执行前预处理,到本地查询兼容层的完整代码路径。
4
+
5
+ ## 1. 入口数据形态
6
+
7
+ 前端创建分析任务时,会把数据筛选信息放入 `globalContext.digitalAnalysis`。当前主要有两类来源:
8
+
9
+ ```json
10
+ {
11
+ "globalContext": {
12
+ "digitalAnalysis": {
13
+ "source": "agent_session_batch",
14
+ "batchId": "batch-xxx",
15
+ "queryInstanceId": "query-xxx",
16
+ "matchedCount": 20,
17
+ "sessionIds": ["call-1", "call-2"],
18
+ "filters": {
19
+ "start_time": "2026-01-01 00:00:00",
20
+ "end_time": "2026-06-18 23:59:59",
21
+ "agentId": "A001",
22
+ "teamId": ["T1", "T2"]
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ 没有批次筛选结果、但填写了固定或动态筛选条件时,前端会提交:
30
+
31
+ ```json
32
+ {
33
+ "globalContext": {
34
+ "digitalAnalysis": {
35
+ "source": "agent_session_filter",
36
+ "filters": {
37
+ "channel": "渠道1",
38
+ "batchCode": "batchId",
39
+ "queueId": "Q001",
40
+ "groupId": ["G1"]
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ 相关前端组装代码:
48
+
49
+ - `want-agent/agent-front-react/src/pages/AnalysisTaskPage.tsx`
50
+ - `want-agent/agent-front-react/src/constants/sessionFilters.ts`
51
+ - `want-agent/agent-front-react/src/pages/analysis-task/SessionBatchPanel.tsx`
52
+
53
+ ## 2. 后端会话创建与保存
54
+
55
+ 创建任务接口接收前端传入的 `globalContext`,并随会话一起保存。
56
+
57
+ 核心代码:
58
+
59
+ - `want-agent/agent-backend/src/main/java/com/example/agent/controller/AgentSessionController.java`
60
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/AgentTurnRunner.java`
61
+ - `want-agent/agent-backend/src/main/java/com/example/agent/domain/AgentSession.java`
62
+
63
+ 处理要点:
64
+
65
+ 1. `AgentSessionController.createSession(...)` 接收创建任务请求。
66
+ 2. 请求中的 `globalContext` 会进入会话创建流程。
67
+ 3. `AgentTurnRunner` 创建 `AgentSession` 记录时,把 `globalContext` 挂到 `AgentSession.globalContext`。
68
+ 4. 后续工具执行阶段通过当前 `AgentSession` 读取这份上下文。
69
+
70
+ 也就是说,`globalContext.digitalAnalysis` 不是直接作为某个查询接口的 body 传入工具,而是先成为会话级上下文,再在工具调用前被后端预处理器读取并转换。
71
+
72
+ ## 3. 工具执行前的参数处理顺序
73
+
74
+ 分析任务运行时,LLM 会选择工具并生成初始参数。工具真正执行前,会经过后端统一执行管线。
75
+
76
+ 核心代码:
77
+
78
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/tool/ToolExecutionPipeline.java`
79
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/tool/AgentSessionBatchToolContextApplier.java`
80
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/tool/argument/ToolArgumentResolverRegistry.java`
81
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/tool/argument/CompanyStructuredQueryToolArgumentResolver.java`
82
+
83
+ 当前关键顺序是:
84
+
85
+ 1. `agentTimeContextService.normalizeArgs(...)`
86
+ 2. `AgentSessionBatchToolContextApplier.apply(...)`
87
+ 3. `toolInvocationGuard.validateBeforeExecution(...)`
88
+ 4. `resolveExecutionArgs(...)`
89
+ 5. 进入具体工具实现或 MCP 调用
90
+
91
+ 其中第 2 步和第 4 步都与数据筛选相关,但职责不同。
92
+
93
+ ## 4. AgentSessionBatchToolContextApplier 的作用
94
+
95
+ `AgentSessionBatchToolContextApplier` 会读取:
96
+
97
+ ```java
98
+ session.getGlobalContext().get("digitalAnalysis")
99
+ ```
100
+
101
+ 它的作用是把会话级数据筛选上下文合并到传统工具参数中,主要用于让工具前置校验、执行保护和旧参数路径能看到约束。
102
+
103
+ 相关代码:
104
+
105
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/tool/AgentSessionBatchToolContextApplier.java`
106
+
107
+ 典型行为:
108
+
109
+ - 读取 `digitalAnalysis.filters`
110
+ - 读取 `digitalAnalysis.sessionIds`
111
+ - 合并成规范化筛选条件
112
+ - 注入到工具参数中的 `filters` 或相关字段
113
+
114
+ 这一步保留了对旧式本地查询参数的兼容,但它不是公司 ES 查询参数最终成型的位置。
115
+
116
+ ## 5. request_preprocessor 决定最终工具参数形态
117
+
118
+ 真正把筛选条件组装成公司查询接口参数形态的是 `request_preprocessor`。
119
+
120
+ 工具定义在种子数据里配置:
121
+
122
+ - `want-agent/agent-backend/src/main/resources/data.sql`
123
+
124
+ 当前两个工具使用公司结构化查询预处理器:
125
+
126
+ ```sql
127
+ query_customer_sessions_structured -> COMPANY_STRUCTURED_DETECT_QUERY
128
+ query_sessions_handle -> COMPANY_STRUCTURED_HANDLE_QUERY
129
+ ```
130
+
131
+ 对应解析器:
132
+
133
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/tool/argument/CompanyStructuredQueryToolArgumentResolver.java`
134
+
135
+ 注意:如果本地数据库里已经存在旧的 `tool_definition` 数据,需要重新执行或迁移 `data.sql` 中的 `request_preprocessor` 配置,否则运行时不会进入这个解析器。
136
+
137
+ ## 6. CompanyStructuredQueryToolArgumentResolver 的优先级
138
+
139
+ `CompanyStructuredQueryToolArgumentResolver` 的核心规则是:只要会话 `globalContext.digitalAnalysis` 里有可识别筛选条件,就优先使用这些条件组装工具参数;否则才使用模型从自然语言问题里解析出的参数。
140
+
141
+ 优先级如下:
142
+
143
+ 1. 有 `globalContext.digitalAnalysis` 筛选条件:使用会话上下文组装公司结构化查询参数。
144
+ 2. 没有数据筛选上下文,但模型已经生成 `queryConditions`:保留并补齐默认字段。
145
+ 3. 没有 `queryConditions`,但模型生成了旧式 `filters` 或扁平字段:转换成公司结构化查询参数。
146
+
147
+ 这对应用户期望的逻辑:
148
+
149
+ - 使用了前端数据筛选时,以 `globalContext` 里的条件为准。
150
+ - 没有使用数据筛选时,继续使用原来的客户自然语言问题解析结果。
151
+
152
+ ## 7. 生成的公司查询参数形态
153
+
154
+ 预处理后,`query_customer_sessions_structured` 和 `query_sessions_handle` 的工具参数会被整理为类似下面的形态:
155
+
156
+ ```json
157
+ {
158
+ "boolOperator": "FILTER",
159
+ "queryConditions": [
160
+ {
161
+ "conditionType": "RANGE",
162
+ "field": "sessionTime",
163
+ "fieldType": "DATE",
164
+ "gte": "2026-01-01 00:00:00",
165
+ "lte": "2026-06-18 23:59:59"
166
+ },
167
+ {
168
+ "conditionType": "MATCH_PHRASE",
169
+ "field": "sourceId",
170
+ "fieldType": "KEYWORD",
171
+ "value": ["call-1", "call-2"]
172
+ },
173
+ {
174
+ "conditionType": "MATCH_PHRASE",
175
+ "field": "agentId",
176
+ "fieldType": "KEYWORD",
177
+ "value": ["A001"]
178
+ },
179
+ {
180
+ "conditionType": "MATCH_PHRASE",
181
+ "field": "teamId",
182
+ "fieldType": "KEYWORD",
183
+ "value": ["T1", "T2"]
184
+ }
185
+ ],
186
+ "queryPurpose": "DETECT",
187
+ "sourceTypes": ["VOICE", "TEXT"],
188
+ "includeFields": ["allText", "queueId", "agentId"],
189
+ "queryType": "STRUCTURED",
190
+ "size": 10
191
+ }
192
+ ```
193
+
194
+ `queryPurpose` 会按工具场景设置:
195
+
196
+ - `COMPANY_STRUCTURED_DETECT_QUERY` -> `DETECT`
197
+ - `COMPANY_STRUCTURED_HANDLE_QUERY` -> `ANALYSE`
198
+
199
+ ## 8. 筛选字段到 queryConditions 的映射
200
+
201
+ 字段映射由 `CompanyStructuredQueryToolArgumentResolver` 完成。
202
+
203
+ | canonical filter | 公司查询字段 | 条件类型 | 说明 |
204
+ | --- | --- | --- | --- |
205
+ | `start_time` / `end_time` | `sessionTime` | `RANGE` | 时间范围 |
206
+ | `session_ids` / `sessionIds` | `sourceId` | `MATCH_PHRASE` | 会话 ID 列表 |
207
+ | `agentId` | `agentId` | `MATCH_PHRASE` | 坐席 ID |
208
+ | `queueId` | `queueId` | `MATCH_PHRASE` | 队列 |
209
+ | `teamId` | `teamId` | `MATCH_PHRASE` | 团队 ID |
210
+ | `groupId` | `groupId` | `MATCH_PHRASE` | 组 ID |
211
+ | `batchCode` | `batchCode` | `MATCH_PHRASE` | 批次号 |
212
+ | `channel` | `channel` | `MATCH_PHRASE` | 数据渠道 |
213
+ | `dataSource` | `dataSource` | `MATCH_PHRASE` | 数据来源 |
214
+ | `callType` | `callType` | `MATCH_PHRASE` | 会话类型 |
215
+ | `businessLine` | `businessLine` | `MATCH_PHRASE` | 业务线 |
216
+ | `customerId` | `customerId` | `MATCH_PHRASE` | 客户 ID |
217
+ | `keywords` | `allText` | `MATCH_PHRASE` | 文本关键词 |
218
+ | `limit` | `size` | - | 查询条数 |
219
+
220
+ `source`、`batchId`、`queryInstanceId`、`matchedCount` 等字段是上下文元数据,不直接生成查询条件。真正参与过滤的是 `filters` 和 `sessionIds` 中可识别的字段。
221
+
222
+ ## 9. 本地查询兼容逻辑
223
+
224
+ 本地开发环境并不直接查公司 ES,而是仍然通过本地查询实现或模拟接口执行。因此后端保留了对两种参数形态的兼容:
225
+
226
+ 1. 旧式参数:
227
+
228
+ ```json
229
+ {
230
+ "filters": {
231
+ "start_time": "2026-01-01 00:00:00",
232
+ "end_time": "2026-06-18 23:59:59",
233
+ "session_ids": ["call-1"]
234
+ },
235
+ "limit": 10
236
+ }
237
+ ```
238
+
239
+ 2. 公司结构化参数:
240
+
241
+ ```json
242
+ {
243
+ "queryConditions": [
244
+ {
245
+ "conditionType": "MATCH_PHRASE",
246
+ "field": "sourceId",
247
+ "fieldType": "KEYWORD",
248
+ "value": ["call-1"]
249
+ }
250
+ ],
251
+ "size": 10
252
+ }
253
+ ```
254
+
255
+ 兼容转换代码:
256
+
257
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/filter/AgentSessionFilterMapper.java`
258
+
259
+ 它负责:
260
+
261
+ - 规范化 `filters`
262
+ - 识别字段别名
263
+ - 把公司结构化 `queryConditions` 反解析成本地 `CustomerSessionQueryRequest` 可用的 canonical filters
264
+ - 把 `size` 兼容为 `limit`
265
+
266
+ 本地工具入口使用该 mapper:
267
+
268
+ - `want-agent/agent-backend/src/main/java/com/example/agent/controller/ToolsController.java`
269
+ - `want-agent/agent-backend/src/main/java/com/example/agent/application/service/localtool/QueryHandleService.java`
270
+
271
+ 最终查询请求对象和 SQL 映射:
272
+
273
+ - `want-agent/agent-backend/src/main/java/com/example/agent/domain/CustomerSessionQueryRequest.java`
274
+ - `want-agent/agent-backend/src/main/resources/mappers/CustomerSessionMapper.xml`
275
+
276
+ 因此,即使工具参数已经被预处理成公司 ES 风格,本地查询路径也能反解析并继续执行。
277
+
278
+ ## 10. 完整调用链概览
279
+
280
+ ```mermaid
281
+ sequenceDiagram
282
+ participant FE as 前端 AnalysisTaskPage
283
+ participant API as AgentSessionController
284
+ participant Runner as AgentTurnRunner
285
+ participant Pipeline as ToolExecutionPipeline
286
+ participant Applier as AgentSessionBatchToolContextApplier
287
+ participant Resolver as CompanyStructuredQueryToolArgumentResolver
288
+ participant Tool as query_customer_sessions_structured / query_sessions_handle
289
+ participant Local as 本地 QueryHandleService / ToolsController
290
+
291
+ FE->>API: createSession(question, globalContext.digitalAnalysis)
292
+ API->>Runner: 创建分析会话
293
+ Runner->>Runner: 保存 AgentSession.globalContext
294
+ Runner->>Pipeline: 执行 LLM 选择的工具
295
+ Pipeline->>Applier: 从 session.globalContext.digitalAnalysis 合并筛选上下文
296
+ Pipeline->>Resolver: 根据 request_preprocessor 解析最终工具参数
297
+ Resolver->>Resolver: globalContext 优先;否则使用模型解析参数
298
+ Resolver->>Tool: 输出公司结构化 queryConditions 参数
299
+ Tool->>Local: 本地环境执行时进入兼容查询
300
+ Local->>Local: AgentSessionFilterMapper 反解析 queryConditions
301
+ ```
302
+
303
+ ## 11. 关键行为约束
304
+
305
+ - 前端使用数据筛选时,`globalContext.digitalAnalysis` 的筛选条件优先级高于模型从自然语言中解析出的查询条件。
306
+ - 前端没有使用数据筛选时,后端仍沿用自然语言问题解析出的工具参数。
307
+ - `sample_sessions_by_keywords` 不走公司结构化查询预处理器,避免影响关键字采样类工具的原行为。
308
+ - `query_customer_sessions_structured` 和 `query_sessions_handle` 才走公司结构化查询预处理器。
309
+ - `sampleCount`、采样功能和数据筛选查询是不同链路;采样读取已有分析结果,不重新生成数据筛选查询。
310
+
311
+ ## 12. 相关测试
312
+
313
+ 覆盖该链路的主要测试:
314
+
315
+ - `want-agent/agent-backend/src/test/java/com/example/agent/application/service/tool/argument/CompanyStructuredQueryToolArgumentResolverTest.java`
316
+ - `want-agent/agent-backend/src/test/java/com/example/agent/application/service/filter/AgentSessionFilterMapperTest.java`
317
+
318
+ 常用验证命令:
319
+
320
+ ```bash
321
+ mvn -Dtest=CompanyStructuredQueryToolArgumentResolverTest,AgentSessionFilterMapperTest test
322
+ ```
323
+
324
+ 如果同时验证采样功能相关改动:
325
+
326
+ ```bash
327
+ mvn -Dtest=CompanyStructuredQueryToolArgumentResolverTest,AgentSessionFilterMapperTest,AgentSessionSampleServiceTest test
328
+ ```
@@ -32,6 +32,9 @@ public class DefaultAsyncCallbackPayloadBuilder implements AsyncCallbackPayloadB
32
32
  return;
33
33
  }
34
34
  for (Map.Entry<String, Object> entry : source.entrySet()) {
35
+ if (isExecutionIdentity(entry.getKey()) && target.containsKey(entry.getKey())) {
36
+ continue;
37
+ }
35
38
  Object targetValue = target.get(entry.getKey());
36
39
  Object sourceValue = entry.getValue();
37
40
  if (targetValue instanceof Map && sourceValue instanceof Map) {
@@ -42,6 +45,10 @@ public class DefaultAsyncCallbackPayloadBuilder implements AsyncCallbackPayloadB
42
45
  }
43
46
  }
44
47
 
48
+ private boolean isExecutionIdentity(String key) {
49
+ return "tool".equals(key) || "args".equals(key);
50
+ }
51
+
45
52
  private Map<String, Object> readJsonMap(Object raw) {
46
53
  if (raw == null) {
47
54
  return new LinkedHashMap<String, Object>();
@@ -99,7 +99,11 @@ public class ToolExecutionPipeline {
99
99
 
100
100
  // 5. 异步工具需要登记等待任务;同步工具直接发布完成事件。
101
101
  if (executionResult.isWaitingAsync()) {
102
- String payloadJson = objectMapper.writeValueAsString(executionResult.getNormalizedPayload());
102
+ String payloadJson = objectMapper.writeValueAsString(asyncPendingPayload(
103
+ request.toolName(),
104
+ executorArgs,
105
+ executionResult.getNormalizedPayload()
106
+ ));
103
107
  asyncJobRepository.insertPendingAgentJob(new AsyncJobRepository.AgentAsyncJobInsert(
104
108
  request.session().getSessionId(),
105
109
  request.turnId(),
@@ -237,6 +241,15 @@ public class ToolExecutionPipeline {
237
241
  return payload;
238
242
  }
239
243
 
244
+ private Map<String, Object> asyncPendingPayload(String toolName, Map<String, Object> args, Map<String, Object> result) {
245
+ Map<String, Object> payload = result == null
246
+ ? new LinkedHashMap<String, Object>()
247
+ : new LinkedHashMap<String, Object>(result);
248
+ payload.put("tool", toolName);
249
+ payload.put("args", args == null ? new LinkedHashMap<String, Object>() : new LinkedHashMap<String, Object>(args));
250
+ return payload;
251
+ }
252
+
240
253
  private Map<String, Object> timedPayload(String toolName, Map<String, Object> args, Object result, long startedAt) {
241
254
  Map<String, Object> payload = payload(toolName, args, result);
242
255
  payload.put("latency_ms", System.currentTimeMillis() - startedAt);
@@ -40,15 +40,14 @@ public class AgentSessionBatchService {
40
40
  List<String> rawSessionIds = new ArrayList<String>();
41
41
  rawSessionIds.addAll(parseSessionIds(safeRequest.getManualIds(), MANUAL_SESSION_ID_SPLITTER));
42
42
  rawSessionIds.addAll(parseSessionIds(safeRequest.getFileContent(), FILE_SESSION_ID_SPLITTER));
43
- if (rawSessionIds.isEmpty()) {
44
- throw new IllegalArgumentException("session ids must not be empty");
45
- }
46
43
 
47
44
  LinkedHashSet<String> uniqueSessionIds = new LinkedHashSet<String>(rawSessionIds);
48
- Map<String, CustomerSessionRecord> existingRecords = existingRecordMap(uniqueSessionIds);
45
+ Map<String, CustomerSessionRecord> existingRecords = uniqueSessionIds.isEmpty()
46
+ ? new LinkedHashMap<String, CustomerSessionRecord>()
47
+ : existingRecordMap(uniqueSessionIds);
49
48
  String batchId = "batch-" + UUID.randomUUID().toString();
50
49
  List<AgentSessionBatchItemPo> items = buildItems(batchId, uniqueSessionIds, existingRecords);
51
- AgentSessionBatchPo batch = buildBatch(batchId, safeRequest, items.size());
50
+ AgentSessionBatchPo batch = buildBatch(batchId, safeRequest, items.size(), uniqueSessionIds.isEmpty());
52
51
  batchRepository.insert(batch, items);
53
52
  return toBatchResponse(batch, items);
54
53
  }
@@ -56,9 +55,6 @@ public class AgentSessionBatchService {
56
55
  public AgentSessionBatchDtos.FilterResponse filter(String batchId, AgentSessionBatchDtos.FilterRequest request) {
57
56
  AgentSessionBatchPo batch = requireBatch(batchId);
58
57
  List<AgentSessionBatchItemPo> validItems = batchRepository.findValidItems(batch.getBatchId());
59
- if (validItems.isEmpty()) {
60
- throw new IllegalArgumentException("batch has no valid session ids: " + batchId);
61
- }
62
58
 
63
59
  AgentSessionBatchDtos.FilterRequest safeRequest = request == null ? new AgentSessionBatchDtos.FilterRequest() : request;
64
60
  CustomerSessionQueryRequest queryRequest = buildQueryRequest(validItems, safeRequest);
@@ -161,12 +157,13 @@ public class AgentSessionBatchService {
161
157
 
162
158
  private AgentSessionBatchPo buildBatch(String batchId,
163
159
  AgentSessionBatchDtos.ParseRequest request,
164
- int validCount) {
160
+ int validCount,
161
+ boolean filterOnly) {
165
162
  AgentSessionBatchPo batch = new AgentSessionBatchPo();
166
163
  batch.setBatchId(batchId);
167
- batch.setSourceType(defaultText(request.getSourceType(), "MANUAL"));
164
+ batch.setSourceType(defaultText(request.getSourceType(), filterOnly ? "FILTER_ONLY" : "MANUAL"));
168
165
  batch.setValidCount(validCount);
169
- batch.setStatus(validCount > 0 ? "READY" : "INVALID");
166
+ batch.setStatus(validCount > 0 || filterOnly ? "READY" : "INVALID");
170
167
  batch.setCreator(defaultText(request.getCreator(), "system"));
171
168
  return batch;
172
169
  }
@@ -174,13 +171,20 @@ public class AgentSessionBatchService {
174
171
  private CustomerSessionQueryRequest buildQueryRequest(List<AgentSessionBatchItemPo> validItems,
175
172
  AgentSessionBatchDtos.FilterRequest request) {
176
173
  Map<String, Object> filters = AgentSessionFilterMapper.normalizeBatchFilterRequest(request);
177
- filters.put(AgentSessionFilterField.SESSION_IDS.key(), validSessionIds(validItems));
174
+ putSessionIdsIfPresent(filters, validItems);
178
175
  CustomerSessionQueryRequest queryRequest = new CustomerSessionQueryRequest();
179
176
  AgentSessionFilterMapper.applyToQueryRequest(queryRequest, filters);
180
177
  queryRequest.setLimit(normalizeLimit(queryRequest.getLimit()));
181
178
  return queryRequest;
182
179
  }
183
180
 
181
+ private void putSessionIdsIfPresent(Map<String, Object> filters, List<AgentSessionBatchItemPo> validItems) {
182
+ List<String> sessionIds = validSessionIds(validItems);
183
+ if (!sessionIds.isEmpty()) {
184
+ filters.put(AgentSessionFilterField.SESSION_IDS.key(), sessionIds);
185
+ }
186
+ }
187
+
184
188
  private List<String> validSessionIds(List<AgentSessionBatchItemPo> validItems) {
185
189
  LinkedHashSet<String> sessionIds = new LinkedHashSet<String>();
186
190
  for (AgentSessionBatchItemPo item : validItems) {
@@ -14,10 +14,16 @@ class DefaultAsyncCallbackPayloadBuilderTest {
14
14
  DefaultAsyncCallbackPayloadBuilder builder = new DefaultAsyncCallbackPayloadBuilder(new ObjectMapper());
15
15
 
16
16
  Map<String, Object> asyncJob = new LinkedHashMap<String, Object>();
17
- asyncJob.put("payload_json", "{\"status\":\"WAITING_ASYNC_CALLBACK\",\"artifacts\":{\"batchId\":\"batch-123\",\"analysis_job_id\":\"job-1\"},\"exports\":{\"analysis_result_id\":\"batch-123\"}}");
17
+ asyncJob.put("payload_json", "{\"tool\":\"query_sessions_handle\",\"args\":{\"queryConditions\":[{\"field\":\"sourceId\",\"value\":[\"call-1\"]}]},\"status\":\"WAITING_ASYNC_CALLBACK\",\"artifacts\":{\"batchId\":\"batch-123\",\"analysis_job_id\":\"job-1\"},\"exports\":{\"analysis_result_id\":\"batch-123\"}}");
18
18
 
19
19
  Map<String, Object> callbackPayload = new LinkedHashMap<String, Object>();
20
20
  callbackPayload.put("status", "SUCCEEDED");
21
+ callbackPayload.put("tool", "query_sessions_handle");
22
+ callbackPayload.put("args", new LinkedHashMap<String, Object>() {{
23
+ put("filters", new LinkedHashMap<String, Object>() {{
24
+ put("session_ids", "llm-call");
25
+ }});
26
+ }});
21
27
  callbackPayload.put("artifacts", new LinkedHashMap<String, Object>() {{
22
28
  put("sample_count", 80);
23
29
  }});
@@ -29,5 +35,8 @@ class DefaultAsyncCallbackPayloadBuilderTest {
29
35
  Assertions.assertEquals("batch-123", artifacts.get("batchId"));
30
36
  Assertions.assertEquals(80, artifacts.get("sample_count"));
31
37
  Assertions.assertEquals("job-1", artifacts.get("analysis_job_id"));
38
+ Map<String, Object> args = (Map<String, Object>) merged.get("args");
39
+ Assertions.assertEquals("sourceId", ((Map<?, ?>) ((java.util.List<?>) args.get("queryConditions")).get(0)).get("field"));
40
+ Assertions.assertFalse(args.containsKey("filters"));
32
41
  }
33
42
  }
@@ -19,6 +19,7 @@ import com.example.agent.domain.model.ToolResultType;
19
19
  import com.example.agent.domain.model.ToolValidationException;
20
20
  import com.example.agent.domain.port.ToolExecutor;
21
21
  import com.example.agent.infrastructure.repository.AsyncJobRepository;
22
+ import com.fasterxml.jackson.core.type.TypeReference;
22
23
  import com.fasterxml.jackson.databind.ObjectMapper;
23
24
  import org.junit.jupiter.api.Assertions;
24
25
  import org.junit.jupiter.api.Test;
@@ -228,6 +229,72 @@ class ToolExecutionPipelineTest {
228
229
  Assertions.assertFalse(executedArgs.containsKey("analysis_prompt"));
229
230
  }
230
231
 
232
+ @Test
233
+ void persistsResolvedArgsForAsyncFinishEvents() throws Exception {
234
+ ToolExecutor executor = Mockito.mock(ToolExecutor.class);
235
+ ToolResult toolResult = new ToolResult();
236
+ toolResult.setType(ToolResultType.ASYNC_JOB);
237
+ toolResult.setAsyncJobId("async-1");
238
+ toolResult.setCallbackTopic("agent.tool.analysis.completed");
239
+ toolResult.setArtifacts(new LinkedHashMap<String, Object>() {{
240
+ put("analysis_job_id", "async-1");
241
+ }});
242
+ Mockito.when(executor.execute(Mockito.any(ToolCommand.class))).thenReturn(toolResult);
243
+ AsyncJobRepository asyncJobRepository = Mockito.mock(AsyncJobRepository.class);
244
+ ToolArgumentResolver resolver = new ToolArgumentResolver() {
245
+ @Override
246
+ public String key() {
247
+ return "TEST_STRUCTURED_QUERY";
248
+ }
249
+
250
+ @Override
251
+ public Map<String, Object> resolve(AgentSession session, ToolDefinition definition, Map<String, Object> args) {
252
+ Map<String, Object> resolved = new LinkedHashMap<String, Object>();
253
+ resolved.put("queryConditions", Collections.singletonList(new LinkedHashMap<String, Object>() {{
254
+ put("field", "sourceId");
255
+ put("value", Collections.singletonList("call-1"));
256
+ }}));
257
+ return resolved;
258
+ }
259
+ };
260
+ ToolExecutionPipeline pipeline = pipeline(
261
+ executor,
262
+ Mockito.mock(HarnessMemoryService.class),
263
+ Arrays.asList(new DefaultToolArgumentResolver(), resolver),
264
+ asyncJobRepository,
265
+ Mockito.mock(RuntimeEventBus.class)
266
+ );
267
+
268
+ AgentSession session = new AgentSession();
269
+ session.setSessionId("session-1");
270
+ session.setQuery("分析筛选数据");
271
+ ToolDefinition definition = tool("query_sessions_handle");
272
+ definition.setResultType(ToolResultType.ASYNC_JOB);
273
+ definition.setRequestPreprocessor("TEST_STRUCTURED_QUERY");
274
+
275
+ pipeline.execute(new ToolExecutionPipeline.ToolExecutionRequest(
276
+ session,
277
+ "turn-1",
278
+ "call-1",
279
+ "query_sessions_handle",
280
+ new LinkedHashMap<String, Object>() {{
281
+ put("filters", Collections.singletonMap("session_ids", Collections.singletonList("llm-call")));
282
+ }},
283
+ Collections.singletonList(definition)
284
+ ));
285
+
286
+ ArgumentCaptor<AsyncJobRepository.AgentAsyncJobInsert> insertCaptor = ArgumentCaptor.forClass(AsyncJobRepository.AgentAsyncJobInsert.class);
287
+ Mockito.verify(asyncJobRepository).insertPendingAgentJob(insertCaptor.capture());
288
+ Map<String, Object> payload = new ObjectMapper().readValue(
289
+ insertCaptor.getValue().payloadJson(),
290
+ new TypeReference<Map<String, Object>>() {}
291
+ );
292
+ Map<String, Object> args = (Map<String, Object>) payload.get("args");
293
+ Assertions.assertEquals("query_sessions_handle", payload.get("tool"));
294
+ Assertions.assertEquals("sourceId", ((Map<?, ?>) ((List<?>) args.get("queryConditions")).get(0)).get("field"));
295
+ Assertions.assertFalse(args.containsKey("filters"));
296
+ }
297
+
231
298
  private ToolExecutionPipeline pipeline(ToolExecutor executor, HarnessMemoryService memoryService) {
232
299
  return pipeline(executor, memoryService, Collections.singletonList(new DefaultToolArgumentResolver()));
233
300
  }
@@ -235,11 +302,25 @@ class ToolExecutionPipelineTest {
235
302
  private ToolExecutionPipeline pipeline(ToolExecutor executor,
236
303
  HarnessMemoryService memoryService,
237
304
  List<ToolArgumentResolver> resolvers) {
305
+ return pipeline(
306
+ executor,
307
+ memoryService,
308
+ resolvers,
309
+ Mockito.mock(AsyncJobRepository.class),
310
+ Mockito.mock(RuntimeEventBus.class)
311
+ );
312
+ }
313
+
314
+ private ToolExecutionPipeline pipeline(ToolExecutor executor,
315
+ HarnessMemoryService memoryService,
316
+ List<ToolArgumentResolver> resolvers,
317
+ AsyncJobRepository asyncJobRepository,
318
+ RuntimeEventBus runtimeEventBus) {
238
319
  return new ToolExecutionPipeline(
239
320
  Mockito.mock(ToolRegistry.class),
240
321
  executor,
241
- Mockito.mock(RuntimeEventBus.class),
242
- Mockito.mock(AsyncJobRepository.class),
322
+ runtimeEventBus,
323
+ asyncJobRepository,
243
324
  new ObjectMapper(),
244
325
  new AgentTimeContextService("Asia/Shanghai"),
245
326
  new ToolArgumentResolverRegistry(resolvers),
@@ -0,0 +1,85 @@
1
+ package com.example.agent.application.service.localtool;
2
+
3
+ import com.example.agent.domain.model.CustomerSessionRecord;
4
+ import com.example.agent.infrastructure.persistence.po.localtool.AgentSessionBatchPo;
5
+ import com.example.agent.infrastructure.repository.localtool.AgentSessionBatchRepository;
6
+ import com.example.agent.infrastructure.repository.localtool.CustomerSessionMysqlRepository;
7
+ import com.example.agent.interfaces.rest.dto.AgentSessionBatchDtos;
8
+ import com.example.agent.interfaces.rest.dto.CustomerSessionQueryRequest;
9
+ import org.junit.jupiter.api.Assertions;
10
+ import org.junit.jupiter.api.Test;
11
+ import org.mockito.ArgumentCaptor;
12
+ import org.mockito.Mockito;
13
+
14
+ import java.util.Collections;
15
+ import java.util.LinkedHashMap;
16
+ import java.util.List;
17
+ import java.util.Map;
18
+
19
+ class AgentSessionBatchServiceTest {
20
+
21
+ @Test
22
+ void parseAllowsFilterOnlyBatchWithoutSessionIds() {
23
+ AgentSessionBatchRepository batchRepository = Mockito.mock(AgentSessionBatchRepository.class);
24
+ AgentSessionBatchService service = service(batchRepository, Mockito.mock(CustomerSessionMysqlRepository.class), Mockito.mock(QueryHandleService.class));
25
+
26
+ AgentSessionBatchDtos.BatchResponse response = service.parse(new AgentSessionBatchDtos.ParseRequest());
27
+
28
+ ArgumentCaptor<AgentSessionBatchPo> batchCaptor = ArgumentCaptor.forClass(AgentSessionBatchPo.class);
29
+ Mockito.verify(batchRepository).insert(batchCaptor.capture(), Mockito.eq(Collections.emptyList()));
30
+ Assertions.assertEquals("READY", response.getStatus());
31
+ Assertions.assertEquals("FILTER_ONLY", response.getSourceType());
32
+ Assertions.assertEquals(Integer.valueOf(0), response.getValidCount());
33
+ Assertions.assertEquals("READY", batchCaptor.getValue().getStatus());
34
+ Assertions.assertEquals("FILTER_ONLY", batchCaptor.getValue().getSourceType());
35
+ }
36
+
37
+ @Test
38
+ void filterWithoutValidSessionIdsUsesOnlyFilterFields() {
39
+ AgentSessionBatchRepository batchRepository = Mockito.mock(AgentSessionBatchRepository.class);
40
+ CustomerSessionMysqlRepository customerSessionMysqlRepository = Mockito.mock(CustomerSessionMysqlRepository.class);
41
+ QueryHandleService queryHandleService = Mockito.mock(QueryHandleService.class);
42
+ AgentSessionBatchPo batch = new AgentSessionBatchPo();
43
+ batch.setBatchId("batch-filter-only");
44
+ batch.setStatus("READY");
45
+ batch.setValidCount(0);
46
+ Mockito.when(batchRepository.findBatch("batch-filter-only")).thenReturn(batch);
47
+ Mockito.when(batchRepository.findValidItems("batch-filter-only")).thenReturn(Collections.emptyList());
48
+ CustomerSessionRecord record = new CustomerSessionRecord();
49
+ record.setCallId("call-1");
50
+ Mockito.when(customerSessionMysqlRepository.queryForHandle(Mockito.any(CustomerSessionQueryRequest.class), Mockito.anyInt()))
51
+ .thenReturn(Collections.singletonList(record));
52
+ Mockito.when(queryHandleService.createHandle(Mockito.anyMap())).thenReturn(handleArtifacts());
53
+ AgentSessionBatchService service = service(batchRepository, customerSessionMysqlRepository, queryHandleService);
54
+
55
+ AgentSessionBatchDtos.FilterRequest request = new AgentSessionBatchDtos.FilterRequest();
56
+ request.setBatchCode("personal-batch-1");
57
+ request.setChannel("VOICE");
58
+ AgentSessionBatchDtos.FilterResponse response = service.filter("batch-filter-only", request);
59
+
60
+ ArgumentCaptor<CustomerSessionQueryRequest> queryCaptor = ArgumentCaptor.forClass(CustomerSessionQueryRequest.class);
61
+ Mockito.verify(customerSessionMysqlRepository).queryForHandle(queryCaptor.capture(), Mockito.anyInt());
62
+ Assertions.assertTrue(queryCaptor.getValue().getSessionIds().isEmpty());
63
+ Assertions.assertEquals("VOICE", queryCaptor.getValue().getChannel());
64
+ Assertions.assertEquals("personal-batch-1", queryCaptor.getValue().getExtraFilters().get("batchCode"));
65
+ ArgumentCaptor<Map<String, Object>> handleCaptor = ArgumentCaptor.forClass(Map.class);
66
+ Mockito.verify(queryHandleService).createHandle(handleCaptor.capture());
67
+ Map<?, ?> filters = (Map<?, ?>) handleCaptor.getValue().get("filters");
68
+ Assertions.assertFalse(filters.containsKey("session_ids"));
69
+ Assertions.assertEquals("personal-batch-1", filters.get("batchCode"));
70
+ Assertions.assertEquals(Integer.valueOf(1), response.getMatchedCount());
71
+ Assertions.assertEquals(Collections.singletonList("call-1"), response.getSessionIds());
72
+ }
73
+
74
+ private AgentSessionBatchService service(AgentSessionBatchRepository batchRepository,
75
+ CustomerSessionMysqlRepository customerSessionMysqlRepository,
76
+ QueryHandleService queryHandleService) {
77
+ return new AgentSessionBatchService(batchRepository, customerSessionMysqlRepository, queryHandleService);
78
+ }
79
+
80
+ private Map<String, Object> handleArtifacts() {
81
+ Map<String, Object> artifacts = new LinkedHashMap<String, Object>();
82
+ artifacts.put("query_instance_id", "query-1");
83
+ return artifacts;
84
+ }
85
+ }