fix: improve OCR indexing and evidence review
This commit is contained in:
+12
-10
@@ -6,9 +6,11 @@
|
||||
|
||||
- 已实现《自动化专业综合实践》的教师端闭环:课程与规则版本、挂载目录扫描和导入、文档解析、课程及项目知识库、独立评分批次、逐项复核、Excel 成绩表和逐生 PDF 反馈。
|
||||
- 前端为 React/TypeScript/Vite/Ant Design;后端为 FastAPI/SQLAlchemy;PostgreSQL + pgvector 存储数据,Redis + RQ 执行评分任务,Nginx 提供前端并代理 `/api`。
|
||||
- 本机 Compose 预览使用 `127.0.0.1:18080`,因为 `8080` 已被占用;仓库默认端口仍为 `8080`。本机 `.env` 已生成随机加密密钥,但不在 Git 中。
|
||||
- 已执行数据库迁移 `0001`,初始课程及其版本已写入 PostgreSQL。当前未导入学生项目。
|
||||
- 本机 Compose 预览使用 `127.0.0.1:18080`,因为 `8080` 已被占用;仓库默认端口仍为 `8080`。本机 `.env` 已生成随机加密密钥并填入 DeepSeek API Key,但不在 Git 中。
|
||||
- 已执行数据库迁移 `0002`,初始课程及其版本已写入 PostgreSQL。本机已从 `docs/` 示例报告导入 1 个验收项目;汇报 PPTX 由示例报告内容生成,并非学生原始 PPT。
|
||||
- `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 渲染。
|
||||
- 前端生产构建通过;Compose 的 PostgreSQL、Redis、API、worker、Web 当前均已启动,首页、深链接、`/api/health`、课程及仪表盘 API 返回正常。数据库版本为 `0001`。
|
||||
- 最终后端测试:**18 通过、1 跳过**;跳过项是宿主机 LibreOffice 缺失。容器内 LibreOffice 25.2.3.2 已实际转换课程 DOCX 和验收 PPTX,页数、文字及页面图片均正常。
|
||||
- 前端生产构建通过;Compose 的 PostgreSQL、Redis、API、worker、Web 当前均已启动,首页、深链接、`/api/health`、课程及仪表盘 API 返回正常。数据库版本为 `0002`;全新 SQLite 数据库迁移至 `0002` 也已验证。
|
||||
- RQ worker 曾在空闲期间因 Redis 连接超时退出,日志显示正常退出码 0;已在 `compose.yaml` 设置 `restart: unless-stopped` 并重新启动。接手后应观察是否反复超时。
|
||||
- 当前未提供真实 BGE-M3/OCR 权重和 DeepSeek API Key。测试对外部模型使用 mock;**尚未验证真实模型请求、真实嵌入检索与扫描 PDF OCR**。模型缺失时系统可启动和导入,但知识库入库或评分会给出错误。
|
||||
- Windows Docker Desktop 的步骤已写入部署指南,尚未在 Windows 机器上实测。
|
||||
- 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。
|
||||
- 对批次 `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 模型。重启服务并确认模型设置页。
|
||||
2. 用一套脱敏的学生目录执行真实流程:扫描和确认、课程知识库入库、创建并运行评分批次、核对每项证据与页码、教师修改、导出 Excel 和 PDF。尤其检查真实模型 JSON、页面图像和批次失败重试。
|
||||
3. 在目标 Windows Docker Desktop 环境验证目录挂载、模型路径和完整启动。
|
||||
4. 持续查看 worker 日志;若 Redis 超时重复出现,再定位 Docker/WSL 休眠或网络原因。不要仅凭容器显示“已启动”判断评分任务可用。
|
||||
1. 使用独立提供、已脱敏的学生目录再次验收五类原始材料,尤其是学生原始 PPTX 和真实扫描仪 PDF;当前验收使用仓库示例报告与生成材料。
|
||||
2. 在目标 Windows Docker Desktop 环境验证目录挂载、模型路径和完整启动。
|
||||
3. 持续查看 worker 日志;若 Redis 空闲超时重复出现,再定位 Docker 后端休眠或网络原因。不要仅凭容器显示“已启动”判断评分任务可用。
|
||||
|
||||
当前代码位于 `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")
|
||||
@@ -26,6 +26,17 @@ def test_json_validation_and_evidence():
|
||||
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):
|
||||
image = tmp_path / "page.png"
|
||||
image.write_bytes(b"png fixture")
|
||||
@@ -56,6 +67,28 @@ def test_project_isolation_and_chunk_metadata(db):
|
||||
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):
|
||||
result = save_model_config(db, {"model": "deepseek-flash", "api_key": "test-private-key"})
|
||||
assert result["api_key_configured"] is True
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-3
@@ -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 地址和密钥;该端点必须支持文本与图片输入。
|
||||
- 离线嵌入模型包。默认使用 BAAI/bge-m3。内存不足时可改用 BAAI/bge-small-zh-v1.5。
|
||||
|
||||
仓库的 `docs/` 是课程资料,不是学生原件。旧版 `.doc` 只用于课程模板,经 LibreOffice 转换;学生输入只接受 PDF、DOCX、PPTX。
|
||||
LibreOffice 已安装在后端 Docker 镜像中。按本指南使用容器时,教师不需要在 Windows 或 Linux 宿主机单独安装 LibreOffice;仅在脱离容器运行后端时才需自行安装。
|
||||
|
||||
## Linux 启动
|
||||
|
||||
@@ -41,13 +42,13 @@ curl http://127.0.0.1:8080/api/health
|
||||
|
||||
## Windows Docker Desktop 启动
|
||||
|
||||
1. 安装 Docker Desktop,启用 WSL2 后端并允许访问存放材料的磁盘。
|
||||
1. 安装并启动 Docker Desktop,启用 WSL2 后端,保持 Linux 容器模式。
|
||||
2. 在 PowerShell 中进入项目目录:`Copy-Item .env.example .env`。
|
||||
3. 编辑 `.env` 中的 `WISEGRADE_DATA_DIR`,使用正斜杠绝对路径,例如 `D:/WiseGradeData`。不要在 `.env` 中添加引号。
|
||||
4. 将离线模型目录放在 `models/bge-m3`,填写 API 配置和随机 `WISEGRADE_SECRET_KEY`。
|
||||
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 配置。
|
||||
也可以把模型复制进后端镜像,但当前 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 模型。
|
||||
|
||||
下面这组 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 都属于课程版本。
|
||||
|
||||
@@ -21,6 +21,9 @@ const nav = [
|
||||
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 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 }) {
|
||||
const [text, setText] = useState(JSON.stringify(value, null, 2));
|
||||
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]);
|
||||
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> }]} /></>}
|
||||
<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>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user