File size: 18,944 Bytes
8a03d2c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
"""
Vcore AI 响应解析工具函数

负责解析上游 API 的响应数据,处理错误和元数据提取。
"""

import json
from typing import Any, cast
from src.core.errors import (
    VcoreError,
    InternalError,
    parse_error_response,
)
from src.utils.logger import get_logger
from src.utils.string_utils import snake_to_camel

logger = get_logger(__name__)


def _coerce_function_args(args: Any) -> dict[str, Any]:
    if isinstance(args, dict):
        return cast(dict[str, Any], args)
    if isinstance(args, str):
        try:
            parsed = json.loads(args)
            return parsed if isinstance(parsed, dict) else {"value": parsed}
        except json.JSONDecodeError:
            return {"raw": args}
    if args is None:
        return {}
    return {"value": args}


def _coerce_function_response(response: Any) -> dict[str, Any]:
    if isinstance(response, dict):
        return cast(dict[str, Any], response)
    if isinstance(response, str):
        try:
            parsed = json.loads(response)
            return parsed if isinstance(parsed, dict) else {"result": parsed}
        except json.JSONDecodeError:
            return {"result": response}
    if response is None:
        return {}
    return {"result": response}

def extract_path_index(result: dict[str, Any]) -> int:
    """从 result 对象中提取 path 索引"""
    path = result.get('path', [])
    if not path or not isinstance(path, list):
        return -1
    try:
        # 索引通常是路径的最后一个整数元素
        path_list = cast(list[Any], path)
        for elem in reversed(path_list):
            if isinstance(elem, int):
                return elem
            if isinstance(elem, str) and elem.isdigit():
                return int(elem)
    except (IndexError, ValueError, TypeError):
        pass
    return -1

def clean_json_string(raw_data: str) -> str:
    """清理并规范化 JSON 字符串"""
    cleaned_data = raw_data.strip()
    if cleaned_data.endswith(','):
        cleaned_data = cleaned_data[:-1]
    
    if not cleaned_data.startswith('['):
        cleaned_data = f'[{cleaned_data}]'
    elif not cleaned_data.endswith(']'):
         # 这是一个不完整的数组,尝试补全
         if '}]' not in cleaned_data:
            cleaned_data += ']'
    return cleaned_data

def process_candidate_metadata(candidate_data: dict[str, Any]) -> dict[str, Any]:
    """提取 candidate 级别的元数据(仅提取实际存在的字段)"""
    metadata: dict[str, Any] = {}
    
    finish_reason = candidate_data.get('finishReason')
    if finish_reason:
        metadata['finish_reason'] = finish_reason
        
    if 'finishMessage' in candidate_data:
        metadata['finish_message'] = candidate_data['finishMessage']
    
    # 提取实际存在的元数据字段
    if candidate_data.get('safetyRatings'):
        metadata['safety_ratings'] = candidate_data['safetyRatings']
        
    if candidate_data.get('citationMetadata'):
        metadata['citation_metadata'] = candidate_data['citationMetadata']
        
    if candidate_data.get('groundingMetadata'):
        metadata['grounding_metadata'] = candidate_data['groundingMetadata']
        
    if 'tokenCount' in candidate_data:
        metadata['token_count'] = candidate_data['tokenCount']
        
    if 'avgLogprobs' in candidate_data:
        metadata['avg_logprobs'] = candidate_data['avgLogprobs']
        
    if 'logprobsResult' in candidate_data:
        metadata['logprobs_result'] = candidate_data['logprobsResult']
    
    # index 字段通常存在
    if candidate_data.get('index') is not None:
        metadata['candidate_index'] = candidate_data['index']
        
    return metadata

def _extract_error_message(item: dict[str, Any]) -> str | None:
    """从单个响应项中提取错误信息(如果有)"""
    
    # 1. 检查顶层 error 对象 (标准 Google Cloud 错误)
    error_obj = item.get('error')
    if error_obj:
        if isinstance(error_obj, dict):
            # 显式转换为 Dict 以满足类型检查
            safe_error_obj = cast(dict[str, Any], error_obj)
            return str(safe_error_obj.get('message', str(safe_error_obj)))
        return str(error_obj)

    # 2. 检查顶层 errors 列表 (GraphQL 风格或批处理错误)
    errors = item.get('errors')
    if errors and isinstance(errors, list):
        # 显式转换
        safe_errors = cast(list[Any], errors)
        if safe_errors:
            first_error = safe_errors[0]
            if isinstance(first_error, dict):
                safe_first_error = cast(dict[str, Any], first_error)
                return str(safe_first_error.get('message', str(safe_first_error)))
            return str(first_error)
            
    return None

