import base64 import json import time from pathlib import Path from typing import Any import httpx from pydantic import BaseModel, Field, ValidationError, model_validator from .config import get_settings class EvidenceResult(BaseModel): chunk_id: int page: int = Field(ge=1) quote: str = Field(min_length=1) class ScoreResult(BaseModel): criterion_id: str score: float = Field(ge=0) max_score: float = Field(gt=0) reason: str = Field(min_length=1) evidence: list[EvidenceResult] missing_evidence: list[str] = Field(default_factory=list) suggestion: str = "" confidence: float = Field(ge=0, le=1) @model_validator(mode="after") def valid_range(self): if self.score > self.max_score or (self.score > 0 and not self.evidence): raise ValueError("score exceeds maximum or lacks evidence") return self class FactResult(BaseModel): student_no: str = "" name: str = "" topic: str = "" direction: str = "" goals: list[str] = Field(default_factory=list) metrics: list[str] = Field(default_factory=list) hardware: list[str] = Field(default_factory=list) software: list[str] = Field(default_factory=list) experiments: list[str] = Field(default_factory=list) data: list[str] = Field(default_factory=list) schedule: list[str] = Field(default_factory=list) cost: list[str] = Field(default_factory=list) conclusions: list[str] = Field(default_factory=list) class ConsistencyIssue(BaseModel): description: str chunk_ids: list[int] = Field(min_length=2) class ConsistencyResult(BaseModel): issues: list[ConsistencyIssue] = Field(default_factory=list) class ModelError(RuntimeError): pass class DeepSeekClient: def __init__(self, *, base_url=None, model=None, api_key=None, timeout=None, retries=None, transport=None): settings = get_settings() self.base_url = (base_url or settings.deepseek_base_url).rstrip("/") self.model = model or settings.deepseek_model self.api_key = api_key or settings.deepseek_api_key self.timeout = timeout or settings.model_timeout_seconds self.retries = settings.model_retries if retries is None else retries self.transport = transport if not self.api_key: raise ModelError("未配置DeepSeek API Key") def json_call(self, prompt: str, payload: dict[str, Any], schema: type[BaseModel], *, images: list[str] | None = None): content: list[dict] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}] for image in images or []: path = Path(image) if not path.is_file(): raise ModelError(f"页面图像不存在: {path.name}") content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode()}}) body = {"model": self.model, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": prompt + " 严格输出一个JSON对象。"}, {"role": "user", "content": content}], "temperature": 0} last_error = None for attempt in range(self.retries + 1): try: with httpx.Client(timeout=self.timeout, transport=self.transport) as client: response = client.post(self.base_url + "/chat/completions", json=body, headers={"Authorization": f"Bearer {self.api_key}"}) response.raise_for_status() raw = response.json()["choices"][0]["message"]["content"] return schema.model_validate(json.loads(raw)) except (httpx.HTTPError, ValueError, KeyError, IndexError, ValidationError) as exc: # Never include response bodies or headers: providers sometimes echo credentials. last_error = type(exc).__name__ if attempt < self.retries: time.sleep(min(2 ** attempt, 5)) raise ModelError(f"模型请求或JSON校验失败({last_error}),已重试{self.retries}次") def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict]): if result.criterion_id != criterion["criterion_id"] or result.max_score != criterion["max_score"]: raise ModelError("模型评分项或满分与规则版本不一致") by_id = {chunk["chunk_id"]: chunk for chunk in chunks} for evidence in result.evidence: chunk = by_id.get(evidence.chunk_id) if not chunk or evidence.page != chunk["page"] or evidence.quote not in chunk["text"]: raise ModelError("模型证据未能匹配检索原文") return result