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}", )