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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user