File size: 13,045 Bytes
e7e1e90
 
d6b82a1
e7e1e90
6c511d6
 
 
 
d6b82a1
 
 
 
 
 
6c511d6
 
 
e7e1e90
 
 
 
 
 
 
 
 
 
 
 
 
d6b82a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7e1e90
 
 
 
 
6b261f5
e7e1e90
6c511d6
 
 
 
6b261f5
 
 
 
 
 
 
 
 
6c511d6
 
 
 
 
 
6b261f5
6c511d6
 
e7e1e90
 
 
 
6c511d6
d6b82a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c511d6
 
e7e1e90
 
d6b82a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7e1e90
 
d6b82a1
 
 
 
 
e7e1e90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6b82a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b261f5
d6b82a1
 
 
 
 
 
 
6b261f5
 
 
 
 
 
 
 
d6b82a1
6b261f5
 
 
 
 
 
 
 
d6b82a1
 
 
 
 
 
 
 
 
 
 
 
 
 
e7e1e90
6b261f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7e1e90
 
6c511d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import json
import re
import ast
from dataclasses import dataclass
from typing import Any

import requests

from config import (
    HF_PLANNER_USE_RESPONSE_FORMAT,
    HF_ROUTER_URL,
    HF_TEXT_MODEL,
    get_hf_token,
)
from tools.common import normalize_answer


@dataclass
class AgentAction:
    action: str
    args: dict[str, Any]
    thought: str = ""
    answer: str | None = None
    confidence: str = "medium"

    @property
    def is_final(self) -> bool:
        return self.action == "final_answer"


@dataclass
class QuestionClassification:
    question_type: str
    confidence: str = "medium"
    reason: str = ""
    query: str = ""
    raw: str = ""


@dataclass
class FinalAnswerFormat:
    answer: str
    confidence: str = "medium"
    raw: str = ""


def call_hf_chat(
    messages: list[dict[str, Any]],
    model: str = HF_TEXT_MODEL,
    max_tokens: int = 512,
    temperature: float = 0.1,
    response_format: dict[str, Any] | None = None,
) -> str:
    token = get_hf_token()
    if not token:
        raise RuntimeError("未配置 HF_TOKEN。")

    payload: dict[str, Any] = {
        "model": model,
        "messages": messages,
        "temperature": temperature,
        "max_tokens": max_tokens,
    }
    if response_format:
        payload["response_format"] = response_format

    response = requests.post(
        HF_ROUTER_URL,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        },
        json=payload,
        timeout=180,
    )
    if not response.ok:
        raise RuntimeError(
            f"HF chat 请求失败:status={response.status_code}, body={response.text[:500]}"
        )
    data = response.json()
    try:
        message = data["choices"][0]["message"]
    except (KeyError, IndexError, TypeError) as exc:
        raise RuntimeError(f"HF chat 响应结构异常:{_compact_json(data)}") from exc
    return extract_message_text(message, data)


def extract_message_text(message: dict[str, Any], raw_response: dict[str, Any]) -> str:
    """兼容不同 HF Router provider 返回的 chat message content 格式。"""
    content = message.get("content")
    if isinstance(content, str) and content.strip():
        return content
    if isinstance(content, list):
        parts = []
        for item in content:
            if isinstance(item, str):
                parts.append(item)
            elif isinstance(item, dict):
                for key in ("text", "content"):
                    value = item.get(key)
                    if isinstance(value, str):
                        parts.append(value)
        joined = "\n".join(part for part in parts if part.strip()).strip()
        if joined:
            return joined

    fallback_parts = []
    for key in ("reasoning_content", "reasoning", "text"):
        value = message.get(key)
        if isinstance(value, str) and value.strip():
            fallback_parts.append(f"{key}={value}")
    if fallback_parts:
        return "\n".join(fallback_parts)

    raise RuntimeError(
        "HF chat 返回空内容。"
        f" message_keys={sorted(message.keys())}; raw={_compact_json(raw_response)}"
    )


