2026-07-23 15:10:13 +08:00
|
|
|
|
"""数据处理任务的大模型生成适配器。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
2026-07-28 10:56:05 +08:00
|
|
|
|
import logging
|
2026-07-23 15:10:13 +08:00
|
|
|
|
import re
|
|
|
|
|
|
from collections.abc import Callable, Iterable, Mapping
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-07-24 20:43:47 +08:00
|
|
|
|
from app.modules.data_process.algorithms import normalize_text, stable_split_assignments
|
2026-07-27 09:11:51 +08:00
|
|
|
|
from app.modules.data_process.constants import (
|
|
|
|
|
|
MAX_QA_PAIRS_PER_ITEM,
|
|
|
|
|
|
MODEL_GENERATION_BATCH_SIZE,
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ModelGenerationError(ValueError):
|
|
|
|
|
|
"""模型配置、响应或调用失败。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 10:56:05 +08:00
|
|
|
|
class _TerminalModelGenerationError(ModelGenerationError):
|
|
|
|
|
|
"""使用相同参数重试也无法恢复的模型响应错误。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 13:08:44 +08:00
|
|
|
|
OUTPUT_TYPE_STANDARD = "standard"
|
|
|
|
|
|
OUTPUT_TYPE_REASONING = "reasoning"
|
|
|
|
|
|
SUPPORTED_OUTPUT_TYPES = {OUTPUT_TYPE_STANDARD, OUTPUT_TYPE_REASONING}
|
2026-07-27 14:41:38 +08:00
|
|
|
|
REASONING_DETAIL_NORMAL = "normal"
|
|
|
|
|
|
REASONING_DETAIL_DETAILED = "detailed"
|
|
|
|
|
|
SUPPORTED_REASONING_DETAILS = {
|
|
|
|
|
|
REASONING_DETAIL_NORMAL,
|
|
|
|
|
|
REASONING_DETAIL_DETAILED,
|
|
|
|
|
|
}
|
2026-07-28 10:56:05 +08:00
|
|
|
|
MINIMAX_M3_API_HOSTS = {"api.minimax.io", "api.minimaxi.com"}
|
|
|
|
|
|
MINIMAX_M3_MIN_COMPLETION_TOKENS = 4096
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_retryable_generation_error(exc: Exception) -> bool:
|
|
|
|
|
|
if isinstance(exc, _TerminalModelGenerationError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
|
|
|
|
status_code = exc.response.status_code
|
|
|
|
|
|
return status_code in {408, 425, 429} or status_code >= 500
|
|
|
|
|
|
if isinstance(exc, httpx.RequestError):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return isinstance(exc, (json.JSONDecodeError, ModelGenerationError))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_official_minimax_m3(endpoint: str, model_name: str) -> bool:
|
|
|
|
|
|
host = (urlsplit(endpoint).hostname or "").casefold()
|
|
|
|
|
|
return host in MINIMAX_M3_API_HOSTS and model_name.casefold() == "minimax-m3"
|
2026-07-27 13:08:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
def chat_completions_url(value: str) -> str:
|
|
|
|
|
|
"""把域名、基础 URL 或完整地址统一为 chat completions 地址。"""
|
|
|
|
|
|
|
|
|
|
|
|
raw = (value or "").strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
raise ModelGenerationError("generation model api_url is required")
|
|
|
|
|
|
if "://" not in raw:
|
|
|
|
|
|
raw = f"https://{raw}"
|
|
|
|
|
|
parsed = urlsplit(raw)
|
|
|
|
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
|
|
|
|
raise ModelGenerationError("generation model api_url must be an HTTP(S) host or URL")
|
|
|
|
|
|
if parsed.username or parsed.password:
|
|
|
|
|
|
raise ModelGenerationError("generation model api_url must not contain credentials")
|
|
|
|
|
|
|
|
|
|
|
|
path = parsed.path.rstrip("/")
|
|
|
|
|
|
if path.endswith("/chat/completions"):
|
|
|
|
|
|
target_path = path
|
|
|
|
|
|
elif path.endswith("/v1"):
|
|
|
|
|
|
target_path = f"{path}/chat/completions"
|
|
|
|
|
|
elif not path:
|
|
|
|
|
|
target_path = "/v1/chat/completions"
|
|
|
|
|
|
else:
|
|
|
|
|
|
target_path = f"{path}/v1/chat/completions"
|
|
|
|
|
|
return urlunsplit((parsed.scheme, parsed.netloc, target_path, "", ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 10:56:05 +08:00
|
|
|
|
def _response_choice(payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
2026-07-23 15:10:13 +08:00
|
|
|
|
try:
|
2026-07-28 10:56:05 +08:00
|
|
|
|
choice = payload["choices"][0]
|
2026-07-23 15:10:13 +08:00
|
|
|
|
except (KeyError, IndexError, TypeError) as exc:
|
2026-07-28 10:56:05 +08:00
|
|
|
|
raise ModelGenerationError("模型响应缺少 choices[0]") from exc
|
|
|
|
|
|
if not isinstance(choice, Mapping):
|
|
|
|
|
|
raise ModelGenerationError("模型响应 choices[0] 不是对象")
|
|
|
|
|
|
return choice
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _response_finish_reason(payload: Mapping[str, Any]) -> str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return str(_response_choice(payload).get("finish_reason") or "").strip().lower()
|
|
|
|
|
|
except ModelGenerationError:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _response_content_length(payload: Mapping[str, Any]) -> int:
|
|
|
|
|
|
try:
|
|
|
|
|
|
message = _response_choice(payload).get("message")
|
|
|
|
|
|
if not isinstance(message, Mapping):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
content = message.get("content")
|
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
|
return len(content)
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
return sum(
|
|
|
|
|
|
len(str(item.get("text") or ""))
|
|
|
|
|
|
for item in content
|
|
|
|
|
|
if isinstance(item, Mapping)
|
|
|
|
|
|
)
|
|
|
|
|
|
except ModelGenerationError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raise_for_terminal_response(payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
|
|
|
choice = _response_choice(payload)
|
|
|
|
|
|
base_response = payload.get("base_resp")
|
|
|
|
|
|
status_code: Any = None
|
|
|
|
|
|
status_message = ""
|
|
|
|
|
|
if isinstance(base_response, Mapping):
|
|
|
|
|
|
status_code = base_response.get("status_code")
|
|
|
|
|
|
status_message = re.sub(
|
|
|
|
|
|
r"\s+", " ", str(base_response.get("status_msg") or "")
|
|
|
|
|
|
).strip()[:200]
|
|
|
|
|
|
|
|
|
|
|
|
if bool(payload.get("input_sensitive")) or status_code in {1026, "1026"}:
|
|
|
|
|
|
raise _TerminalModelGenerationError(
|
|
|
|
|
|
f"模型输入触发内容安全拦截(code={status_code or 1026})"
|
|
|
|
|
|
)
|
|
|
|
|
|
if bool(payload.get("output_sensitive")) or status_code in {1027, "1027"}:
|
|
|
|
|
|
raise _TerminalModelGenerationError(
|
|
|
|
|
|
f"模型输出触发内容安全拦截(code={status_code or 1027})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
finish_reason = str(choice.get("finish_reason") or "").strip().lower()
|
|
|
|
|
|
if finish_reason == "length":
|
|
|
|
|
|
raise _TerminalModelGenerationError(
|
|
|
|
|
|
"模型输出因达到 Token 上限被截断(finish_reason=length),"
|
|
|
|
|
|
"请提高最大输出长度后重试"
|
|
|
|
|
|
)
|
|
|
|
|
|
if finish_reason == "content_filter":
|
|
|
|
|
|
raise _TerminalModelGenerationError(
|
|
|
|
|
|
"模型输出被内容安全策略拦截(finish_reason=content_filter)"
|
|
|
|
|
|
)
|
|
|
|
|
|
if finish_reason in {"tool_calls", "function_call"}:
|
|
|
|
|
|
raise _TerminalModelGenerationError(
|
|
|
|
|
|
f"模型返回了当前生成任务不支持的工具调用(finish_reason={finish_reason})"
|
|
|
|
|
|
)
|
|
|
|
|
|
if status_code not in {None, "", 0, "0"}:
|
|
|
|
|
|
detail = f":{status_message}" if status_message else ""
|
|
|
|
|
|
raise _TerminalModelGenerationError(
|
|
|
|
|
|
f"模型服务返回业务错误(code={status_code}){detail}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return choice
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _message_content(payload: Mapping[str, Any]) -> str:
|
|
|
|
|
|
choice = _raise_for_terminal_response(payload)
|
|
|
|
|
|
message = choice.get("message")
|
|
|
|
|
|
if not isinstance(message, Mapping):
|
|
|
|
|
|
raise ModelGenerationError("模型响应缺少 choices[0].message")
|
|
|
|
|
|
content = message.get("content")
|
2026-07-23 15:10:13 +08:00
|
|
|
|
if isinstance(content, str):
|
2026-07-28 10:56:05 +08:00
|
|
|
|
result = content
|
|
|
|
|
|
elif isinstance(content, list):
|
2026-07-23 15:10:13 +08:00
|
|
|
|
parts = [
|
|
|
|
|
|
str(item.get("text") or "")
|
|
|
|
|
|
for item in content
|
|
|
|
|
|
if isinstance(item, Mapping) and item.get("type") in {None, "text", "output_text"}
|
|
|
|
|
|
]
|
2026-07-28 10:56:05 +08:00
|
|
|
|
result = "".join(parts)
|
|
|
|
|
|
elif content is None:
|
|
|
|
|
|
result = ""
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ModelGenerationError("模型响应 content 必须是文本")
|
|
|
|
|
|
if not result.strip():
|
|
|
|
|
|
raise ModelGenerationError("模型返回的最终内容为空,未生成可解析的 JSON")
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_documents(content: str) -> list[Any]:
|
|
|
|
|
|
decoder = json.JSONDecoder()
|
|
|
|
|
|
documents: list[Any] = []
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
while cursor < len(content):
|
|
|
|
|
|
match = re.search(r"[\[{]", content[cursor:])
|
|
|
|
|
|
if not match:
|
|
|
|
|
|
break
|
|
|
|
|
|
start = cursor + match.start()
|
|
|
|
|
|
try:
|
|
|
|
|
|
value, end = decoder.raw_decode(content[start:])
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
cursor = start + 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(value, (Mapping, list)):
|
|
|
|
|
|
documents.append(value)
|
|
|
|
|
|
cursor = start + max(end, 1)
|
|
|
|
|
|
return documents
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_payload(content: str) -> Any:
|
2026-07-27 13:08:44 +08:00
|
|
|
|
# 只移除模型在 JSON 之前自行输出的思考过程,不能破坏 JSON 字段中的训练内容。
|
2026-07-28 10:56:05 +08:00
|
|
|
|
cleaned = content.strip()
|
|
|
|
|
|
if re.match(r"^\s*<think>", cleaned, flags=re.IGNORECASE) and not re.match(
|
|
|
|
|
|
r"^\s*<think>[\s\S]*?</think>", cleaned, flags=re.IGNORECASE
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ModelGenerationError("模型思考内容未闭合,响应可能已被截断")
|
2026-07-27 13:08:44 +08:00
|
|
|
|
cleaned = re.sub(
|
2026-07-28 10:56:05 +08:00
|
|
|
|
r"^\s*(?:<think>[\s\S]*?</think>\s*)+",
|
2026-07-27 13:08:44 +08:00
|
|
|
|
"",
|
2026-07-28 10:56:05 +08:00
|
|
|
|
cleaned,
|
2026-07-27 13:08:44 +08:00
|
|
|
|
count=1,
|
|
|
|
|
|
flags=re.IGNORECASE,
|
|
|
|
|
|
).strip()
|
2026-07-23 15:10:13 +08:00
|
|
|
|
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, flags=re.IGNORECASE)
|
|
|
|
|
|
if fenced:
|
|
|
|
|
|
cleaned = fenced.group(1).strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(cleaned)
|
2026-07-28 10:56:05 +08:00
|
|
|
|
except json.JSONDecodeError as direct_error:
|
|
|
|
|
|
documents = _json_documents(cleaned)
|
|
|
|
|
|
if len(documents) == 1:
|
|
|
|
|
|
return documents[0]
|
|
|
|
|
|
if len(documents) > 1:
|
|
|
|
|
|
raise ModelGenerationError("模型响应包含多个 JSON 对象,无法确定应使用哪一个")
|
2026-07-23 15:10:13 +08:00
|
|
|
|
raise ModelGenerationError(
|
2026-07-28 10:56:05 +08:00
|
|
|
|
"模型响应中没有找到唯一且完整的 JSON 对象"
|
|
|
|
|
|
f"(第 {direct_error.lineno} 行,第 {direct_error.colno} 列)"
|
|
|
|
|
|
) from direct_error
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _result_items(payload: Any) -> list[Mapping[str, Any]]:
|
|
|
|
|
|
if isinstance(payload, list):
|
|
|
|
|
|
values = payload
|
|
|
|
|
|
elif isinstance(payload, Mapping):
|
|
|
|
|
|
nested = next(
|
|
|
|
|
|
(
|
|
|
|
|
|
payload[key]
|
|
|
|
|
|
for key in ("items", "results", "data", "records")
|
|
|
|
|
|
if isinstance(payload.get(key), list)
|
|
|
|
|
|
),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
values = nested if isinstance(nested, list) else [payload]
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ModelGenerationError("model JSON must be an object or array")
|
|
|
|
|
|
items = [item for item in values if isinstance(item, Mapping)]
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
raise ModelGenerationError("model JSON does not contain result objects")
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
def _prompt_messages(
|
|
|
|
|
|
prompt: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
count: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
start_index: int,
|
|
|
|
|
|
total_count: int,
|
2026-07-27 13:08:44 +08:00
|
|
|
|
output_type: str,
|
2026-07-27 14:41:38 +08:00
|
|
|
|
reasoning_detail: str,
|
2026-07-27 09:11:51 +08:00
|
|
|
|
) -> list[dict[str, str]]:
|
|
|
|
|
|
end_index = start_index + count - 1
|
2026-07-27 13:08:44 +08:00
|
|
|
|
if output_type == OUTPUT_TYPE_REASONING:
|
|
|
|
|
|
schema = '{"items":[{"instruction":"...","input":"...","reasoning":"...","answer":"..."}]}'
|
2026-07-27 14:41:38 +08:00
|
|
|
|
detail_rule = (
|
|
|
|
|
|
"推理详细程度为“详细”:完整展开问题条件、来源依据、中间计算或推导,"
|
|
|
|
|
|
"并在得出答案前核对结论;每一步都必须能从来源内容中验证。"
|
|
|
|
|
|
if reasoning_detail == REASONING_DETAIL_DETAILED
|
|
|
|
|
|
else
|
|
|
|
|
|
"推理详细程度为“普通”:只保留得出答案所需的关键依据和必要步骤,"
|
|
|
|
|
|
"避免冗长复述、套话和无依据扩展。"
|
|
|
|
|
|
)
|
2026-07-27 13:08:44 +08:00
|
|
|
|
output_rule = (
|
2026-07-27 14:41:38 +08:00
|
|
|
|
"你正在生成用于训练推理模型的思维链数据,而不是普通问答数据。"
|
2026-07-27 13:08:44 +08:00
|
|
|
|
"instruction、reasoning 和 answer 均不得为空;reasoning 必须是基于来源内容、"
|
2026-07-27 14:41:38 +08:00
|
|
|
|
f"可核对的推理过程,answer 只写最终答案。{detail_rule}"
|
2026-07-27 13:08:44 +08:00
|
|
|
|
"这是思维链输出模式,即使其他提示语要求省略分析,也不得省略 reasoning。"
|
|
|
|
|
|
"不要自行添加 <think> 标签,系统会在保存时统一组装。"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
schema = '{"items":[{"instruction":"...","input":"...","output":"..."}]}'
|
2026-07-27 14:41:38 +08:00
|
|
|
|
output_rule = (
|
|
|
|
|
|
"你正在生成标准监督微调问答数据。instruction 和 output 不得为空;"
|
|
|
|
|
|
"output 只写最终答案,禁止输出分析、推理过程或 <think> 标签。"
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
schema_instruction = (
|
2026-07-27 13:08:44 +08:00
|
|
|
|
f"必须只返回 JSON 对象,格式为 {schema};items 必须包含 {count} 条。"
|
2026-07-27 09:11:51 +08:00
|
|
|
|
f"这是总计 {total_count} 条中的第 {start_index}-{end_index} 条,"
|
|
|
|
|
|
"各条必须使用不同的提问角度和表述,避免重复。"
|
2026-07-27 13:08:44 +08:00
|
|
|
|
f"{output_rule}不要输出 Markdown 代码围栏或 JSON 之外的说明。"
|
2026-07-23 15:10:13 +08:00
|
|
|
|
)
|
2026-07-27 13:08:44 +08:00
|
|
|
|
base_prompt = normalize_text(prompt) or "请根据来源内容生成可用于监督微调的问答数据。"
|
2026-07-23 15:10:13 +08:00
|
|
|
|
if "{{ content }}" in base_prompt:
|
|
|
|
|
|
user_prompt = base_prompt.replace("{{ content }}", content)
|
|
|
|
|
|
return [
|
|
|
|
|
|
{"role": "system", "content": schema_instruction},
|
|
|
|
|
|
{"role": "user", "content": user_prompt},
|
|
|
|
|
|
]
|
|
|
|
|
|
return [
|
|
|
|
|
|
{"role": "system", "content": f"{base_prompt}\n{schema_instruction}"},
|
|
|
|
|
|
{"role": "user", "content": f"来源内容:\n{content}"},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_model_records(
|
|
|
|
|
|
preview_items: Iterable[Mapping[str, Any]],
|
|
|
|
|
|
*,
|
|
|
|
|
|
model: Mapping[str, Any],
|
|
|
|
|
|
config: Mapping[str, Any],
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
split: Mapping[str, int],
|
|
|
|
|
|
qa_pairs_per_item: int,
|
|
|
|
|
|
client: httpx.Client | None = None,
|
|
|
|
|
|
on_progress: Callable[[int, int], None] | None = None,
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""调用 OpenAI 兼容接口,将预览切片生成标准训练记录。
|
|
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
每个切片按安全批次调用模型;失败批次会产生一条可人工修复的
|
|
|
|
|
|
invalid 结果,已经成功的批次不会丢失。
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
if not 1 <= qa_pairs_per_item <= MAX_QA_PAIRS_PER_ITEM:
|
2026-07-27 13:08:44 +08:00
|
|
|
|
raise ModelGenerationError(f"qa_pairs_per_item must be in [1, {MAX_QA_PAIRS_PER_ITEM}]")
|
|
|
|
|
|
output_type = str(config.get("output_type") or OUTPUT_TYPE_STANDARD).strip().lower()
|
|
|
|
|
|
if output_type not in SUPPORTED_OUTPUT_TYPES:
|
|
|
|
|
|
raise ModelGenerationError(f"output_type must be one of {sorted(SUPPORTED_OUTPUT_TYPES)}")
|
2026-07-27 14:41:38 +08:00
|
|
|
|
reasoning_detail = str(
|
|
|
|
|
|
config.get("reasoning_detail") or REASONING_DETAIL_NORMAL
|
|
|
|
|
|
).strip().lower()
|
|
|
|
|
|
if reasoning_detail not in SUPPORTED_REASONING_DETAILS:
|
|
|
|
|
|
raise ModelGenerationError(
|
|
|
|
|
|
f"reasoning_detail must be one of {sorted(SUPPORTED_REASONING_DETAILS)}"
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
endpoint = chat_completions_url(str(model.get("api_url") or ""))
|
|
|
|
|
|
model_name = str(model.get("online_model_name") or model.get("name") or "").strip()
|
|
|
|
|
|
if not model_name:
|
|
|
|
|
|
raise ModelGenerationError("generation model name is required")
|
2026-07-28 10:56:05 +08:00
|
|
|
|
is_minimax_m3 = _is_official_minimax_m3(endpoint, model_name)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
temperature = float(config.get("temperature", 0.7))
|
|
|
|
|
|
max_tokens = int(config.get("max_tokens", 1024))
|
|
|
|
|
|
timeout = max(1.0, min(120.0, float(config.get("request_timeout_seconds", 60))))
|
|
|
|
|
|
retries = max(0, min(5, int(config.get("generation_retries", 2))))
|
|
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
|
|
api_key = str(model.get("api_key") or "").strip()
|
|
|
|
|
|
if api_key:
|
|
|
|
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
|
|
|
|
|
|
|
|
owns_client = client is None
|
|
|
|
|
|
http_client = client or httpx.Client(timeout=timeout)
|
|
|
|
|
|
results: list[dict[str, Any]] = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
preview_list = list(preview_items)
|
|
|
|
|
|
total_items = len(preview_list)
|
|
|
|
|
|
for item_index, item in enumerate(preview_list):
|
|
|
|
|
|
preview_id = str(item.get("id") or f"preview-{item_index + 1}")
|
|
|
|
|
|
content = normalize_text(
|
|
|
|
|
|
str(item.get("edited_content") or item.get("original_content") or "")
|
|
|
|
|
|
)
|
2026-07-27 09:11:51 +08:00
|
|
|
|
for batch_offset in range(0, qa_pairs_per_item, MODEL_GENERATION_BATCH_SIZE):
|
|
|
|
|
|
batch_count = min(
|
|
|
|
|
|
MODEL_GENERATION_BATCH_SIZE,
|
|
|
|
|
|
qa_pairs_per_item - batch_offset,
|
|
|
|
|
|
)
|
|
|
|
|
|
batch_start = batch_offset + 1
|
|
|
|
|
|
batch_end = batch_offset + batch_count
|
|
|
|
|
|
request_payload: dict[str, Any] = {
|
|
|
|
|
|
"model": model_name,
|
|
|
|
|
|
"messages": _prompt_messages(
|
|
|
|
|
|
str(config.get("generation_prompt") or ""),
|
|
|
|
|
|
content,
|
|
|
|
|
|
batch_count,
|
|
|
|
|
|
start_index=batch_start,
|
|
|
|
|
|
total_count=qa_pairs_per_item,
|
2026-07-27 13:08:44 +08:00
|
|
|
|
output_type=output_type,
|
2026-07-27 14:41:38 +08:00
|
|
|
|
reasoning_detail=reasoning_detail,
|
2026-07-27 09:11:51 +08:00
|
|
|
|
),
|
|
|
|
|
|
"temperature": temperature,
|
|
|
|
|
|
}
|
2026-07-28 10:56:05 +08:00
|
|
|
|
if is_minimax_m3:
|
|
|
|
|
|
request_payload.update(
|
|
|
|
|
|
reasoning_split=True,
|
|
|
|
|
|
max_completion_tokens=max(
|
|
|
|
|
|
max_tokens,
|
|
|
|
|
|
MINIMAX_M3_MIN_COMPLETION_TOKENS,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
request_payload["max_tokens"] = max_tokens
|
|
|
|
|
|
if bool(config.get("json_mode", False)) and not is_minimax_m3:
|
2026-07-27 09:11:51 +08:00
|
|
|
|
request_payload["response_format"] = {"type": "json_object"}
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
last_error: Exception | None = None
|
|
|
|
|
|
generated_items: list[Mapping[str, Any]] | None = None
|
|
|
|
|
|
for _ in range(retries + 1):
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = http_client.post(
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
json=request_payload,
|
|
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
body = response.json()
|
|
|
|
|
|
if not isinstance(body, Mapping):
|
2026-07-27 13:08:44 +08:00
|
|
|
|
raise ModelGenerationError("model response body must be a JSON object")
|
2026-07-28 10:56:05 +08:00
|
|
|
|
try:
|
|
|
|
|
|
candidate_items = _result_items(
|
|
|
|
|
|
_json_payload(_message_content(body))
|
|
|
|
|
|
)
|
|
|
|
|
|
except ModelGenerationError as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"data process model response rejected task_id=%s model=%s "
|
|
|
|
|
|
"finish_reason=%s response_chars=%s input_sensitive=%s "
|
|
|
|
|
|
"output_sensitive=%s reason=%s",
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
model_name,
|
|
|
|
|
|
_response_finish_reason(body) or "missing",
|
|
|
|
|
|
_response_content_length(body),
|
|
|
|
|
|
bool(body.get("input_sensitive")),
|
|
|
|
|
|
bool(body.get("output_sensitive")),
|
|
|
|
|
|
str(exc),
|
|
|
|
|
|
)
|
|
|
|
|
|
raise
|
2026-07-27 09:11:51 +08:00
|
|
|
|
if len(candidate_items) < batch_count:
|
|
|
|
|
|
raise ModelGenerationError(
|
|
|
|
|
|
"model response contains fewer result objects than requested: "
|
|
|
|
|
|
f"expected {batch_count}, got {len(candidate_items)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
generated_items = candidate_items
|
|
|
|
|
|
break
|
|
|
|
|
|
except (
|
|
|
|
|
|
httpx.HTTPError,
|
|
|
|
|
|
json.JSONDecodeError,
|
|
|
|
|
|
ModelGenerationError,
|
|
|
|
|
|
) as exc:
|
|
|
|
|
|
last_error = exc
|
2026-07-28 10:56:05 +08:00
|
|
|
|
if not _is_retryable_generation_error(exc):
|
|
|
|
|
|
break
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
if generated_items is None:
|
|
|
|
|
|
error_message = str(last_error or "model generation failed")[:2000]
|
|
|
|
|
|
failure_instruction = (
|
|
|
|
|
|
f"模型生成失败,请人工补充(第 {batch_start}-{batch_end} 条)"
|
|
|
|
|
|
)
|
|
|
|
|
|
result_id = (
|
|
|
|
|
|
"result_"
|
|
|
|
|
|
f"{hashlib.sha256(f'{preview_id}:error:{batch_start}'.encode()).hexdigest()[:16]}"
|
|
|
|
|
|
)
|
|
|
|
|
|
results.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": result_id,
|
|
|
|
|
|
"preview_item_id": preview_id,
|
|
|
|
|
|
"instruction": failure_instruction,
|
|
|
|
|
|
"input": content,
|
|
|
|
|
|
"output": "",
|
|
|
|
|
|
"original_instruction": failure_instruction,
|
|
|
|
|
|
"original_input": content,
|
|
|
|
|
|
"original_output": "",
|
|
|
|
|
|
"status": "invalid",
|
|
|
|
|
|
"error": error_message,
|
|
|
|
|
|
"split": "train",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
for batch_index, value in enumerate(generated_items[:batch_count]):
|
|
|
|
|
|
variant_index = batch_offset + batch_index
|
|
|
|
|
|
instruction = normalize_text(
|
|
|
|
|
|
str(value.get("instruction") or value.get("question") or "")
|
|
|
|
|
|
)
|
|
|
|
|
|
input_text = normalize_text(
|
|
|
|
|
|
str(value.get("input") or value.get("context") or "")
|
|
|
|
|
|
)
|
2026-07-27 13:08:44 +08:00
|
|
|
|
if output_type == OUTPUT_TYPE_REASONING:
|
|
|
|
|
|
reasoning = normalize_text(
|
|
|
|
|
|
re.sub(
|
|
|
|
|
|
r"</?think>",
|
|
|
|
|
|
"",
|
|
|
|
|
|
str(value.get("reasoning") or value.get("analysis") or ""),
|
|
|
|
|
|
flags=re.IGNORECASE,
|
|
|
|
|
|
)
|
2026-07-27 09:11:51 +08:00
|
|
|
|
)
|
2026-07-27 13:08:44 +08:00
|
|
|
|
answer = normalize_text(
|
|
|
|
|
|
re.sub(
|
|
|
|
|
|
r"</?think>",
|
|
|
|
|
|
"",
|
|
|
|
|
|
str(
|
|
|
|
|
|
value.get("answer")
|
|
|
|
|
|
or value.get("final_answer")
|
|
|
|
|
|
or value.get("output")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
),
|
|
|
|
|
|
flags=re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
output = (
|
|
|
|
|
|
f"<think>\n{reasoning}\n</think>\n{answer}"
|
|
|
|
|
|
if reasoning and answer
|
|
|
|
|
|
else answer or (f"<think>\n{reasoning}\n</think>" if reasoning else "")
|
|
|
|
|
|
)
|
|
|
|
|
|
valid = bool(instruction and reasoning and answer)
|
|
|
|
|
|
missing_error = "model result is missing instruction, reasoning or answer"
|
|
|
|
|
|
else:
|
|
|
|
|
|
output = normalize_text(
|
|
|
|
|
|
str(
|
|
|
|
|
|
value.get("output")
|
|
|
|
|
|
or value.get("answer")
|
|
|
|
|
|
or value.get("response")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
output = normalize_text(
|
|
|
|
|
|
re.sub(
|
|
|
|
|
|
r"<think>[\s\S]*?(?:</think>|$)",
|
|
|
|
|
|
"",
|
|
|
|
|
|
output,
|
|
|
|
|
|
flags=re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
valid = bool(instruction and output)
|
|
|
|
|
|
missing_error = "model result is missing instruction or output"
|
2026-07-27 09:11:51 +08:00
|
|
|
|
raw_id = f"{preview_id}:{variant_index + 1}:{instruction}:{output}"
|
2026-07-27 13:08:44 +08:00
|
|
|
|
result_id = f"result_{hashlib.sha256(raw_id.encode()).hexdigest()[:16]}"
|
2026-07-27 09:11:51 +08:00
|
|
|
|
results.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": result_id,
|
|
|
|
|
|
"preview_item_id": preview_id,
|
|
|
|
|
|
"instruction": instruction,
|
|
|
|
|
|
"input": input_text,
|
|
|
|
|
|
"output": output,
|
|
|
|
|
|
"original_instruction": instruction,
|
|
|
|
|
|
"original_input": input_text,
|
|
|
|
|
|
"original_output": output,
|
|
|
|
|
|
"status": "valid" if valid else "invalid",
|
2026-07-27 13:08:44 +08:00
|
|
|
|
"error": (None if valid else missing_error),
|
2026-07-27 09:11:51 +08:00
|
|
|
|
"split": "train",
|
|
|
|
|
|
}
|
2026-07-23 15:10:13 +08:00
|
|
|
|
)
|
|
|
|
|
|
if on_progress:
|
|
|
|
|
|
on_progress(item_index + 1, total_items)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if owns_client:
|
|
|
|
|
|
http_client.close()
|
2026-07-24 20:43:47 +08:00
|
|
|
|
assignments = stable_split_assignments(
|
|
|
|
|
|
[str(result["id"]) for result in results],
|
|
|
|
|
|
split,
|
|
|
|
|
|
seed=task_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
for result, assignment in zip(results, assignments, strict=True):
|
|
|
|
|
|
result["split"] = assignment
|
2026-07-23 15:10:13 +08:00
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = ["ModelGenerationError", "chat_completions_url", "generate_model_records"]
|