diff --git a/HANDOFF.md b/HANDOFF.md index a580153..88840b2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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` 排除。 diff --git a/backend/migrations/versions/0002_knowledge_index_version.py b/backend/migrations/versions/0002_knowledge_index_version.py new file mode 100644 index 0000000..67cd718 --- /dev/null +++ b/backend/migrations/versions/0002_knowledge_index_version.py @@ -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") diff --git a/backend/tests/test_llm_and_rag.py b/backend/tests/test_llm_and_rag.py index d09a062..3655ef0 100644 --- a/backend/tests/test_llm_and_rag.py +++ b/backend/tests/test_llm_and_rag.py @@ -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 diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py index 3fc5ef5..218f914 100644 --- a/backend/wisegrade/api.py +++ b/backend/wisegrade/api.py @@ -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} diff --git a/backend/wisegrade/exports.py b/backend/wisegrade/exports.py index 7105fa2..5f02799 100644 --- a/backend/wisegrade/exports.py +++ b/backend/wisegrade/exports.py @@ -82,7 +82,10 @@ def _html_report(result, batch): rows = [] for row in result["rows"]: criterion = row["criterion"] - evidences = "".join(f"
模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}
" diff --git a/backend/wisegrade/llm.py b/backend/wisegrade/llm.py index b2d8d3a..675548e 100644 --- a/backend/wisegrade/llm.py +++ b/backend/wisegrade/llm.py @@ -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 diff --git a/backend/wisegrade/models.py b/backend/wisegrade/models.py index 2532434..469d2a6 100644 --- a/backend/wisegrade/models.py +++ b/backend/wisegrade/models.py @@ -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"),) diff --git a/backend/wisegrade/parsing.py b/backend/wisegrade/parsing.py index dc7fb2b..44a1579 100644 --- a/backend/wisegrade/parsing.py +++ b/backend/wisegrade/parsing.py @@ -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): diff --git a/backend/wisegrade/rag.py b/backend/wisegrade/rag.py index e5f2048..757e719 100644 --- a/backend/wisegrade/rag.py +++ b/backend/wisegrade/rag.py @@ -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)) diff --git a/backend/wisegrade/scoring.py b/backend/wisegrade/scoring.py index ea50198..fc115a2 100644 --- a/backend/wisegrade/scoring.py +++ b/backend/wisegrade/scoring.py @@ -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) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 525e413..918e365 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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`,访问