File size: 10,944 Bytes
5db30ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any

from elbench.schemas.evaluation import ModelResponse, Sample
from elbench.utils import extract_choice_answer


class ResponseFormatError(ValueError):
    def __init__(self, message: str, *, metadata: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.metadata = metadata or {}


@dataclass(slots=True)
class ResponseFormatSpec:
    name: str
    instruction: str
    reminder_template: str
    valid_letters: list[str] = field(default_factory=list)
    answer_prefixes: list[str] = field(default_factory=list)
    require_last_line_exact: bool = True
    allow_multiple: bool = False

    def validate(self, response_text: str | None) -> dict[str, Any]:
        text = (response_text or "").strip()
        if not text:
            raise ResponseFormatError(
                "Model response is empty.",
                metadata={"format_name": self.name, "expected_format": self.instruction},
            )
        answer = self._extract_answer(text)
        if answer is None:
            raise ResponseFormatError(
                "Model response does not follow the required answer format.",
                metadata={
                    "format_name": self.name,
                    "expected_format": self.instruction,
                    "response_preview": text[:200],
                },
            )
        return {
            "format_name": self.name,
            "expected_format": self.instruction,
            "parsed_answer": answer,
        }

    def _extract_answer(self, text: str) -> str | None:
        if not self.require_last_line_exact:
            return None
        return extract_choice_answer(
            text,
            self.valid_letters,
            answer_prefixes=self.answer_prefixes,
            allow_multiple=self.allow_multiple,
        )

    def build_reminder(self, response_text: str | None) -> str:
        preview = (response_text or "").strip()
        preview = re.sub(r"\s+", " ", preview)[:200]
        if not preview:
            return (
                "Your previous response was empty or unparsable. "
                f"Reply again and output only the final answer line. {self.instruction} "
                "Do not leave the answer blank. If unsure, provide your best guess in the required final line."
            ).strip()
        return self.reminder_template.format(
            expected_format=self.instruction,
            response_preview=preview,
        ).strip()


def should_force_concise_retry(response: ModelResponse | None) -> bool:
    if response is None:
        return False

    payload = response.raw_payload if isinstance(response.raw_payload, dict) else {}
    finish_reason = _first_choice_finish_reason(payload)
    if finish_reason != "length":
        return False
    if (response.text or "").strip():
        return True

    usage = response.usage if isinstance(response.usage, dict) else {}
    completion_tokens = _as_int(usage.get("completion_tokens"))
    reasoning_tokens = _as_int((usage.get("completion_tokens_details") or {}).get("reasoning_tokens"))
    return reasoning_tokens > 0 and completion_tokens >= 512


def build_retry_followup(
    spec: ResponseFormatSpec | None,
    response: ModelResponse | None,
) -> dict[str, Any]:
    response_text = response.text if response is not None else ""
    if spec is None:
        return {
            "assistant_content": response_text,
            "user_reminder": "",
            "retry_reason": "format_invalid",
            "max_tokens": None,
        }
    if should_force_concise_retry(response):
        return {
            "assistant_content": None,
            "user_reminder": (
                "Your previous response used up the output budget before giving the final answer. "
                "Do not include any reasoning, explanation, or thinking. "
                f"Reply with exactly one final answer line only. {spec.instruction}"
            ).strip(),
            "retry_reason": "format_empty_after_reasoning",
            "max_tokens": 64,
        }
    return {
        "assistant_content": response_text,
        "user_reminder": spec.build_reminder(response_text),
        "retry_reason": "format_invalid",
        "max_tokens": None,
    }


def build_concise_retry_prompt(base_prompt: str, spec: ResponseFormatSpec | None) -> str:
    if spec is not None and spec.name in {"single_choice_en", "single_choice_cn"}:
        return _build_choice_only_retry_prompt(base_prompt, spec)

    lines = []
    for line in base_prompt.splitlines():
        lowered = line.lower()
        if "think step by step" in lowered or "逐步思考" in line:
            continue
        lines.append(line)
    prompt = "\n".join(lines).rstrip()
    instruction = spec.instruction if spec is not None else "Output only the final answer."
    return (
        "CRITICAL OUTPUT RULE: Your entire response must be exactly one answer line. "
        "Do not include reasoning, explanation, calculations, restatement, markdown, or any extra text.\n"
        f"{instruction}\n\n"
        f"{prompt}\n\n"
        "FINAL REMINDER: Output exactly one answer line and nothing else. "
        f"{instruction}"
    ).strip()


def _build_choice_only_retry_prompt(base_prompt: str, spec: ResponseFormatSpec) -> str:
    prompt = _strip_reasoning_instructions(base_prompt)
    prompt = _last_question_block(prompt)
    valid = ",".join(spec.valid_letters) if spec.valid_letters else "A,B,C,D"
    if spec.name == "single_choice_cn":
        instruction = f"只输出一个大写选项字母({valid}),不要输出推理、解释、标点或其他文字。"
        final = "你的答案:"
    else:
        instruction = (
            f"Answer with exactly one uppercase option letter ({valid}) and nothing else. "
            "No reasoning, explanation, punctuation, markdown, or extra text."
        )
        final = "Your answer:"
    return f"{instruction}\n\n{prompt}\n\n{final}".strip()


def _strip_reasoning_instructions(prompt: str) -> str:
    filtered: list[str] = []
    for line in prompt.splitlines():
        lowered = line.lower()
        if "think step by step" in lowered or "逐步思考" in line or "先逐步思考" in line:
            continue
        filtered.append(line)
    return "\n".join(filtered).strip()


def _last_question_block(prompt: str) -> str:
    markers = ["Answer the following multiple choice question.", "问题:", "Question:"]
    start = -1
    for marker in markers:
        index = prompt.rfind(marker)
        if index > start:
            start = index
    if start >= 0:
        return prompt[start:].strip()
    return prompt.strip()


def get_response_format_spec(sample: Sample) -> ResponseFormatSpec | None:
    if sample.task == "mmlu_pro":
        return ResponseFormatSpec(
            name="single_choice_en",
            instruction="The last line must be exactly: ANSWER: [LETTER], where [LETTER] is a valid option letter.",
            reminder_template=(
                "Your previous response did not follow the required answer format. "
                "Reply again. If you include reasoning, still end with exactly one final line: ANSWER: [LETTER]. "
                "Do not add any extra text after that final answer line."
            ),
            valid_letters=_choice_letters_from_sample(sample),
            answer_prefixes=["ANSWER", "FINAL ANSWER"],
        )
    if sample.task == "ceval":
        return ResponseFormatSpec(
            name="single_choice_cn",
            instruction="最后一行必须严格是:答案:[LETTER],其中 [LETTER] 是合法选项字母。",
            reminder_template=(
                "你上一轮回复没有遵守规定格式。请重新回答,最后一行必须严格是:答案:[LETTER]。"
                "不要在最后答案行之后添加任何额外文本。"
            ),
            valid_letters=_choice_letters_from_sample(sample),
            answer_prefixes=["答案", "最终答案", "ANSWER", "FINAL ANSWER"],
        )
    if sample.task == "highlevel_omni":
        return ResponseFormatSpec(
            name="choice_omni",
            instruction="最后一行必须严格是:答案:[LETTER(S)],其中 [LETTER(S)] 是 A、B、C、D 中的一个或多个字母。",
            reminder_template=(
                "你上一轮回复没有遵守规定格式。请重新回答,最后一行必须严格是:答案:[LETTER(S)]。"
                "如果有多个正确选项,请连续写出多个字母,例如:答案:AC。"
                "不要在最后答案行之后添加任何额外文本。"
            ),
            valid_letters=list("ABCD"),
            answer_prefixes=["答案", "最终答案", "ANSWER", "FINAL ANSWER"],
            allow_multiple=True,
        )
    return None


def augment_prompt_for_format(sample: Sample, prompt: str) -> str:
    spec = get_response_format_spec(sample)
    if spec is None:
        return prompt
    if spec.instruction in prompt:
        return prompt
    if sample.task == "mmlu_pro" and "ANSWER: [LETTER]" in prompt:
        return prompt
    if sample.task == "ceval" and "答案:[LETTER]" in prompt:
        return prompt
    if sample.task == "highlevel_omni" and "答案:[LETTER]" in prompt:
        return prompt
    return f"{prompt.rstrip()}\n\n[Answer Format]\n{spec.instruction}"


def attach_format_metadata(response: ModelResponse, spec: ResponseFormatSpec | None) -> ModelResponse:
    if spec is None:
        response.format_valid = None
        return response
    if str(response.error or "").strip().lower() in {"content_filter", "invalid_prompt"}:
        response.format_valid = False
        response.format_metadata = {
            "format_name": spec.name,
            "expected_format": spec.instruction,
            "blocked_by_upstream_filter": True,
            "error": response.error,
        }
        return response
    metadata = spec.validate(response.text)
    response.format_valid = True
    response.format_metadata = metadata
    return response


def _choice_letters_from_sample(sample: Sample) -> list[str]:
    choices = sample.metadata.get("choices")
    if isinstance(choices, list) and choices:
        return [chr(65 + index) for index in range(len(choices))]
    return list("ABCDEFGHIJ")


def _first_choice_finish_reason(payload: dict[str, Any]) -> str:
    choices = payload.get("choices")
    if not isinstance(choices, list) or not choices:
        return ""
    first = choices[0]
    if not isinstance(first, dict):
        return ""
    value = first.get("finish_reason")
    return "" if value in (None, "") else str(value).strip().lower()


def _as_int(value: Any) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0