224 lines
12 KiB
Python
224 lines
12 KiB
Python
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from contextlib import contextmanager
|
|
import fcntl
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
from .config import get_settings
|
|
from .db import SessionLocal
|
|
from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validate_score
|
|
from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch,
|
|
SubmissionFile, now)
|
|
from .parsing import parse_document
|
|
from .rag import INDEX_VERSION, ingest, retrieve
|
|
from .storage import secure_path, sha256
|
|
from .settings_store import client_for_batch
|
|
|
|
|
|
@contextmanager
|
|
def file_index_lock(file_id: int):
|
|
root = get_settings().processed_path
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
with (root / f"file-{file_id}.lock").open("a+b") as handle:
|
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
try:
|
|
yield
|
|
finally:
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
|
|
|
|
def parse_submission(db, file: SubmissionFile, *, embedder=None):
|
|
with file_index_lock(file.id):
|
|
db.refresh(file)
|
|
return _parse_submission_locked(db, file, embedder=embedder)
|
|
|
|
|
|
def _parse_submission_locked(db, file: SubmissionFile, *, embedder=None):
|
|
path = secure_path(file.relative_path)
|
|
if sha256(path) != file.sha256:
|
|
file.parse_status, file.parse_error = "changed", "源文件已变化,请重新扫描并确认导入"
|
|
db.commit()
|
|
raise ValueError(file.parse_error)
|
|
if file.parse_status == "ready" and file.parsed:
|
|
document = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.submission_file_id == file.id))
|
|
if not document or document.source_hash != file.sha256 or document.index_version != INDEX_VERSION or document.status != "ready":
|
|
project = db.get(Project, file.project_id)
|
|
kwargs = {"embedder": embedder} if embedder else {}
|
|
try:
|
|
ingest(db, course_version_id=project.course_version_id, project_id=project.id,
|
|
submission_file_id=file.id, title=path.name, category=file.category,
|
|
source_hash=file.sha256, pages=file.parsed["pages"], **kwargs)
|
|
except Exception as exc:
|
|
file.parse_status, file.parse_error = "failed", str(exc)[:1000]
|
|
db.commit()
|
|
raise
|
|
return file.parsed
|
|
settings = get_settings()
|
|
try:
|
|
parsed = parse_document(path, file.category, settings.processed_path / f"file-{file.id}")
|
|
project = db.get(Project, file.project_id)
|
|
kwargs = {"embedder": embedder} if embedder else {}
|
|
ingest(db, course_version_id=project.course_version_id, project_id=project.id,
|
|
submission_file_id=file.id, title=path.name, category=file.category,
|
|
source_hash=file.sha256, pages=parsed["pages"], **kwargs)
|
|
file.parsed, file.parse_status, file.parse_error = parsed, "ready", ""
|
|
db.commit()
|
|
return parsed
|
|
except Exception as exc:
|
|
file.parse_status, file.parse_error = "failed", str(exc)[:1000]
|
|
db.commit()
|
|
raise
|
|
|
|
|
|
def _project_facts(db, project, course_snapshot, client, *, retriever):
|
|
prompts = course_snapshot["prompts"]
|
|
categories = ("stage1", "stage2", "stage3", "final_report", "presentation")
|
|
snippets = []
|
|
for category in categories:
|
|
snippets.extend(retriever(db, course_version_id=project.course_version_id, project_id=project.id,
|
|
category=category, query="项目名称 目标 指标 硬件 软件 实验 数据 成本 结论", limit=3, include_public=False))
|
|
if not snippets:
|
|
return {}
|
|
if client is None:
|
|
raise ModelError("未配置或无法连接评分模型")
|
|
facts = client.json_call(prompts["facts"], {"evidence": snippets}, FactResult).model_dump()
|
|
from .models import Student
|
|
student = db.get(Student, project.student_id)
|
|
warnings = []
|
|
for key, expected in (("student_no", student.student_no), ("name", student.name), ("topic", project.topic)):
|
|
if facts.get(key) and facts[key] != expected:
|
|
warnings.append(f"模型抽取的{key}与教师确认信息不一致")
|
|
consistency = client.json_call(prompts["consistency"], {"facts": facts, "evidence": snippets}, ConsistencyResult)
|
|
known = {snippet["chunk_id"] for snippet in snippets}
|
|
for issue in consistency.issues:
|
|
if not set(issue.chunk_ids) <= known:
|
|
raise ModelError("一致性检查引用了未提供的证据")
|
|
project.facts = {**facts, "identity_warnings": warnings,
|
|
"consistency_issues": [issue.model_dump() for issue in consistency.issues]}
|
|
db.commit()
|
|
return project.facts
|
|
|
|
|
|
def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
|
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
|
|
CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"]))
|
|
if score and score.status in ("scored", "reviewed"):
|
|
return score
|
|
if not score:
|
|
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
|
|
db.add(score)
|
|
db.flush()
|
|
category = "presentation" if criterion["criterion_id"] == "final.presentation" else (
|
|
"final_report" if criterion["category"] == "final" else criterion["category"])
|
|
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id,
|
|
SubmissionFile.category == category, SubmissionFile.recognition_status == "recognized")).all()
|
|
ready = [file for file in files if file.parse_status == "ready"]
|
|
if len(files) != 1 or len(ready) != 1:
|
|
score.status, score.error = "unscorable", "材料缺失、重复、待确认或解析失败"
|
|
db.commit()
|
|
return score
|
|
query = criterion["description"] + " " + " ".join(criterion["required_evidence"])
|
|
chunks = retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
|
|
category=category, query=query, limit=10, include_public=False)
|
|
if not chunks:
|
|
score.status, score.error = "unscorable", "无可检索的材料证据"
|
|
db.commit()
|
|
return score
|
|
if client is None:
|
|
raise ModelError("未配置或无法连接评分模型")
|
|
page_paths = {page["page_number"]: page.get("rendered_image_path") for page in ready[0].parsed["pages"]}
|
|
images = [page_paths[page] for page in dict.fromkeys(chunk["page"] for chunk in chunks[:2]) if page_paths.get(page)]
|
|
public_context = retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
|
|
category=None, query=query, limit=6, public_only=True)
|
|
payload = {"criterion": criterion, "project_facts": project.facts, "course_context": public_context,
|
|
"evidence": chunks,
|
|
"required_output": {"criterion_id": "string", "score": "number", "max_score": "number", "reason": "string",
|
|
"evidence": [{"chunk_id": "integer", "page": "integer", "quote": "exact substring"}],
|
|
"missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1"}}
|
|
prompt = (batch.course_snapshot["prompts"]["score"] +
|
|
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page。不要改写、缩写或拼接引文。")
|
|
for attempt in range(2):
|
|
try:
|
|
result = validate_score(client.json_call(prompt, payload, ScoreResult, images=images), criterion, chunks)
|
|
break
|
|
except ModelError:
|
|
if attempt:
|
|
raise
|
|
prompt += " 上次回答的证据未通过原文和页码校验;请重新选择片段并逐字复制quote。"
|
|
score.model_score, score.model_comment, score.confidence = result.score, result.reason, result.confidence
|
|
score.missing_evidence, score.suggestion, score.status, score.error = result.missing_evidence, result.suggestion, "scored", ""
|
|
for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all():
|
|
db.delete(evidence)
|
|
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
|
|
for evidence in result.evidence:
|
|
chunk = by_id[evidence.chunk_id]
|
|
doc = db.get(KnowledgeDocument, chunk["document_id"])
|
|
db.add(ScoreEvidence(score_id=score.id, document_id=doc.id, submission_file_id=doc.submission_file_id,
|
|
page=evidence.page, quote=evidence.quote, image_path=page_paths.get(evidence.page)))
|
|
db.commit()
|
|
return score
|
|
|
|
|
|
def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever):
|
|
failures = 0
|
|
with session_factory() as db:
|
|
batch = db.get(ScoringBatch, batch_id)
|
|
project = db.get(Project, project_id)
|
|
try:
|
|
client = client_factory(db, batch)
|
|
except Exception:
|
|
client = None
|
|
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
|
|
for file in files:
|
|
if file.recognition_status == "recognized":
|
|
try:
|
|
parse_submission(db, file, embedder=embedder)
|
|
except Exception:
|
|
failures += 1
|
|
try:
|
|
_project_facts(db, project, batch.course_snapshot, client, retriever=retriever)
|
|
except Exception as exc:
|
|
project.facts = {"error": str(exc)[:500]}
|
|
db.commit()
|
|
failures += 1
|
|
for criterion in batch.rubric_snapshot["criteria"]:
|
|
try:
|
|
result = _score_criterion(db, batch, project, criterion, client, retriever=retriever)
|
|
if result.status not in ("scored", "reviewed"):
|
|
failures += 1
|
|
except Exception as exc:
|
|
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
|
|
CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"]))
|
|
if not score:
|
|
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
|
|
db.add(score)
|
|
score.status, score.error = "failed", str(exc)[:500]
|
|
db.commit()
|
|
failures += 1
|
|
db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(progress_done=ScoringBatch.progress_done + 1))
|
|
db.commit()
|
|
return failures
|
|
|
|
|
|
def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None, retriever=retrieve):
|
|
with session_factory() as db:
|
|
batch = db.get(ScoringBatch, batch_id)
|
|
if not batch:
|
|
raise ValueError("评分批次不存在")
|
|
project_ids = batch.project_ids if batch.project_ids is not None else db.scalars(
|
|
select(Project.id).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all()
|
|
criteria = batch.rubric_snapshot["criteria"]
|
|
batch.status, batch.progress_total, batch.progress_done, batch.finished_at = "running", len(project_ids) * len(criteria), 0, None
|
|
db.commit()
|
|
failures = 0
|
|
with ThreadPoolExecutor(max_workers=max(1, int(batch.model_config_snapshot.get("concurrency", 1)))) as pool:
|
|
futures = [pool.submit(_run_project, batch_id, project_id, session_factory, client_factory, embedder, retriever) for project_id in project_ids]
|
|
for future in as_completed(futures):
|
|
try:
|
|
failures += future.result()
|
|
except Exception as exc:
|
|
failures += 1
|
|
batch.error = (batch.error + f" 项目任务失败: {type(exc).__name__}")[:1000]
|
|
batch.status, batch.finished_at = ("partial" if failures else "completed"), now()
|
|
db.commit()
|