def _clean_part_fields(part: dict[str, Any]) -> dict[str, Any]:
    """
    清理 part 中的空字段,只保留有实际内容的字段
    
    Args:
        part: 原始 part 字典
        
    Returns:
        清理后的 part 字典
    """
    if 'function_call' in part and 'functionCall' not in part:
        part = {**part, 'functionCall': part['function_call']}
    if 'function_response' in part and 'functionResponse' not in part:
        part = {**part, 'functionResponse': part['function_response']}
    if 'thought_signature' in part and 'thoughtSignature' not in part:
        part = {**part, 'thoughtSignature': part['thought_signature']}
    if 'inline_data' in part and 'inlineData' not in part:
        inline_data = part['inline_data']
        if isinstance(inline_data, dict):
            part = {**part, 'inlineData': {snake_to_camel(str(k)): v for k, v in cast(dict[Any, Any], inline_data).items()}}
        else:
            part = {**part, 'inlineData': inline_data}
    if 'file_data' in part and 'fileData' not in part:
        file_data = part['file_data']
        if isinstance(file_data, dict):
            part = {**part, 'fileData': {snake_to_camel(str(k)): v for k, v in cast(dict[Any, Any], file_data).items()}}
        else:
            part = {**part, 'fileData': file_data}

    try:
        from src.core.types import ContentPart
        content_part = ContentPart.model_validate(part)
        cleaned_part = content_part.model_dump(exclude_none=True, by_alias=True)
        # 额外处理 text 为空字符串的情况,以及一些需要保留真实值的嵌套字典
        if 'text' in cleaned_part and str(cleaned_part['text']) == "":
            del cleaned_part['text']
        if 'thought_signature' in cleaned_part and 'thoughtSignature' not in cleaned_part:
            cleaned_part['thoughtSignature'] = cleaned_part.pop('thought_signature')
        return cleaned_part
    except Exception as e:
        logger.debug(f"Pydantic 验证 part 失败,回退到基础清洗: {e}")
        cleaned_part: dict[str, Any] = {}
        
        # 处理文本内容
        if 'text' in part:
            text_value = part['text']
            if text_value is not None and str(text_value) != "":
                cleaned_part['text'] = text_value
        
        # 处理思考标记
        if 'thought' in part:
            cleaned_part['thought'] = part['thought']

        # 处理思考签名,兼容 REST snake_case 和 SDK camelCase
        if 'thoughtSignature' in part:
            cleaned_part['thoughtSignature'] = part['thoughtSignature']
        elif 'thought_signature' in part:
            cleaned_part['thoughtSignature'] = part['thought_signature']
        
        # 处理数据类型标记(如果存在且不为空)
        if 'data' in part and part['data']:
            cleaned_part['data'] = part['data']
        
        # 处理函数调用(只保留有名称的)
        if 'functionCall' in part or 'function_call' in part:
            func_call = part.get('functionCall') or part.get('function_call')
            if isinstance(func_call, dict):
                func_call_dict = cast(dict[str, Any], func_call)
                if func_call_dict.get('name') and str(func_call_dict['name']).strip():
                    fixed_call = func_call_dict.copy()
                    fixed_call['args'] = _coerce_function_args(fixed_call.get('args', {}))
                    cleaned_part['functionCall'] = fixed_call
        
        # 处理函数响应(只保留有名称的)
        if 'functionResponse' in part or 'function_response' in part:
            func_response = part.get('functionResponse') or part.get('function_response')
            if isinstance(func_response, dict):
                func_response_dict = cast(dict[str, Any], func_response)
                current_name = func_response_dict.get('name') or func_response_dict.get('functionName') or func_response_dict.get('function_name')
                if current_name and str(current_name).strip():
                    fixed_response = func_response_dict.copy()
                    fixed_response['name'] = str(current_name)
                    fixed_response['response'] = _coerce_function_response(fixed_response.get('response', {}))
                    cleaned_part['functionResponse'] = fixed_response
        
        # 处理内联数据(只保留有实际数据的)
        if 'inlineData' in part:
            inline_data = part['inlineData']
            if isinstance(inline_data, dict):
                inline_data_dict = cast(dict[str, Any], inline_data)
                if (inline_data_dict.get('data') and
                    str(inline_data_dict['data']).strip() and
                    inline_data_dict.get('mimeType') and
                    str(inline_data_dict['mimeType']).strip()):
                    cleaned_part['inlineData'] = inline_data
        
        # 处理文件数据(只保留有实际数据的)
        if 'fileData' in part:
            file_data = part['fileData']
            if isinstance(file_data, dict):
                file_data_dict = cast(dict[str, Any], file_data)
                if (file_data_dict.get('fileUri') and
                    str(file_data_dict['fileUri']).strip() and
                    file_data_dict.get('mimeType') and
                    str(file_data_dict['mimeType']).strip()):
                    cleaned_part['fileData'] = file_data
        
        return cleaned_part

