fix: improve import, evidence indexing and material-scoped grading

Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
This commit is contained in:
2026-10-14 13:52:56 +08:00
parent 29c95ce5ad
commit ebe33392ca
42 changed files with 2332 additions and 304 deletions
+39 -3
View File
@@ -77,6 +77,26 @@ class ModelError(RuntimeError):
pass
def _safe_error_detail(exc):
"""Describe failures without provider bodies, headers, URLs or exception text."""
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
hint = {401: "认证失败,请检查 API Key", 403: "访问被拒绝,请检查模型权限",
402: "额度不足,请检查账户余额", 429: "请求限流,请稍后重试或降低并发",
400: "请求参数不被模型支持", 404: "端点或模型不存在"}.get(
code, "模型服务异常,请稍后重试" if code >= 500 else "请求被拒绝,请检查模型设置")
return f"HTTP {code}:{hint}"
if isinstance(exc, httpx.ConnectError):
return "ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理"
if isinstance(exc, httpx.TimeoutException):
return "模型请求超时,请稍后重试或调整超时设置"
if isinstance(exc, ValidationError):
fields = sorted({str(error["loc"][0]) for error in exc.errors(include_input=False)
if error["loc"] and str(error["loc"][0]).isidentifier()})
return "ValidationError:模型返回字段不符合格式" + (f"({', '.join(fields)})" if fields else "")
return type(exc).__name__
class DeepSeekClient:
def __init__(self, *, base_url=None, model=None, api_key=None, timeout=None, retries=None, transport=None):
settings = get_settings()
@@ -96,8 +116,9 @@ class DeepSeekClient:
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()}})
output_schema = json.dumps(schema.model_json_schema(), ensure_ascii=False)
body = {"model": self.model, "response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": prompt + " 严格输出一个JSON对象。"}, {"role": "user", "content": content}],
"messages": [{"role": "system", "content": prompt + " 严格输出一个JSON对象,字段与类型遵循以下JSON Schema:" + output_schema}, {"role": "user", "content": content}],
"temperature": 0}
last_error = None
for attempt in range(self.retries + 1):
@@ -110,7 +131,7 @@ class DeepSeekClient:
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__
last_error = _safe_error_detail(exc)
if attempt < self.retries:
time.sleep(min(2 ** attempt, 5))
raise ModelError(f"模型请求或JSON校验失败({last_error}),已重试{self.retries}次")
@@ -197,6 +218,7 @@ def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict], *,
by_target = {target["target_id"]: target for target in targets}
default_content = next((target for target in targets if target["use"] == USE_CONTENT), None)
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
has_content_evidence = False
for evidence in result.evidence:
chunk = by_id.get(evidence.chunk_id)
@@ -209,16 +231,30 @@ def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict], *,
if quote is None:
raise ModelError("模型证据未能匹配检索原文")
page_type = chunk.get("page_type") or "unknown"
target = by_target.get(evidence.target_id) or default_content or (targets[0] if targets else None)
if evidence.target_id and evidence.target_id not in by_target:
raise ModelError("模型证据包含未知 target_id")
target = by_target.get(evidence.target_id)
if target is None and not evidence.target_id:
# Empty identifiers are accepted only for pre-object legacy rubrics.
if any(isinstance(value, dict) for value in criterion.get("required_evidence", [])):
raise ModelError("模型证据缺少 target_id")
target = default_content or (targets[0] if targets else None)
if target is None:
raise ModelError("模型证据未关联任何必备证据目标")
if chunk.get("target_ids") and target["target_id"] not in chunk["target_ids"]:
raise ModelError("模型证据未关联该片段的检索目标")
if not page_type_allowed(page_type, target["allowed_page_types"]):
raise ModelError(f"模型证据来自不允许的页面类型({page_type}),不能支持「{target['label']}」")
if target["use"] == USE_CONTENT and not content_evidence_allowed(page_type):
raise ModelError(f"目录、摘要或参考文献页不能作为「{target['label']}」的内容证据")
if target["use"] == USE_CONTENT and heading_only_text(quote):
raise ModelError(f"「{target['label']}」的证据只有章节标题,没有实质内容")
if any(char in quote for char in ("\ufffd", "\ufffe", "\uffff")):
raise ModelError("模型证据含未解析字符,请核对原页")
has_content_evidence |= target["use"] == USE_CONTENT
evidence.chunk_id, evidence.page, evidence.quote = chunk["chunk_id"], chunk["page"], quote
evidence.target_id = target["target_id"]
if result.score is not None and result.score > 0 and require_content_evidence and not has_content_evidence:
raise ModelError("正分内容判断必须包含合法内容证据,不能仅引用结构信息")
return result