import asyncio import json import re from dataclasses import dataclass from urllib.parse import urlparse import httpx from config import settings from models.ai_model import AiModel from models.paragraph import Paragraph from services.security import decrypt_text @dataclass class AiCallResult: content: dict raw_text: str used_model: str def _ensure_json_content(text: str) -> dict: stripped = text.strip() if not stripped: return {"content": [{"type": "text", "text": ""}]} try: parsed = json.loads(stripped) if isinstance(parsed, dict) and "content" in parsed: return parsed except json.JSONDecodeError: pass code_block_match = re.search(r"```json\s*(.*?)\s*```", stripped, re.S) if code_block_match: try: parsed = json.loads(code_block_match.group(1)) if isinstance(parsed, dict) and "content" in parsed: return parsed except json.JSONDecodeError: pass return {"content": [{"type": "text", "text": stripped}]} def _format_file_context(file_summaries: list[dict]) -> str: file_blocks: list[str] = [] for item in file_summaries: file_name = item.get("file_name") or "未命名文件" summary = item.get("summary") or "文件内容为空。" file_blocks.append(f"文件:{file_name}\n内容:\n{summary}") return "\n\n".join(file_blocks) def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: system_prompt = ( "你是一个企业文档撰写助手。" "请严格输出 JSON,不要输出 JSON 之外的说明。" '格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。' ) if getattr(paragraph, "enable_reasoning", False): system_prompt += "你可以先进行充分思考,再给出最终答案,但最终只输出要求的结果内容。" user_parts = [f"段落标题:{paragraph.title}"] if paragraph.content: user_parts.append(f"模板上下文:{paragraph.content}") if paragraph.need_prompt and paragraph.prompt_text: user_parts.append(f"附加要求:{paragraph.prompt_text}") if file_summaries: user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries)) user_parts.append(f"输出格式:{paragraph.output_format}") return system_prompt, "\n\n".join(user_parts) def _normalize_openai_endpoint(api_endpoint: str) -> str: endpoint = api_endpoint.rstrip("/") parsed = urlparse(endpoint if "://" in endpoint else f"https://{endpoint}") host = parsed.netloc or parsed.path.split("/")[0] if host == "api.deepseek.com": return "https://api.deepseek.com/chat/completions" if endpoint.endswith("/chat/completions"): return endpoint if endpoint.endswith("/v1"): return f"{endpoint}/chat/completions" return f"{endpoint}/v1/chat/completions" def _normalize_anthropic_endpoint(api_endpoint: str) -> str: endpoint = api_endpoint.rstrip("/") if endpoint.endswith("/messages"): return endpoint if endpoint.endswith("/v1"): return f"{endpoint}/messages" return f"{endpoint}/v1/messages" async def _post_with_retry( client: httpx.AsyncClient, url: str, headers: dict, payload: dict, ) -> httpx.Response: last_error: Exception | None = None for attempt in range(settings.AI_MAX_RETRIES): try: response = await client.post(url, headers=headers, json=payload) if response.status_code in (429, 500, 502, 503, 504): raise httpx.HTTPStatusError( f"上游模型响应异常: {response.status_code} - {response.text[:500]}", request=response.request, response=response, ) response.raise_for_status() return response except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: last_error = error if attempt == settings.AI_MAX_RETRIES - 1: break await asyncio.sleep(2 ** attempt) error_message = str(last_error) if isinstance(last_error, httpx.HTTPStatusError) and last_error.response is not None: error_message = f"{error_message}\n响应内容: {last_error.response.text[:1000]}" raise RuntimeError(f"模型调用失败:{error_message}") async def _call_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult: api_key = decrypt_text(model.api_key_encrypted) if not api_key: raise RuntimeError("模型 API Key 不可用") payload = { "model": model.name, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": 0.3, } headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload) body = response.json() text = body["choices"][0]["message"]["content"] return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) async def _stream_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str): api_key = decrypt_text(model.api_key_encrypted) if not api_key: raise RuntimeError("模型 API Key 不可用") payload = { "model": model.name, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": 0.3, "stream": True, } headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} last_error: Exception | None = None for attempt in range(settings.AI_MAX_RETRIES): try: async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: async with client.stream("POST", _normalize_openai_endpoint(model.api_endpoint), headers=headers, json=payload) as response: if response.status_code in (429, 500, 502, 503, 504): body = await response.aread() raise httpx.HTTPStatusError( f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}", request=response.request, response=response, ) response.raise_for_status() async for line in response.aiter_lines(): if not line or not line.startswith("data:"): continue payload_line = line[5:].strip() if payload_line == "[DONE]": break try: chunk = json.loads(payload_line) except json.JSONDecodeError: continue delta_payload = chunk.get("choices", [{}])[0].get("delta", {}) delta = delta_payload.get("content", "") reasoning = delta_payload.get("reasoning_content", "") if isinstance(delta, list): delta = "".join( item.get("text", "") if isinstance(item, dict) else str(item) for item in delta ) if delta: yield delta if reasoning: yield reasoning return except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: last_error = error if attempt == settings.AI_MAX_RETRIES - 1: break await asyncio.sleep(2 ** attempt) raise RuntimeError(f"模型流式调用失败:{last_error}") async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult: api_key = decrypt_text(model.api_key_encrypted) if not api_key: raise RuntimeError("模型 API Key 不可用") payload = { "model": model.name, "max_tokens": 2048, "system": system_prompt, "messages": [{"role": "user", "content": user_prompt}], } headers = { "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", } async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload) body = response.json() text = "" for item in body.get("content", []): if item.get("type") == "text": text += item.get("text", "") return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name) async def _stream_anthropic(model: AiModel, system_prompt: str, user_prompt: str): api_key = decrypt_text(model.api_key_encrypted) if not api_key: raise RuntimeError("模型 API Key 不可用") payload = { "model": model.name, "max_tokens": 2048, "system": system_prompt, "messages": [{"role": "user", "content": user_prompt}], "stream": True, } headers = { "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", } last_error: Exception | None = None for attempt in range(settings.AI_MAX_RETRIES): try: async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client: async with client.stream("POST", _normalize_anthropic_endpoint(model.api_endpoint), headers=headers, json=payload) as response: if response.status_code in (429, 500, 502, 503, 504): body = await response.aread() raise httpx.HTTPStatusError( f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}", request=response.request, response=response, ) response.raise_for_status() async for line in response.aiter_lines(): if not line or not line.startswith("data:"): continue payload_line = line[5:].strip() if payload_line == "[DONE]": break try: chunk = json.loads(payload_line) except json.JSONDecodeError: continue if chunk.get("type") == "content_block_delta": delta = chunk.get("delta", {}).get("text", "") if delta: yield delta return except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error: last_error = error if attempt == settings.AI_MAX_RETRIES - 1: break await asyncio.sleep(2 ** attempt) raise RuntimeError(f"模型流式调用失败:{last_error}") async def call_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult: system_prompt, user_prompt = _build_prompt(paragraph, file_summaries) if model.api_format == "anthropic": return await _call_anthropic(model, system_prompt, user_prompt) return await _call_openai_compatible(model, system_prompt, user_prompt) def build_test_stream_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]: system_prompt = "你是一个企业文档撰写助手。请直接输出适合预览的正文内容或 Markdown 表格,不要输出 JSON。" if getattr(paragraph, "enable_reasoning", False): system_prompt += "你可以先进行充分思考,再持续输出最终可展示的内容。" _, user_prompt = _build_prompt(paragraph, file_summaries or []) return system_prompt, user_prompt async def stream_ai_preview(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None): system_prompt, user_prompt = build_test_stream_prompt(paragraph, file_summaries) if model.api_format == "anthropic": async for chunk in _stream_anthropic(model, system_prompt, user_prompt): yield chunk return async for chunk in _stream_openai_compatible(model, system_prompt, user_prompt): yield chunk