def _merge_content_blocks(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """
    合并思考块和非思考块的文本内容
    
    Args:
        parts: 原始的 parts 列表
        
    Returns:
        合并后的 parts 列表,思考块在前,非思考块在后
    """
    # 首先清理所有 parts 中的空字段
    cleaned_parts = [_clean_part_fields(part) for part in parts]
    # 过滤掉完全为空的 parts
    cleaned_parts = [part for part in cleaned_parts if part]
    
    thought_texts: list[str] = []  # 思考块文本
    thought_signatures: list[str] = []  # 思考块签名
    content_texts: list[str] = []  # 非思考块文本
    other_parts: list[dict[str, Any]] = []  # 非文本部分(函数调用等)
    
    # 分类处理所有 parts
    for part in cleaned_parts:
            
        # 检查是否为文本块
        if 'text' in part and part['text'] is not None:
            text_content = str(part['text'])
            if text_content == "":
                continue
                
            # 判断是否为思考块
            is_thought = part.get('thought', False)
            
            if is_thought:
                thought_texts.append(text_content)
                if 'thoughtSignature' in part:
                    thought_signatures.append(part['thoughtSignature'])
            else:
                content_texts.append(text_content)
        else:
            # 非文本部分(如函数调用、函数响应等)保持原样
            other_parts.append(part)
    
    # 构建合并后的 parts 列表
    merged_parts: list[dict[str, Any]] = []
    
    # 1. 合并思考块文本(如果有)
    if thought_texts:
        merged_thought_text = ''.join(thought_texts)
        thought_part = {
            'text': merged_thought_text,
            'thought': True
        }
        # 重新插入签名(使用最后一个有效的签名,或者可以根据需要合并)
        if thought_signatures:
            thought_part['thoughtSignature'] = thought_signatures[-1]
            
        merged_parts.append(thought_part)
    
    # 2. 添加非文本部分(保持原有顺序)
    merged_parts.extend(other_parts)
    
    # 3. 合并非思考块文本(如果有)
    if content_texts:
        merged_content_text = ''.join(content_texts)
        merged_parts.append({
            'text': merged_content_text
        })
    
    return merged_parts

def parse_upstream_data(raw_data: str) -> dict[str, Any]:
    """
    解析完整的上游原始数据。
    
    Returns:
        包含 parts, finish_reason 和实际存在的元数据的字典
    """
    state: dict[str, Any] = {
        "finish_reason": None,
        "finish_message": None,
        "safety_ratings": [],
        "citation_metadata": {},
        "grounding_metadata": {},
        "token_count": None,
        "avg_logprobs": None,
        "logprobs_result": None,
        "candidate_index": 0,
        "prompt_feedback": {},
        "usage_metadata": {},
        "create_time": None,
        "model_version": None,
        "response_id": None,
        "model_status": None,
        "has_error": False,
        "error_message": "",
        "error_obj": None,
        "parts_by_path": {},
        "unindexed_parts": []
    }

    try:
        cleaned_data = clean_json_string(raw_data)
        data_list = json.loads(cleaned_data)
        
        if not isinstance(data_list, list):
            data_list = [data_list]
        
        # 显式转换为 List[Any]
        safe_data_list = cast(list[Any], data_list)

        for item in safe_data_list:
            if not isinstance(item, dict):
                continue
            
            item_dict = cast(dict[str, Any], item)
            
            # 1. 优先通过统一解析逻辑检查 item 中的错误 (处理 errors 数组)
            parsed_error = parse_error_response(item_dict)
            if parsed_error:
                # 如果是 "Failed to verify action",我们忽略它,因为这可能是匿名的第一个预期错误
                if "Failed to verify action" in parsed_error.message:
                    logger.debug(f"忽略预期的认证错误: {parsed_error.message}")
                else:
                    state["has_error"] = True
                    state["error_message"] = parsed_error.message
                    state["error_obj"] = parsed_error
                # 继续解析以尝试提取更多上下文
            
            # 2. 检查顶层错误 (作为兜底)
            error_msg = _extract_error_message(item_dict)
            if error_msg and not state["has_error"]:
                state["has_error"] = True
                state["error_message"] = error_msg
            
            # 3. 处理 results 列表
            results = item_dict.get('results', [])
            if not isinstance(results, list):
                continue
            
            typed_results: list[dict[str, Any]] = []
            safe_results = cast(list[Any], results)
            for r in safe_results:
                if isinstance(r, dict):
                    typed_results.append(cast(dict[str, Any], r))

            # 3. 检查 results 中的错误 (使用统一解析逻辑)
            parsed_error = parse_error_response(typed_results)
            if parsed_error:
                state["has_error"] = True
                state["error_message"] = parsed_error.message
                state["error_obj"] = parsed_error
            
            # 4. 提取数据 parts
            for result in typed_results:
                # 如果有 data=null 且有 errors,这已经被上面的 parsed_error 捕获
                # 我们跳过这个 result 的 data 处理,避免 NoneType 错误
                if result.get('data') is None and 'errors' in result:
                    continue

                path_index = extract_path_index(result)
                data = result.get('data')
                
                if isinstance(data, dict):
                    _update_state_from_data(state, cast(dict[str, Any], data), path_index)

    except json.JSONDecodeError as e:
        state["has_error"] = True
        state["error_message"] = f"JSON parse error: {e}"
    except VcoreError:
        raise
    except Exception as e:
        # 捕获其他未预期的解析错误
        logger.error(f"解析过程发生未知错误: {e}")
        state["has_error"] = True
        state["error_message"] = f"Parse error: {str(e)}"
    
    # 组装 parts - 先按原有逻辑收集所有parts
    parts_by_path = cast(dict[int, Any], state['parts_by_path'])
    ordered_parts: list[dict[str, Any]] = [parts_by_path[k] for k in sorted(parts_by_path.keys())]
    unindexed_parts = cast(list[Any], state['unindexed_parts'])
    ordered_parts.extend(unindexed_parts)
    
    # 新增:合并思考块和非思考块
    final_parts = _merge_content_blocks(ordered_parts)
    
    result: dict[str, Any] = {
        "parts": final_parts
    }
    # 将 state 中除了临时存储结构外的所有字段合并到 result
    excluded_keys = ['parts_by_path', 'unindexed_parts']
    result.update({k: v for k, v in state.items() if k not in excluded_keys})
    return result

def _update_state_from_data(state: dict[str, Any], data: dict[str, Any], path_index: int):
    """从数据对象更新解析状态(仅提取实际存在的字段)"""
    # 只提取实际存在的顶层元数据
    if data.get('promptFeedback'):
        state['prompt_feedback'] = data['promptFeedback']
    if 'usageMetadata' in data:
        state['usage_metadata'] = data['usageMetadata']
    if 'createTime' in data:
        state['create_time'] = data['createTime']
    if 'modelVersion' in data:
        state['model_version'] = data['modelVersion']
    if 'responseId' in data:
        state['response_id'] = data['responseId']
    if 'modelStatus' in data:
        state['model_status'] = data['modelStatus']
            
    # 处理 candidates
    candidates = data.get('candidates', [])
    for candidate in candidates:
        # 提取 candidate 元数据 (包括 finish_reason)
        meta = process_candidate_metadata(candidate)
        for k, v in meta.items():
            if v is not None and v != [] and v != {}:
                state[k] = v

        # 提取 content parts
        content = candidate.get('content', {})
        parts = content.get('parts', [])
        for part in parts:
            if path_index != -1:
                state['parts_by_path'][path_index] = part
            else:
                state['unindexed_parts'].append(part)