fix: improve OCR indexing and evidence review
This commit is contained in:
@@ -485,7 +485,7 @@ def start_batch(batch_id: int, db: Session = Depends(get_db)):
|
||||
job_timeout="12h", retry=Retry(max=1, interval=[60]))
|
||||
except Exception as exc:
|
||||
raise HTTPException(503, f"无法提交后台任务: {type(exc).__name__}") from exc
|
||||
batch.status, batch.error = "queued", ""
|
||||
batch.status, batch.error, batch.finished_at = "queued", "", None
|
||||
db.commit()
|
||||
return {"id": batch.id, "status": batch.status}
|
||||
|
||||
|
||||
@@ -82,7 +82,10 @@ def _html_report(result, batch):
|
||||
rows = []
|
||||
for row in result["rows"]:
|
||||
criterion = row["criterion"]
|
||||
evidences = "".join(f"<li>{esc(e['document'])} · 第{e['page']}页/张:{esc(e['quote'])}</li>" for e in row["evidence"])
|
||||
evidences = "".join(
|
||||
f"<li>{esc(e['document'])} · 第{e['page']}{'张幻灯片' if str(e['document']).lower().endswith('.pptx') else '页'}:{esc(e['quote'])}</li>"
|
||||
for e in row["evidence"]
|
||||
)
|
||||
missing = "、".join(row["missing_evidence"] or [])
|
||||
rows.append(f"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>"
|
||||
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}</p>"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -105,8 +106,38 @@ 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)
|
||||
if not chunk or evidence.page != chunk["page"] or evidence.quote not in chunk["text"]:
|
||||
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
|
||||
|
||||
@@ -165,6 +165,7 @@ class KnowledgeDocument(Base):
|
||||
title = Column(String(500), nullable=False)
|
||||
document_type = Column(String(80), nullable=False)
|
||||
source_hash = Column(String(64), nullable=False)
|
||||
index_version = Column(Integer, nullable=False, default=2, server_default="1")
|
||||
status = Column(String(40), default="ready")
|
||||
__table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
from functools import lru_cache
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
@@ -30,11 +31,21 @@ def _ocr_engine(model_dir: str):
|
||||
from paddleocr import PaddleOCR
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("扫描PDF需要PaddleOCR;请安装OCR依赖或交由教师处理") from exc
|
||||
try:
|
||||
return PaddleOCR(lang="ch", text_detection_model_dir=str(detection), text_recognition_model_dir=str(recognition),
|
||||
use_textline_orientation=False, use_doc_orientation_classify=False, use_doc_unwarping=False)
|
||||
except TypeError:
|
||||
return PaddleOCR(lang="ch", det_model_dir=str(detection), rec_model_dir=str(recognition), use_angle_cls=False, show_log=False)
|
||||
if int(version("paddleocr").split(".")[0]) >= 3:
|
||||
def model_name(directory):
|
||||
config = directory / "inference.yml"
|
||||
if not config.is_file():
|
||||
raise RuntimeError(f"离线OCR模型缺少 inference.yml: {directory}")
|
||||
for line in config.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("model_name:"):
|
||||
return line.split(":", 1)[1].strip().strip("'\"")
|
||||
raise RuntimeError(f"离线OCR模型未声明 model_name: {config}")
|
||||
|
||||
return PaddleOCR(text_detection_model_name=model_name(detection), text_detection_model_dir=str(detection),
|
||||
text_recognition_model_name=model_name(recognition), text_recognition_model_dir=str(recognition),
|
||||
use_textline_orientation=False, use_doc_orientation_classify=False, use_doc_unwarping=False,
|
||||
device="cpu", enable_mkldnn=False)
|
||||
return PaddleOCR(lang="ch", det_model_dir=str(detection), rec_model_dir=str(recognition), use_angle_cls=False, show_log=False)
|
||||
|
||||
|
||||
def _ocr(image_path: str):
|
||||
|
||||
+18
-10
@@ -7,9 +7,13 @@ from .config import get_settings
|
||||
from .models import KnowledgeChunk, KnowledgeDocument
|
||||
|
||||
|
||||
INDEX_VERSION = 2
|
||||
|
||||
|
||||
def split_pages(pages: list[dict], *, size=650, overlap=100):
|
||||
for page in pages:
|
||||
blocks = [block.strip() for block in page.get("text", "").split("\n\n") if block.strip()]
|
||||
text = page.get("text", "").strip()
|
||||
blocks = [text] if text else []
|
||||
for table in page.get("tables", []):
|
||||
blocks.append("表格: " + str(table.get("rows", table)))
|
||||
for block in blocks:
|
||||
@@ -49,18 +53,22 @@ def ingest(db, *, course_version_id: int, project_id: int | None, submission_fil
|
||||
existing = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id,
|
||||
KnowledgeDocument.project_id == project_id, KnowledgeDocument.submission_file_id == submission_file_id,
|
||||
KnowledgeDocument.title == title))
|
||||
if existing and existing.source_hash == source_hash and existing.status == "ready":
|
||||
if existing and existing.source_hash == source_hash and existing.index_version == INDEX_VERSION and existing.status == "ready":
|
||||
return existing
|
||||
if existing:
|
||||
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id))
|
||||
db.delete(existing)
|
||||
db.flush()
|
||||
doc = KnowledgeDocument(course_version_id=course_version_id, project_id=project_id, submission_file_id=submission_file_id,
|
||||
title=title, document_type=category, source_hash=source_hash, status="indexing")
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
chunks = list(split_pages(pages))
|
||||
vectors = embedder([chunk["text"] for chunk in chunks])
|
||||
if len(vectors) != len(chunks):
|
||||
raise RuntimeError("嵌入向量数量与知识片段数量不一致")
|
||||
if existing:
|
||||
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id))
|
||||
doc = existing
|
||||
doc.document_type, doc.source_hash = category, source_hash
|
||||
else:
|
||||
doc = KnowledgeDocument(course_version_id=course_version_id, project_id=project_id, submission_file_id=submission_file_id,
|
||||
title=title, document_type=category, source_hash=source_hash)
|
||||
db.add(doc)
|
||||
doc.index_version, doc.status = INDEX_VERSION, "indexing"
|
||||
db.flush()
|
||||
for chunk, vector in zip(chunks, vectors):
|
||||
db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id,
|
||||
document_type=category, page=chunk["page"], section=chunk["section"], text=chunk["text"], embedding=vector))
|
||||
|
||||
@@ -8,7 +8,7 @@ from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validat
|
||||
from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch,
|
||||
SubmissionFile, now)
|
||||
from .parsing import parse_document
|
||||
from .rag import ingest, retrieve
|
||||
from .rag import INDEX_VERSION, ingest, retrieve
|
||||
from .storage import secure_path, sha256
|
||||
from .settings_store import client_for_batch
|
||||
|
||||
@@ -20,6 +20,18 @@ def parse_submission(db, file: SubmissionFile, *, embedder=None):
|
||||
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:
|
||||
@@ -99,7 +111,16 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
||||
"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"}}
|
||||
result = validate_score(client.json_call(batch.course_snapshot["prompts"]["score"], payload, ScoreResult, images=images), criterion, chunks)
|
||||
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():
|
||||
@@ -122,7 +143,7 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
|
||||
client = client_factory(db, batch)
|
||||
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
|
||||
for file in files:
|
||||
if file.recognition_status == "recognized" and file.parse_status != "ready":
|
||||
if file.recognition_status == "recognized":
|
||||
try:
|
||||
parse_submission(db, file, embedder=embedder)
|
||||
except Exception:
|
||||
@@ -159,7 +180,7 @@ def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=cli
|
||||
raise ValueError("评分批次不存在")
|
||||
project_ids = 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 = "running", len(project_ids) * len(criteria), 0
|
||||
batch.status, batch.progress_total, batch.progress_done, batch.finished_at = "running", len(project_ids) * len(criteria), 0, None
|
||||
db.commit()
|
||||
try:
|
||||
client_factory(db, batch)
|
||||
|
||||
Reference in New Issue
Block a user