Files
ozon-seller-kit/services/deepseek.py
T
2026-08-07 17:34:24 +08:00

183 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
from typing import Any
import httpx
from fastapi import HTTPException
from schemas.copy import CopyRequest, CopyResponse, UsageInfo
from services.models_catalog import ModelSpec, get_model_spec, resolve_api_key
from services.prompts.copy_ru import SYSTEM_PROMPT, build_user_prompt
_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
def _extract_json_object(content: str) -> dict[str, Any]:
text = (content or "").strip()
if not text:
raise ValueError("模型返回空内容")
match = _JSON_BLOCK_RE.search(text)
if match:
text = match.group(1).strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start < 0 or end <= start:
raise ValueError("无法从模型回复中解析 JSON") from None
data = json.loads(text[start : end + 1])
if not isinstance(data, dict):
raise ValueError("模型返回的 JSON 不是对象")
return data
def _as_str(value: Any, field: str) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
raise ValueError(f"字段 {field} 必须是字符串")
def _as_str_list(value: Any, field: str) -> list[str]:
if value is None:
return []
if isinstance(value, str):
parts = re.split(r"[,\n]+", value)
return [p.strip() for p in parts if p.strip()]
if isinstance(value, list):
result: list[str] = []
for item in value:
s = str(item).strip()
if s:
result.append(s)
return result
raise ValueError(f"字段 {field} 必须是字符串数组")
def _as_title_list(value: Any, field: str) -> list[str]:
"""标题本身可能含逗号,不能按标点切分。"""
if value is None:
return []
if isinstance(value, str):
text = value.strip()
return [text] if text else []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
raise ValueError(f"字段 {field} 必须是字符串数组")
def _map_copy_payload(data: dict[str, Any], *, model: str, usage: dict[str, Any] | None) -> CopyResponse:
usage = usage or {}
return CopyResponse(
titles_ru=_as_title_list(data.get("titles_ru"), "titles_ru"),
titles_zh=_as_title_list(data.get("titles_zh"), "titles_zh"),
description_ru=_as_str(data.get("description_ru"), "description_ru"),
description_zh=_as_str(data.get("description_zh"), "description_zh"),
tags_ru=_as_str_list(data.get("tags_ru"), "tags_ru"),
tags_zh=_as_str_list(data.get("tags_zh"), "tags_zh"),
model=model,
usage=UsageInfo(
prompt_tokens=int(usage.get("prompt_tokens") or 0),
completion_tokens=int(usage.get("completion_tokens") or 0),
),
)
async def _chat_once(spec: ModelSpec, messages: list[dict[str, str]]) -> tuple[str, dict[str, Any]]:
api_key = resolve_api_key(spec)
url = spec.base_url.rstrip("/") + "/chat/completions"
payload = {
"model": spec.api_model,
"messages": messages,
# 商品事实需要稳定,营销表达仍保留少量变化。
"temperature": 0.45,
"max_tokens": spec.max_tokens,
"response_format": {"type": "json_object"},
**spec.params,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(url, headers=headers, json=payload)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"模型网络错误:{exc}") from exc
if resp.status_code >= 400:
detail = resp.text[:500]
raise HTTPException(
status_code=502,
detail=f"模型调用失败(HTTP {resp.status_code}):{detail}",
)
body = resp.json()
try:
choice = body["choices"][0]
content = choice["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise HTTPException(status_code=502, detail="模型响应格式异常") from exc
# 思考型模型的思维链也计入 max_tokens,推理过长时正文会是空串。
if not (content or "").strip() and choice.get("finish_reason") == "length":
raise HTTPException(
status_code=502,
detail=(
f"模型「{spec.id}」在 max_tokens={spec.max_tokens} 内只输出了思维链、没有正文。"
"请调高该模型的 max_tokens,或在 config/models.yaml 中为它关闭/降低思维链。"
),
)
usage = body.get("usage") or {}
return content, usage
async def generate_copy(req: CopyRequest) -> CopyResponse:
spec = get_model_spec(req.model or None)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": build_user_prompt(
source_text=req.source_text,
product_name=req.product_name,
model_code=req.model_code,
),
},
]
last_error: Exception | None = None
for attempt in range(2):
content, usage = await _chat_once(spec, messages)
try:
data = _extract_json_object(content)
result = _map_copy_payload(data, model=spec.id, usage=usage)
if not result.titles_ru or not result.description_ru:
raise ValueError("标题或描述俄文为空")
return result
except (ValueError, json.JSONDecodeError) as exc:
last_error = exc
if attempt == 0:
messages.append({"role": "assistant", "content": content})
messages.append(
{
"role": "user",
"content": "上一次输出无法解析为约定 JSON,请仅重新输出合法 JSON 对象,不要其它文字。",
}
)
continue
raise HTTPException(
status_code=502,
detail=f"模型返回无法解析:{last_error}",
)