import json import time from dataclasses import dataclass from typing import Literal import httpx from pydantic import BaseModel, Field, ValidationError Sentiment = Literal["positive", "negative", "neutral", "unknown"] @dataclass(frozen=True) class AIAnalysisResult: comment_id: str sentiment: str labels: list[str] reason: str = "" ai_analysis_status: str = "success" class AIAnalysisItem(BaseModel): comment_id: str sentiment: Sentiment labels: list[str] = Field(default_factory=list, max_length=3) reason: str = "" def build_comment_prompt(comments: list[dict[str, str]]) -> str: payload = [ { "comment_id": str(comment["comment_id"]), "content": str(comment.get("content", ""))[:150], } for comment in comments ] return ( "只返回 JSON Array,不输出 Markdown 或解释性自然语言。" "请原样回填输入中的 comment_id,不得修改或生成新 ID。" "sentiment 只能是 positive、negative、neutral、unknown;labels 最多 3 个简短中文短语。\n" f"{json.dumps(payload, ensure_ascii=False)}" ) def parse_ai_comment_response(response_text: str, *, expected_comment_ids: set[str]) -> list[AIAnalysisResult]: try: raw = json.loads(response_text) except json.JSONDecodeError as exc: raise ValueError("AI response is not valid JSON") from exc if not isinstance(raw, list): raise ValueError("AI response must be a JSON Array") results = [] for item in raw: try: parsed = AIAnalysisItem.model_validate(item) except ValidationError as exc: raise ValueError("AI response item does not match schema") from exc if parsed.comment_id not in expected_comment_ids: raise ValueError("AI response comment_id does not match input") results.append( AIAnalysisResult( comment_id=parsed.comment_id, sentiment=parsed.sentiment, labels=parsed.labels, reason=parsed.reason, ) ) return results def calculate_analysis_status(*, success_count: int, total_count: int) -> tuple[float, str]: if total_count <= 0: return 0.0, "insufficient" rate = success_count / total_count return rate, "normal" if rate >= 0.8 else "insufficient" def analyze_comments_with_retry( comments: list[dict[str, str]], *, requester, max_retries: int = 3, ) -> list[AIAnalysisResult]: expected_ids = {str(comment["comment_id"]) for comment in comments} prompt = build_comment_prompt(comments) for attempt in range(max_retries): try: return parse_ai_comment_response(requester(prompt), expected_comment_ids=expected_ids) except Exception: if attempt >= max_retries - 1: break time.sleep(2**attempt) return [ AIAnalysisResult( comment_id=comment_id, sentiment="unknown", labels=[], reason="ai_parse_failed", ai_analysis_status="failed", ) for comment_id in expected_ids ] class OpenAICompatibleAIClient: def __init__( self, *, base_url: str, api_key: str, model: str, timeout_seconds: int = 30, http_client: httpx.Client | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model self._http_client = http_client or httpx.Client(timeout=timeout_seconds) def request(self, prompt: str) -> str: endpoint = f"{self.base_url}/chat/completions" if self.base_url.endswith("/v1") else f"{self.base_url}/v1/chat/completions" response = self._http_client.post( endpoint, headers={"Authorization": f"Bearer {self.api_key}"}, json={ "model": self.model, "messages": [ { "role": "system", "content": "只返回 JSON Array,不输出 Markdown 或解释性自然语言。", }, {"role": "user", "content": prompt}, ], "temperature": 0, }, ) response.raise_for_status() payload = response.json() return str(payload["choices"][0]["message"]["content"])