Files
WiseGrade/backend/wisegrade/llm.py
T

144 lines
6.0 KiB
Python

import base64
import json
import time
import unicodedata
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}
def locate_quote(text: str, quote: str):
def comparable(value: str):
return "".join(char for original in value for char in unicodedata.normalize("NFKC", original)
if not char.isspace() and not unicodedata.category(char).startswith("P"))
if quote in text:
return quote
needle = comparable(quote)
if len(needle) < 4:
return None
positions = []
normalized = []
for index, original in enumerate(text):
for char in unicodedata.normalize("NFKC", original):
if not char.isspace() and not unicodedata.category(char).startswith("P"):
normalized.append(char)
positions.append(index)
start = "".join(normalized).find(needle)
if start < 0:
return None
return text[positions[start]:positions[start + len(needle) - 1] + 1]
for evidence in result.evidence:
chunk = by_id.get(evidence.chunk_id)
quote = locate_quote(chunk["text"], evidence.quote) if chunk else None
if quote is None and len(evidence.quote) >= 8:
matches = [(candidate, located) for candidate in chunks
if (located := locate_quote(candidate["text"], evidence.quote)) is not None]
if len(matches) == 1:
chunk, quote = matches[0]
if quote is None:
raise ModelError("模型证据未能匹配检索原文")
evidence.chunk_id, evidence.page, evidence.quote = chunk["chunk_id"], chunk["page"], quote
return result