feat: add versioned backend scoring workflow
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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
|
||||
Reference in New Issue
Block a user