def extract_json_object(text: str) -> dict[str, Any]:
    text = text.strip()
    candidates = [text]

    fenced_blocks = re.findall(
        r"```(?:json)?\s*(.*?)```",
        text,
        flags=re.IGNORECASE | re.DOTALL,
    )
    candidates.extend(block.strip() for block in fenced_blocks)
    candidates.extend(_balanced_json_candidates(text))

    errors = []
    for candidate in candidates:
        if not candidate:
            continue
        try:
            parsed = json.loads(candidate)
        except json.JSONDecodeError as exc:
            errors.append(str(exc))
        else:
            if isinstance(parsed, dict):
                return parsed

        try:
            parsed_literal = ast.literal_eval(candidate)
        except (SyntaxError, ValueError):
            continue
        if isinstance(parsed_literal, dict):
            return parsed_literal

    error_hint = f"; parse_errors={errors[:2]}" if errors else ""
    raise ValueError(f"模型没有输出 JSON 对象:{text[:300]}{error_hint}")


def _balanced_json_candidates(text: str) -> list[str]:
    candidates = []
    starts = [index for index, char in enumerate(text) if char == "{"]
    for start in starts:
        depth = 0
        in_string = False
        escaped = False
        for index in range(start, len(text)):
            char = text[index]
            if in_string:
                if escaped:
                    escaped = False
                elif char == "\\":
                    escaped = True
                elif char == '"':
                    in_string = False
                continue
            if char == '"':
                in_string = True
            elif char == "{":
                depth += 1
            elif char == "}":
                depth -= 1
                if depth == 0:
                    candidates.append(text[start : index + 1])
                    break
    return candidates


def _compact_json(data: Any, limit: int = 1200) -> str:
    text = json.dumps(data, ensure_ascii=False, default=str)
    if len(text) <= limit:
        return text
    return text[:limit] + "...<truncated>"


def parse_agent_action(text: str) -> AgentAction:
    data = extract_json_object(text)
    action = data.get("action") or data.get("tool")
    if not isinstance(action, str) or not action.strip():
        raise ValueError(f"模型 JSON 缺少 action 字段:{data}")

    args = data.get("args") or {}
    if not isinstance(args, dict):
        raise ValueError(f"action args 必须是对象:{data}")

    answer = data.get("answer")
    if answer is not None:
        answer = str(answer)

    return AgentAction(
        action=action.strip(),
        args=args,
        thought=str(data.get("thought", "")).strip(),
        answer=answer,
        confidence=str(data.get("confidence", "medium")).strip() or "medium",
    )


def classify_question_type(
    question: str,
    task_id: str,
    file_name: str,
    type_specs: list[dict[str, str]],
) -> QuestionClassification:
    allowed_types = {spec["type"] for spec in type_specs}
    type_lines = "\n".join(
        (
            f"- {spec['type']}: tool={spec['tool']}; "
            f"description={spec['description']}"
        )
        for spec in type_specs
    )
    prompt = f"""
Classify this GAIA task into exactly one question_type.

Available question types:
{type_lines}

Return exactly one JSON object:
{{
  "question_type": "one allowed type",
  "confidence": "high|medium|low",
  "reason": "short reason",
  "query": "optional search/query string if useful"
}}

Rules:
1. Do not answer the question.
2. Choose the tool category that should be tried first.
3. If no type clearly fits, use "unknown".
4. Prefer direct_text for self-contained table, string, list, or regex-like tasks.
5. Prefer python_code for .py attachments.
6. Prefer spreadsheet for .xlsx/.xls attachments.

Task:
task_id={task_id}
file_name={file_name}
question={question}
""".strip()

    raw = call_hf_chat(
        [
            {
                "role": "system",
                "content": (
                    "You are a strict GAIA task classifier. "
                    "Return JSON only. Never solve the task."
                ),
            },
            {"role": "user", "content": prompt},
        ],
        max_tokens=350,
        temperature=0.0,
    )
    data = extract_json_object(raw)
    question_type = str(data.get("question_type") or data.get("type") or "unknown").strip()
    if question_type not in allowed_types:
        question_type = "unknown"
    confidence = str(data.get("confidence", "medium")).strip() or "medium"
    reason = str(data.get("reason", "")).strip()
    query = str(data.get("query", "")).strip()
    return QuestionClassification(
        question_type=question_type,
        confidence=confidence,
        reason=reason,
        query=query,
        raw=raw,
    )


