fix: improve OCR indexing and evidence review

This commit is contained in:
2026-09-25 19:03:19 +08:00
parent ac1f0ff46d
commit df5fcb4bda
12 changed files with 185 additions and 36 deletions
+12 -10
View File
@@ -6,9 +6,11 @@
- 已实现《自动化专业综合实践》的教师端闭环:课程与规则版本、挂载目录扫描和导入、文档解析、课程及项目知识库、独立评分批次、逐项复核、Excel 成绩表和逐生 PDF 反馈。 - 已实现《自动化专业综合实践》的教师端闭环:课程与规则版本、挂载目录扫描和导入、文档解析、课程及项目知识库、独立评分批次、逐项复核、Excel 成绩表和逐生 PDF 反馈。
- 前端为 React/TypeScript/Vite/Ant Design;后端为 FastAPI/SQLAlchemy;PostgreSQL + pgvector 存储数据,Redis + RQ 执行评分任务,Nginx 提供前端并代理 `/api`。 - 前端为 React/TypeScript/Vite/Ant Design;后端为 FastAPI/SQLAlchemy;PostgreSQL + pgvector 存储数据,Redis + RQ 执行评分任务,Nginx 提供前端并代理 `/api`。
- 本机 Compose 预览使用 `127.0.0.1:18080`,因为 `8080` 已被占用;仓库默认端口仍为 `8080`。本机 `.env` 已生成随机加密密钥,但不在 Git 中。 - 本机 Compose 预览使用 `127.0.0.1:18080`,因为 `8080` 已被占用;仓库默认端口仍为 `8080`。本机 `.env` 已生成随机加密密钥并填入 DeepSeek API Key,但不在 Git 中。
- 已执行数据库迁移 `0001`,初始课程及其版本已写入 PostgreSQL。当前未导入学生项目。 - 已执行数据库迁移 `0002`,初始课程及其版本已写入 PostgreSQL。本机已从 `docs/` 示例报告导入 1 个验收项目;汇报 PPTX 由示例报告内容生成,并非学生原始 PPT。
- `docs/阶段报告3.pdf` 已纳入示例材料和端到端测试。 - `docs/阶段报告3.pdf` 已纳入示例材料和端到端测试。
- 本机已下载 BGE-M3 到 `models/bge-m3/`,固定 Hugging Face 修订版 `5617a9f61b028005a4858fdac845db406aefb181`;模型目录不提交到 Git。
- 本机已下载 PP-OCRv5 mobile 检测与中文识别模型到 `models/ocr/`;下载与解压命令见部署指南。
## 代码入口与数据流 ## 代码入口与数据流
@@ -49,17 +51,17 @@ uv run pytest -q
## 已验证与尚未验证 ## 已验证与尚未验证
- 最终后端测试:**15 通过、1 跳过**;跳过项是宿主机 LibreOffice 缺失。容器内另行验证了 DOCX/PPTX 渲染。 - 最终后端测试:**18 通过、1 跳过**;跳过项是宿主机 LibreOffice 缺失。容器内 LibreOffice 25.2.3.2 已实际转换课程 DOCX 和验收 PPTX,页数、文字及页面图片均正常。
- 前端生产构建通过;Compose 的 PostgreSQL、Redis、API、worker、Web 当前均已启动,首页、深链接、`/api/health`、课程及仪表盘 API 返回正常。数据库版本为 `0001`。 - 前端生产构建通过;Compose 的 PostgreSQL、Redis、API、worker、Web 当前均已启动,首页、深链接、`/api/health`、课程及仪表盘 API 返回正常。数据库版本为 `0002`;全新 SQLite 数据库迁移至 `0002` 也已验证。
- RQ worker 曾在空闲期间因 Redis 连接超时退出,日志显示正常退出码 0;已在 `compose.yaml` 设置 `restart: unless-stopped` 并重新启动。接手后应观察是否反复超时。 - RQ worker 曾在空闲期间因 Redis 连接超时退出,日志显示正常退出码 0;已在 `compose.yaml` 设置 `restart: unless-stopped` 并重新启动。接手后应观察是否反复超时。
- 当前未提供真实 BGE-M3/OCR 权重和 DeepSeek API Key。测试对外部模型使用 mock;**尚未验证真实模型请求、真实嵌入检索与扫描 PDF OCR**。模型缺失时系统可启动和导入,但知识库入库或评分会给出错误。 - 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。
- Windows Docker Desktop 的步骤已写入部署指南,尚未在 Windows 机器上实测。 - 对批次 `3` 完成教师复核和修改历史验证;Excel 成绩表及 11 页 PDF 反馈均可下载且可解析,文件位于 `data/exports/`。批次 `2` 保留为首次真实运行的对照记录(16 项成功、6 项证据不匹配)。
- 将 `docs/阶段报告1.pdf` 的一页栅格化为无文本层 PDF 后,离线 PaddleOCR 提取 496 字,识别到目录和章节标题。真实扫描仪产生的不同质量扫描件尚未实测。Windows Docker Desktop 尚未在 Windows 机器上实测。
## 接手后优先完成 ## 接手后优先完成
1. 放入离线 BGE-M3 模型,配置 DeepSeek 密钥和可处理图像的 `deepseek-flash` 兼容端点;有扫描版 PDF 时再放入离线 OCR 模型。重启服务并确认模型设置页。 1. 使用独立提供、已脱敏的学生目录再次验收五类原始材料,尤其是学生原始 PPTX 和真实扫描仪 PDF;当前验收使用仓库示例报告与生成材料。
2. 用一套脱敏的学生目录执行真实流程:扫描和确认、课程知识库入库、创建并运行评分批次、核对每项证据与页码、教师修改、导出 Excel 和 PDF。尤其检查真实模型 JSON、页面图像和批次失败重试。 2. 在目标 Windows Docker Desktop 环境验证目录挂载、模型路径和完整启动。
3. 在目标 Windows Docker Desktop 环境验证目录挂载、模型路径和完整启动。 3. 持续查看 worker 日志;若 Redis 空闲超时重复出现,再定位 Docker 后端休眠或网络原因。不要仅凭容器显示“已启动”判断评分任务可用。
4. 持续查看 worker 日志;若 Redis 超时重复出现,再定位 Docker/WSL 休眠或网络原因。不要仅凭容器显示“已启动”判断评分任务可用。
当前代码位于 `main` 分支,阶段提交使用 Conventional Commits。交接前工作区应保持干净;`.env`、`models/`、`data/`、缓存和构建产物由 `.gitignore` 排除。 当前代码位于 `main` 分支,阶段提交使用 Conventional Commits。交接前工作区应保持干净;`.env`、`models/`、`data/`、缓存和构建产物由 `.gitignore` 排除。
@@ -0,0 +1,22 @@
"""Track the knowledge chunk format so existing documents can be reindexed.
Revision ID: 0002
Revises: 0001
"""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade():
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("knowledge_documents")}
if "index_version" not in columns:
op.add_column("knowledge_documents", sa.Column("index_version", sa.Integer(), nullable=False, server_default="1"))
def downgrade():
op.drop_column("knowledge_documents", "index_version")
+33
View File
@@ -26,6 +26,17 @@ def test_json_validation_and_evidence():
ScoreResult(criterion_id="x", score=5, max_score=10, reason="无依据", evidence=[], confidence=.5) ScoreResult(criterion_id="x", score=5, max_score=10, reason="无依据", evidence=[], confidence=.5)
def test_evidence_quote_resolves_pdf_line_breaks_and_page():
criterion = {"criterion_id": "stage1.problem", "max_score": 25}
chunks = [{"chunk_id": 7, "page": 2, "text": "机器人\n控制精度:2 cm。"},
{"chunk_id": 8, "page": 3, "text": "其他实验结果"}]
result = ScoreResult(criterion_id="stage1.problem", score=19, max_score=25, reason="有指标",
evidence=[{"chunk_id": 8, "page": 3, "quote": "机器人控制精度:2 cm"}], confidence=.8)
checked = validate_score(result, criterion, chunks)
assert checked.evidence[0].chunk_id == 7 and checked.evidence[0].page == 2
assert checked.evidence[0].quote in chunks[0]["text"]
def test_deepseek_adapter_mock_and_key_not_in_error(tmp_path): def test_deepseek_adapter_mock_and_key_not_in_error(tmp_path):
image = tmp_path / "page.png" image = tmp_path / "page.png"
image.write_bytes(b"png fixture") image.write_bytes(b"png fixture")
@@ -56,6 +67,28 @@ def test_project_isolation_and_chunk_metadata(db):
assert "秘密" not in str(hits) assert "秘密" not in str(hits)
def test_short_paragraphs_share_a_page_chunk():
text = "\n\n".join(f"实验步骤{i}:采集机器人定位数据。" for i in range(12))
chunks = list(split_pages([{"page_number": 4, "section": "实验", "text": text}]))
assert len(chunks) == 1
assert chunks[0]["page"] == 4
assert "实验步骤0" in chunks[0]["text"] and "实验步骤11" in chunks[0]["text"]
def test_stale_knowledge_index_reuses_document_id(db):
pages = [{"page_number": 1, "text": "实验方案与测量结果"}]
document = ingest(db, course_version_id=1, project_id=101, submission_file_id=None,
title="报告", category="stage3", source_hash="same-source", pages=pages, embedder=fake_embed)
old_chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
old_chunk.text = "过时片段"
document.index_version = 1
db.commit()
reindexed = ingest(db, course_version_id=1, project_id=101, submission_file_id=None,
title="报告", category="stage3", source_hash="same-source", pages=pages, embedder=fake_embed)
assert reindexed.id == document.id and reindexed.index_version == 2
assert db.scalar(select(KnowledgeChunk.text).where(KnowledgeChunk.document_id == document.id)) == pages[0]["text"]
def test_model_key_is_encrypted_and_hidden(db): def test_model_key_is_encrypted_and_hidden(db):
result = save_model_config(db, {"model": "deepseek-flash", "api_key": "test-private-key"}) result = save_model_config(db, {"model": "deepseek-flash", "api_key": "test-private-key"})
assert result["api_key_configured"] is True assert result["api_key_configured"] is True
+1 -1
View File
@@ -485,7 +485,7 @@ def start_batch(batch_id: int, db: Session = Depends(get_db)):
job_timeout="12h", retry=Retry(max=1, interval=[60])) job_timeout="12h", retry=Retry(max=1, interval=[60]))
except Exception as exc: except Exception as exc:
raise HTTPException(503, f"无法提交后台任务: {type(exc).__name__}") from 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() db.commit()
return {"id": batch.id, "status": batch.status} return {"id": batch.id, "status": batch.status}
+4 -1
View File
@@ -82,7 +82,10 @@ def _html_report(result, batch):
rows = [] rows = []
for row in result["rows"]: for row in result["rows"]:
criterion = row["criterion"] 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 []) missing = "、".join(row["missing_evidence"] or [])
rows.append(f"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>" 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>" f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}</p>"
+32 -1
View File
@@ -1,6 +1,7 @@
import base64 import base64
import json import json
import time import time
import unicodedata
from pathlib import Path from pathlib import Path
from typing import Any 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"]: if result.criterion_id != criterion["criterion_id"] or result.max_score != criterion["max_score"]:
raise ModelError("模型评分项或满分与规则版本不一致") raise ModelError("模型评分项或满分与规则版本不一致")
by_id = {chunk["chunk_id"]: chunk for chunk in chunks} 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: for evidence in result.evidence:
chunk = by_id.get(evidence.chunk_id) 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("模型证据未能匹配检索原文") raise ModelError("模型证据未能匹配检索原文")
evidence.chunk_id, evidence.page, evidence.quote = chunk["chunk_id"], chunk["page"], quote
return result return result
+1
View File
@@ -165,6 +165,7 @@ class KnowledgeDocument(Base):
title = Column(String(500), nullable=False) title = Column(String(500), nullable=False)
document_type = Column(String(80), nullable=False) document_type = Column(String(80), nullable=False)
source_hash = Column(String(64), 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") status = Column(String(40), default="ready")
__table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),) __table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),)
+16 -5
View File
@@ -1,6 +1,7 @@
import subprocess import subprocess
import tempfile import tempfile
from functools import lru_cache from functools import lru_cache
from importlib.metadata import version
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
@@ -30,11 +31,21 @@ def _ocr_engine(model_dir: str):
from paddleocr import PaddleOCR from paddleocr import PaddleOCR
except ImportError as exc: except ImportError as exc:
raise RuntimeError("扫描PDF需要PaddleOCR;请安装OCR依赖或交由教师处理") from exc raise RuntimeError("扫描PDF需要PaddleOCR;请安装OCR依赖或交由教师处理") from exc
try: if int(version("paddleocr").split(".")[0]) >= 3:
return PaddleOCR(lang="ch", text_detection_model_dir=str(detection), text_recognition_model_dir=str(recognition), def model_name(directory):
use_textline_orientation=False, use_doc_orientation_classify=False, use_doc_unwarping=False) config = directory / "inference.yml"
except TypeError: if not config.is_file():
return PaddleOCR(lang="ch", det_model_dir=str(detection), rec_model_dir=str(recognition), use_angle_cls=False, show_log=False) 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): def _ocr(image_path: str):
+18 -10
View File
@@ -7,9 +7,13 @@ from .config import get_settings
from .models import KnowledgeChunk, KnowledgeDocument from .models import KnowledgeChunk, KnowledgeDocument
INDEX_VERSION = 2
def split_pages(pages: list[dict], *, size=650, overlap=100): def split_pages(pages: list[dict], *, size=650, overlap=100):
for page in pages: 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", []): for table in page.get("tables", []):
blocks.append("表格: " + str(table.get("rows", table))) blocks.append("表格: " + str(table.get("rows", table)))
for block in blocks: 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, 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.project_id == project_id, KnowledgeDocument.submission_file_id == submission_file_id,
KnowledgeDocument.title == title)) 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 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)) chunks = list(split_pages(pages))
vectors = embedder([chunk["text"] for chunk in chunks]) 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): for chunk, vector in zip(chunks, vectors):
db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id, 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)) document_type=category, page=chunk["page"], section=chunk["section"], text=chunk["text"], embedding=vector))
+25 -4
View File
@@ -8,7 +8,7 @@ from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validat
from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch, from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch,
SubmissionFile, now) SubmissionFile, now)
from .parsing import parse_document from .parsing import parse_document
from .rag import ingest, retrieve from .rag import INDEX_VERSION, ingest, retrieve
from .storage import secure_path, sha256 from .storage import secure_path, sha256
from .settings_store import client_for_batch from .settings_store import client_for_batch
@@ -20,6 +20,18 @@ def parse_submission(db, file: SubmissionFile, *, embedder=None):
db.commit() db.commit()
raise ValueError(file.parse_error) raise ValueError(file.parse_error)
if file.parse_status == "ready" and file.parsed: 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 return file.parsed
settings = get_settings() settings = get_settings()
try: 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", "required_output": {"criterion_id": "string", "score": "number", "max_score": "number", "reason": "string",
"evidence": [{"chunk_id": "integer", "page": "integer", "quote": "exact substring"}], "evidence": [{"chunk_id": "integer", "page": "integer", "quote": "exact substring"}],
"missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1"}} "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.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", "" 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(): 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) client = client_factory(db, batch)
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all() files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
for file in files: for file in files:
if file.recognition_status == "recognized" and file.parse_status != "ready": if file.recognition_status == "recognized":
try: try:
parse_submission(db, file, embedder=embedder) parse_submission(db, file, embedder=embedder)
except Exception: except Exception:
@@ -159,7 +180,7 @@ def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=cli
raise ValueError("评分批次不存在") raise ValueError("评分批次不存在")
project_ids = db.scalars(select(Project.id).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all() 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"] 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() db.commit()
try: try:
client_factory(db, batch) client_factory(db, batch)
+17 -3
View File
@@ -4,12 +4,13 @@ WiseGrade 是单机单管理员教师端。Web 服务仅绑定 `127.0.0.1`,默
## 准备 ## 准备
- Docker Engine + Compose v2(Linux),或 Windows Docker Desktop(WSL2 后端)。 - Docker Engine + Compose v2(Linux),或 Windows Docker Desktop(WSL2 后端,运行 Linux 容器)。
- 一个存放学生材料的宿主机目录;其下每个批次目录包含 `学号_姓名_选题题目` 子目录。 - 一个存放学生材料的宿主机目录;其下每个批次目录包含 `学号_姓名_选题题目` 子目录。
- `deepseek-flash` 可访问的 OpenAI 兼容 API 地址和密钥;该端点必须支持文本与图片输入。 - `deepseek-flash` 可访问的 OpenAI 兼容 API 地址和密钥;该端点必须支持文本与图片输入。
- 离线嵌入模型包。默认使用 BAAI/bge-m3。内存不足时可改用 BAAI/bge-small-zh-v1.5。 - 离线嵌入模型包。默认使用 BAAI/bge-m3。内存不足时可改用 BAAI/bge-small-zh-v1.5。
仓库的 `docs/` 是课程资料,不是学生原件。旧版 `.doc` 只用于课程模板,经 LibreOffice 转换;学生输入只接受 PDF、DOCX、PPTX。 仓库的 `docs/` 是课程资料,不是学生原件。旧版 `.doc` 只用于课程模板,经 LibreOffice 转换;学生输入只接受 PDF、DOCX、PPTX。
LibreOffice 已安装在后端 Docker 镜像中。按本指南使用容器时,教师不需要在 Windows 或 Linux 宿主机单独安装 LibreOffice;仅在脱离容器运行后端时才需自行安装。
## Linux 启动 ## Linux 启动
@@ -41,13 +42,13 @@ curl http://127.0.0.1:8080/api/health
## Windows Docker Desktop 启动 ## Windows Docker Desktop 启动
1. 安装 Docker Desktop,启用 WSL2 后端并允许访问存放材料的磁盘。 1. 安装并启动 Docker Desktop,启用 WSL2 后端,保持 Linux 容器模式。
2. 在 PowerShell 中进入项目目录:`Copy-Item .env.example .env`。 2. 在 PowerShell 中进入项目目录:`Copy-Item .env.example .env`。
3. 编辑 `.env` 中的 `WISEGRADE_DATA_DIR`,使用正斜杠绝对路径,例如 `D:/WiseGradeData`。不要在 `.env` 中添加引号。 3. 编辑 `.env` 中的 `WISEGRADE_DATA_DIR`,使用正斜杠绝对路径,例如 `D:/WiseGradeData`。不要在 `.env` 中添加引号。
4. 将离线模型目录放在 `models/bge-m3`,填写 API 配置和随机 `WISEGRADE_SECRET_KEY`。 4. 将离线模型目录放在 `models/bge-m3`,填写 API 配置和随机 `WISEGRADE_SECRET_KEY`。
5. 运行 `docker compose up --build -d`,访问 <http://127.0.0.1:8080>。 5. 运行 `docker compose up --build -d`,访问 <http://127.0.0.1:8080>。
如果挂载失败,先确认 `WISEGRADE_DATA_DIR` 存在、Docker Desktop 已取得对应磁盘访问权,再运行 `docker compose config` 查看展开后的挂载路径。 如果挂载失败,先确认 `WISEGRADE_DATA_DIR` 存在、Docker Desktop 已取得对应磁盘访问权,再运行 `docker compose config` 查看展开后的挂载路径。首次启动后若修改了 `.env` 中的 API Key 等配置,运行 `docker compose up -d --force-recreate api worker web`,使容器读取新值。当前仓库尚未在 Windows 实机验证上述路径和文件挂载。
## 离线模型包 ## 离线模型包
@@ -60,9 +61,22 @@ EMBEDDING_MODEL_VERSION=your-package-version
``` ```
`EMBEDDING_MODEL_VERSION` 会写入每个评分批次快照。若模型目录缺失或内容不完整,导入仍可完成,知识库入库和评分会显示错误。可离线分发 `docker save` 生成的镜像包及单独的 `models-bge-m3.tar.zst`;目标机执行 `docker load -i docker-images.tar` 后解压模型包,运行相同的 Compose 配置。 `EMBEDDING_MODEL_VERSION` 会写入每个评分批次快照。若模型目录缺失或内容不完整,导入仍可完成,知识库入库和评分会显示错误。可离线分发 `docker save` 生成的镜像包及单独的 `models-bge-m3.tar.zst`;目标机执行 `docker load -i docker-images.tar` 后解压模型包,运行相同的 Compose 配置。
也可以把模型复制进后端镜像,但当前 Dockerfile 和 Compose 采用只读目录挂载;若改成镜像内置模型,须同时调整构建上下文,并移除会遮盖镜像内 `/models` 的挂载。本机 BGE-M3 必需文件约 2.29 GB,后端镜像当前约 1.08 GB;直接内置后镜像未压缩体积预计约 3.37 GB。API 与 worker 可以共用镜像层,不必各存一份模型。独立模型包便于单独更新和分发。
扫描 PDF 的 OCR 使用 PaddleOCR。离线 OCR 包放在 `models/ocr/detection/` 和 `models/ocr/recognition/`,或修改 `OCR_MODEL_DIR` 指向包含这两个子目录的位置。没有 OCR 模型且 PDF 无可提取文字时,文件进入解析失败状态并显示原因;系统不会在解析时自动下载 OCR 模型。 扫描 PDF 的 OCR 使用 PaddleOCR。离线 OCR 包放在 `models/ocr/detection/` 和 `models/ocr/recognition/`,或修改 `OCR_MODEL_DIR` 指向包含这两个子目录的位置。没有 OCR 模型且 PDF 无可提取文字时,文件进入解析失败状态并显示原因;系统不会在解析时自动下载 OCR 模型。
下面这组 PP-OCRv5 mobile 中文识别模型已在 PaddleOCR 3.7.0 中验证。下载后将压缩包的顶层目录去掉,使 `detection/` 和 `recognition/` 内直接包含 `inference.yml`、`inference.json` 和 `inference.pdiparams`:
```bash
mkdir -p models/ocr/detection models/ocr/recognition
curl -fL -o models/ocr/PP-OCRv5_mobile_det_infer.tar https://paddle-model-ecology.bj.bcebos.com/paddlex/official_inference_model/paddle3.0.0/PP-OCRv5_mobile_det_infer.tar
curl -fL -o models/ocr/PP-OCRv5_mobile_rec_infer.tar https://paddle-model-ecology.bj.bcebos.com/paddlex/official_inference_model/paddle3.0.0/PP-OCRv5_mobile_rec_infer.tar
tar -xf models/ocr/PP-OCRv5_mobile_det_infer.tar --strip-components=1 -C models/ocr/detection
tar -xf models/ocr/PP-OCRv5_mobile_rec_infer.tar --strip-components=1 -C models/ocr/recognition
```
模型名称从各自的 `inference.yml` 读取,不能让 PaddleOCR 使用与离线权重不匹配的默认模型。模型目录经 Compose 只读挂载,放入权重后无需修改 `.env`。
## 教师操作 ## 教师操作
1. 在“课程与规则”中查看初始课程版本和评分规则。已发布版本不能直接覆盖;复制草稿、编辑、保存并发布新版本。课程目标、指标点、选题方向、模板和三个 Prompt 都属于课程版本。 1. 在“课程与规则”中查看初始课程版本和评分规则。已发布版本不能直接覆盖;复制草稿、编辑、保存并发布新版本。课程目标、指标点、选题方向、模板和三个 Prompt 都属于课程版本。
+4 -1
View File
@@ -21,6 +21,9 @@ const nav = [
const fail = (error: unknown) => message.error(error instanceof Error ? error.message : String(error)); const fail = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
const load = (path: string, setter: (data: any) => void) => api(path).then(setter).catch(fail); const load = (path: string, setter: (data: any) => void) => api(path).then(setter).catch(fail);
const options = (rows: any[], label: (row: any) => string) => rows.map(row => ({ value: row.id, label: label(row) })); const options = (rows: any[], label: (row: any) => string) => rows.map(row => ({ value: row.id, label: label(row) }));
const isSlideEvidence = (evidence: any) => /\.pptx$/i.test(evidence.document || '');
const evidencePlace = (evidence: any) => `第 ${evidence.page} ${isSlideEvidence(evidence) ? '张幻灯片' : '页'}`;
const evidencePreviewLabel = (evidence: any) => isSlideEvidence(evidence) ? '查看该张幻灯片的完整图像' : '查看该页的完整图像';
function JsonField({ value, onChange, disabled, rows = 4, resetKey }: { value: any; onChange: (value: any) => void; disabled?: boolean; rows?: number; resetKey: string }) { function JsonField({ value, onChange, disabled, rows = 4, resetKey }: { value: any; onChange: (value: any) => void; disabled?: boolean; rows?: number; resetKey: string }) {
const [text, setText] = useState(JSON.stringify(value, null, 2)); const [text, setText] = useState(JSON.stringify(value, null, 2));
useEffect(() => { setText(JSON.stringify(value, null, 2)); }, [resetKey]); useEffect(() => { setText(JSON.stringify(value, null, 2)); }, [resetKey]);
@@ -108,7 +111,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
useEffect(() => { if (batch && project) load(`/projects/${project}/scores?batch_id=${batch}`, setResult); }, [batch, project]); useEffect(() => { if (batch && project) load(`/projects/${project}/scores?batch_id=${batch}`, setResult); }, [batch, project]);
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult); const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult);
return <><Title level={2}>逐项评分与教师复核</Title><Space wrap><Select style={{ width: 180 }} placeholder="评分批次" value={batch} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatch} /><Select style={{ width: 330 }} placeholder="学生项目" value={project} options={options(projects, p => `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} /></Space>{result && <><Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{r.criterion.name}</> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态', render: (_, r) => <Tag color={r.status === 'scored' || r.status === 'reviewed' ? 'green' : 'orange'}>{r.status}</Tag> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue({ score: r.final_score ?? 0, comment: r.teacher_comment || r.model_comment || '', reason: '' }); }}>查看证据/复核</Button> }]} /></>} return <><Title level={2}>逐项评分与教师复核</Title><Space wrap><Select style={{ width: 180 }} placeholder="评分批次" value={batch} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatch} /><Select style={{ width: 330 }} placeholder="学生项目" value={project} options={options(projects, p => `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} /></Space>{result && <><Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{r.criterion.name}</> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态', render: (_, r) => <Tag color={r.status === 'scored' || r.status === 'reviewed' ? 'green' : 'orange'}>{r.status}</Tag> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue({ score: r.final_score ?? 0, comment: r.teacher_comment || r.model_comment || '', reason: '' }); }}>查看证据/复核</Button> }]} /></>}
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <><Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '—'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{ev.document} · 第 {ev.page} 页/张</Text><Paragraph>{ev.quote}</Paragraph>{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">查看页面图像</a>}</Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true }]}><InputNumber min={0} max={review.criterion.max_score} /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal> <Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <><Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '—'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Paragraph>{ev.quote}</Paragraph>{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true }]}><InputNumber min={0} max={review.criterion.max_score} /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal>
</>; </>;
} }