def format_final_answer_with_llm(
    question: str,
    question_type: str,
    candidate_answer: str,
    evidence: str,
    source: str,
    confidence: str,
) -> FinalAnswerFormat:
    prompt = f"""
Format the tool result into the exact final answer required by the GAIA question.

Question:
{question}

Question type:
{question_type}

Tool source:
{source}

Tool confidence:
{confidence}

Candidate answer:
{candidate_answer or "<none>"}

Tool evidence:
{evidence or "<none>"}

Return exactly one JSON object:
{{"answer":"final answer only","confidence":"high|medium|low"}}

Rules:
1. Do not explain.
2. If candidate_answer is present, preserve its factual content and only fix formatting.
3. Obey requested capitalization, decimal places, ordering, separators, and units.
4. If evidence is insufficient and no candidate_answer is available, return answer "无法确定" with low confidence.
""".strip()
    raw = call_hf_chat(
        [
            {
                "role": "system",
                "content": (
                    "You are a strict final-answer formatter. "
                    "Return JSON only and never include reasoning."
                ),
            },
            {"role": "user", "content": prompt},
        ],
        max_tokens=300,
        temperature=0.0,
    )
    data = extract_json_object(raw)
    answer = data.get("answer") or data.get("final_answer")
    if answer is None:
        raise ValueError(f"格式化模型 JSON 缺少 answer 字段:{data}")
    return FinalAnswerFormat(
        answer=normalize_answer(str(answer)),
        confidence=str(data.get("confidence", confidence)).strip() or confidence,
        raw=raw,
    )


def call_planner_model(messages: list[dict[str, Any]]) -> str:
    if not HF_PLANNER_USE_RESPONSE_FORMAT:
        return call_hf_chat(
            messages,
            max_tokens=700,
            temperature=0.0,
        )

    try:
        return call_hf_chat(
            messages,
            max_tokens=700,
            temperature=0.0,
            response_format={"type": "json_object"},
        )
    except RuntimeError as exc:
        if not _should_retry_without_response_format(str(exc)):
            raise
        return call_hf_chat(
            messages,
            max_tokens=700,
            temperature=0.0,
        )


def _should_retry_without_response_format(error_text: str) -> bool:
    lower_error = error_text.lower()
    return any(
        marker in lower_error
        for marker in (
            "response_format",
            "json_object",
            "json mode",
            "空内容",
            "empty",
        )
    )


def plan_next_action(messages: list[dict[str, Any]]) -> AgentAction:
    raw = call_planner_model(messages)
    try:
        return parse_agent_action(raw)
    except Exception as first_error:
        repair_messages = messages + [
            {
                "role": "assistant",
                "content": raw or "<empty response>",
            },
            {
                "role": "user",
                "content": (
                    "Your previous response was invalid because it was not one JSON object. "
                    "Return exactly one JSON object now, with no markdown and no prose. "
                    "Valid tool-call schema: "
                    "{\"thought\":\"...\",\"action\":\"tool_name\",\"args\":{}}. "
                    "Valid final-answer schema: "
                    "{\"thought\":\"...\",\"action\":\"final_answer\",\"answer\":\"...\",\"confidence\":\"high|medium|low\"}."
                ),
            },
        ]
        repaired_raw = call_planner_model(repair_messages)
        try:
            return parse_agent_action(repaired_raw)
        except Exception as second_error:
            raise ValueError(
                "planner 连续两次没有输出合法 JSON。"
                f" first_error={first_error}; first_raw={raw[:300]!r};"
                f" second_error={second_error}; second_raw={repaired_raw[:300]!r}"
            ) from second_error


def answer_with_light_model(question: str, evidence: str) -> str:
    prompt = f"""
你是一个 GAIA 问答 Agent。你会收到问题和工具已经整理好的证据。

硬性规则:
1. 只输出最终答案,不输出推理过程。
2. 严格遵守题目要求的格式、大小写、排序、逗号、单位和小数位。
3. 如果证据不足,不要编造;输出你能从证据中最可靠得到的答案。
4. 不要添加 "Answer:"、"最终答案:" 或解释性文字。

问题:
{question}

工具证据:
{evidence}
""".strip()

    raw_answer = call_hf_chat(
        [
            {
                "role": "system",
                "content": "You are a precise GAIA final-answer agent. Return only the final answer.",
            },
            {"role": "user", "content": prompt},
        ],
        max_tokens=500,
    )
    return normalize_answer(raw_answer)