test: use PostgreSQL and live model integrations

This commit is contained in:
2026-09-25 20:30:28 +08:00
parent dd48e13cf2
commit 8bebcdb470
9 changed files with 144 additions and 77 deletions
+1
View File
@@ -52,6 +52,7 @@ uv run pytest -q
## 已验证与尚未验证
- 部署镜像内完整后端测试:**19 通过、0 跳过**,包括 DOCX/PPTX 转换与页面图像测试。宿主机运行结果为 18 通过、1 跳过,因为宿主机未安装 LibreOffice;部署容器内为 25.2.3.2。另已用课程 DOCX 和验收 PPTX 实际验证页数、文字及页面图片。
- 2026-09-25:后端测试已改用独立 PostgreSQL + pgvector,并通过真实 DeepSeek 和本地 BGE-M3 模型测试。19 个其他测试通过;使用课程示例材料的完整流程单独重跑通过,22 个评分项全部完成,教师复核和导出检查通过。测试命令见 `docs/DEPLOYMENT.md`。
- 前端生产构建通过;Compose 的 PostgreSQL、Redis、API、worker、Web 当前均已启动,首页、深链接、`/api/health`、课程及仪表盘 API 返回正常。数据库版本为 `0002`;全新 SQLite 数据库迁移至 `0002` 也已验证。
- RQ worker 曾在空闲期间因 Redis 连接超时退出,日志显示正常退出码 0;已在 `compose.yaml` 设置 `restart: unless-stopped` 并重新启动。接手后应观察是否反复超时。
- 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。
+2
View File
@@ -5,5 +5,7 @@ WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.11.0 /uv /uvx /bin/
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --extra ocr
ARG INCLUDE_DEV=false
RUN if [ "$INCLUDE_DEV" = "true" ]; then uv sync --frozen --group dev --extra ocr; fi
COPY . .
ENV PATH=/app/.venv/bin:$PATH PYTHONPATH=/app
+41 -5
View File
@@ -1,13 +1,28 @@
import os
from pathlib import Path
from uuid import uuid4
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine
from sqlalchemy.engine import make_url
from sqlalchemy.orm import sessionmaker
from wisegrade.config import get_settings
from wisegrade.db import Base, engine_for
from wisegrade.db import engine_for
from wisegrade.seed import seed_initial
from wisegrade import models # noqa: F401
@pytest.fixture(scope="session")
def test_database_url():
value = os.environ.get("TEST_DATABASE_URL")
if not value:
pytest.fail("Set TEST_DATABASE_URL to a dedicated PostgreSQL database named wisegrade_tests")
url = make_url(value)
if url.drivername != "postgresql+psycopg" or url.database != "wisegrade_tests":
pytest.fail("TEST_DATABASE_URL must use postgresql+psycopg and the wisegrade_tests database")
return url
@pytest.fixture
@@ -24,14 +39,35 @@ def environment(tmp_path, monkeypatch):
@pytest.fixture
def session_factory(environment):
engine = engine_for(f"sqlite:///{environment / 'test.db'}")
Base.metadata.create_all(engine)
def session_factory(environment, monkeypatch, test_database_url):
admin_engine = create_engine(test_database_url, isolation_level="AUTOCOMMIT")
name = f"wisegrade_test_{uuid4().hex}"
engine = None
created = False
try:
with admin_engine.connect() as connection:
connection.exec_driver_sql(f'CREATE DATABASE "{name}"')
created = True
url = test_database_url.set(database=name).render_as_string(hide_password=False)
monkeypatch.setenv("DATABASE_URL", url)
get_settings.cache_clear()
backend_root = Path(__file__).resolve().parents[1]
migration_config = Config(str(backend_root / "alembic.ini"))
migration_config.set_main_option("script_location", str(backend_root / "migrations"))
command.upgrade(migration_config, "head")
engine = engine_for(url)
factory = sessionmaker(bind=engine, expire_on_commit=False)
with factory() as db:
seed_initial(db)
yield factory
finally:
if engine is not None:
engine.dispose()
get_settings.cache_clear()
if created:
with admin_engine.connect() as connection:
connection.exec_driver_sql(f'DROP DATABASE "{name}" WITH (FORCE)')
admin_engine.dispose()
@pytest.fixture
+1 -1
View File
@@ -55,7 +55,7 @@ def test_missing_material_is_explicit_and_reviewable(session_factory):
client = client_for(session_factory)
try:
batch_id = client.post("/api/scoring-batches", json={"course_version_id": 1, "rubric_version_id": 1}).json()["id"]
run_batch(batch_id, session_factory=session_factory, client_factory=lambda db, batch: object())
run_batch(batch_id, session_factory=session_factory)
status = client.get(f"/api/scoring-batches/{batch_id}/progress").json()
assert status["status"] == "partial" and status["progress_done"] == status["progress_total"] == 22
result = client.get(f"/api/scoring-batches/{batch_id}/scores").json()[0]
+7 -1
View File
@@ -1,13 +1,19 @@
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy import select, text
from wisegrade.models import CourseObjective, CourseVersion, GraduationIndicator, RubricCriterion, RubricScoreLevel, RubricVersion, TopicDirection
from wisegrade.rules import RubricData, totals
from wisegrade.storage import classify, extract_identity, parse_student_folder, secure_path, sha256
def test_postgresql_migrations_and_vector_extension(db):
assert db.bind.dialect.name == "postgresql"
assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0002"
assert db.scalar(text("SELECT extname FROM pg_extension WHERE extname = 'vector'")) == "vector"
def test_seeded_course_and_weights(db):
version = db.scalar(select(CourseVersion))
rubric = db.scalar(select(RubricVersion))
+34 -33
View File
@@ -1,17 +1,22 @@
import json
import httpx
import pytest
from PIL import Image, ImageDraw
from sqlalchemy import select
from wisegrade.llm import DeepSeekClient, ModelError, ScoreResult, validate_score
from wisegrade.models import AppSetting, KnowledgeChunk
from wisegrade.llm import DeepSeekClient, FactResult, ModelError, ScoreResult, validate_score
from wisegrade.models import AppSetting, KnowledgeChunk, Project, Student
from wisegrade.rag import ingest, local_encoder, retrieve, split_pages
from wisegrade.settings_store import model_config, save_model_config
def fake_embed(texts):
return [[1.0] + [0.0] * 1023 for _ in texts]
def make_project(db, number):
student = Student(student_no=f"90000000{number}", name=f"测试学生{number}")
db.add(student)
db.flush()
project = Project(course_version_id=1, student_id=student.id, topic=f"测试项目{number}",
source_dir=f"tests/student-{number}")
db.add(project)
db.flush()
return project.id
def test_json_validation_and_evidence():
@@ -37,33 +42,28 @@ def test_evidence_quote_resolves_pdf_line_breaks_and_page():
assert checked.evidence[0].quote in chunks[0]["text"]
def test_deepseek_adapter_mock_and_key_not_in_error(tmp_path):
@pytest.mark.integration
def test_deepseek_adapter_live_with_image(tmp_path):
image = tmp_path / "page.png"
image.write_bytes(b"png fixture")
def handler(request):
assert request.url.path == "/chat/completions"
assert request.headers["Authorization"] == "Bearer fake-secret"
content = json.loads(request.content)["messages"][1]["content"]
assert content[1]["type"] == "image_url" and content[1]["image_url"]["url"].startswith("data:image/png;base64,")
return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"criterion_id": "x", "score": 0,
"max_score": 10, "reason": "缺失", "evidence": [], "confidence": .2})}}]})
client = DeepSeekClient(base_url="https://example.test", api_key="fake-secret", retries=0, transport=httpx.MockTransport(handler))
assert client.json_call("prompt", {}, ScoreResult, images=[str(image)]).score == 0
bad = DeepSeekClient(base_url="https://example.test", api_key="fake-secret", retries=0,
transport=httpx.MockTransport(lambda _: httpx.Response(500, text="fake-secret")))
with pytest.raises(ModelError) as error:
bad.json_call("prompt", {}, ScoreResult)
assert "fake-secret" not in str(error.value)
picture = Image.new("RGB", (320, 100), "white")
ImageDraw.Draw(picture).text((20, 30), "TEST 123", fill="black")
picture.save(image)
result = DeepSeekClient().json_call(
"从用户提供的JSON中复制topic字段,原样返回到JSON对象的topic字段。",
{"topic": "真实模型连通测试"}, FactResult, images=[str(image)])
assert result.topic == "真实模型连通测试"
def test_project_isolation_and_chunk_metadata(db):
project_a = make_project(db, 1)
project_b = make_project(db, 2)
pages = [{"page_number": 3, "section": "实验", "text": "机器人实验数据和分析"}]
ingest(db, course_version_id=1, project_id=101, submission_file_id=None, title="学生A", category="stage3",
source_hash="a", pages=pages, embedder=fake_embed)
ingest(db, course_version_id=1, project_id=102, submission_file_id=None, title="学生B", category="stage3",
source_hash="b", pages=[{**pages[0], "text": "另一个学生的秘密"}], embedder=fake_embed)
hits = retrieve(db, course_version_id=1, project_id=101, category="stage3", query="实验数据", embedder=fake_embed)
assert hits and all(hit["project_id"] == 101 and hit["page"] == 3 for hit in hits)
ingest(db, course_version_id=1, project_id=project_a, submission_file_id=None, title="学生A", category="stage3",
source_hash="a", pages=pages)
ingest(db, course_version_id=1, project_id=project_b, submission_file_id=None, title="学生B", category="stage3",
source_hash="b", pages=[{**pages[0], "text": "另一个学生的秘密"}])
hits = retrieve(db, course_version_id=1, project_id=project_a, category="stage3", query="实验数据")
assert hits and all(hit["project_id"] == project_a and hit["page"] == 3 for hit in hits)
assert "秘密" not in str(hits)
@@ -76,15 +76,16 @@ def test_short_paragraphs_share_a_page_chunk():
def test_stale_knowledge_index_reuses_document_id(db):
project_id = make_project(db, 1)
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)
document = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None,
title="报告", category="stage3", source_hash="same-source", pages=pages)
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)
reindexed = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None,
title="报告", category="stage3", source_hash="same-source", pages=pages)
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"]
+12 -30
View File
@@ -4,37 +4,13 @@ from pathlib import Path
import pymupdf as fitz
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from wisegrade.config import get_settings
from wisegrade.db import get_db
from wisegrade.llm import ConsistencyResult, FactResult, ScoreResult
from wisegrade.main import app
from wisegrade.models import CourseVersion, CriterionScore, RubricVersion, ScoringBatch, TeacherRevision
from wisegrade.rag import retrieve
from wisegrade.scoring import run_batch
from wisegrade.storage import scan
def fake_embed(texts):
return [[1.0] + [0.0] * 1023 for _ in texts]
class FakeModel:
def json_call(self, _prompt, payload, schema, images=None):
if schema is FactResult:
return FactResult(topic="自动化实践示例")
if schema is ConsistencyResult:
return ConsistencyResult()
criterion = payload["criterion"]
evidence = payload["evidence"][0]
return ScoreResult(criterion_id=criterion["criterion_id"], score=criterion["max_score"] / 2,
max_score=criterion["max_score"], reason="示例证据支持部分要求",
evidence=[{"chunk_id": evidence["chunk_id"], "page": evidence["page"],
"quote": evidence["text"][:min(12, len(evidence["text"]))]}],
missing_evidence=[], suggestion="补充量化论证", confidence=.7)
def make_pdf(path, title):
pdf = fitz.open()
page = pdf.new_page()
@@ -70,14 +46,20 @@ def test_docs_examples_end_to_end(environment, session_factory):
response = client.post("/api/scoring-batches", json={"course_version_id": 1, "rubric_version_id": 1})
assert response.status_code == 200, response.text
batch_id = response.json()["id"]
run_batch(batch_id, session_factory=session_factory, client_factory=lambda db, batch: FakeModel(), embedder=fake_embed,
retriever=lambda db, **kwargs: retrieve(db, embedder=fake_embed, **kwargs))
run_batch(batch_id, session_factory=session_factory)
progress = client.get(f"/api/scoring-batches/{batch_id}/progress").json()
assert progress["status"] == "completed", progress
assert progress["progress_done"] == progress["progress_total"] == 22
result = client.get(f"/api/projects/{project_id}/scores?batch_id={batch_id}").json()
assert result["totals"]["overall"] == 50
assert all(row["evidence"] and row["evidence"][0]["page"] >= 1 for row in result["rows"])
assert client.get(result["rows"][0]["evidence"][0]["image_url"]).content[:8] == b"\x89PNG\r\n\x1a\n"
assert result["totals"]["overall"] is not None
assert all(row["status"] == "scored" and
(row["model_score"] == 0 or row["evidence"] and row["evidence"][0]["page"] >= 1)
for row in result["rows"]), result["rows"]
evidence_row = next(row for row in result["rows"] if row["evidence"] and row["evidence"][0]["image_url"])
assert client.get(evidence_row["evidence"][0]["image_url"]).content[:8] == b"\x89PNG\r\n\x1a\n"
score_id = result["rows"][0]["score_id"]
reviewed = client.put(f"/api/criterion-scores/{score_id}/review", json={"score": 20, "comment": "教师核定", "reason": "核查原文"})
teacher_score = 0 if result["rows"][0]["model_score"] != 0 else 1
reviewed = client.put(f"/api/criterion-scores/{score_id}/review", json={"score": teacher_score, "comment": "教师核定", "reason": "核查原文"})
assert reviewed.status_code == 200
assert reviewed.json()["model_score"] != reviewed.json()["teacher_score"]
assert len(client.get(f"/api/criterion-scores/{score_id}/revisions").json()) == 1
+32
View File
@@ -13,6 +13,38 @@ services:
retries: 12
redis:
image: redis:7-alpine
test-db:
profiles: [test]
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: wisegrade_tests
POSTGRES_USER: wisegrade_test
POSTGRES_PASSWORD: wisegrade_test
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, "pg_isready -U wisegrade_test -d wisegrade_tests"]
interval: 5s
retries: 12
test:
profiles: [test]
build:
context: ./backend
args:
INCLUDE_DEV: "true"
env_file: .env
environment:
DATABASE_URL: postgresql+psycopg://wisegrade_test:wisegrade_test@test-db:5432/wisegrade_tests
TEST_DATABASE_URL: postgresql+psycopg://wisegrade_test:wisegrade_test@test-db:5432/wisegrade_tests
EMBEDDING_MODEL_PATH: /models/bge-m3
OCR_MODEL_DIR: /models/ocr
volumes:
- ./backend/tests:/app/tests:ro
- ./docs:/docs:ro
- ./models:/models:ro
depends_on:
test-db: {condition: service_healthy}
command: ["pytest", "-q"]
api:
build: ./backend
env_file: .env
+9 -2
View File
@@ -96,10 +96,17 @@ Python 依赖由 `backend/pyproject.toml` 管理:
cd backend
uv sync --extra ocr --group dev
uv run alembic upgrade head
uv run pytest
uv run uvicorn wisegrade.main:app --reload
```
测试从仓库根目录运行:
```bash
docker compose --profile test run --build --rm test
```
测试使用独立的 PostgreSQL 16 + pgvector 容器,不连接业务数据库。每个数据库测试创建临时数据库,执行 Alembic 迁移后运行,结束时删除;`test-db` 的数据目录为 tmpfs,不会写入业务数据卷。测试会从 `.env` 读取真实 DeepSeek API Key,并加载 `models/bge-m3` 和 `models/ocr` 中的离线模型;完整流程测试会发起真实模型请求。在宿主机直接运行 `uv run pytest` 时,需先设置指向专用 `wisegrade_tests` 数据库的 `TEST_DATABASE_URL`(使用 `postgresql+psycopg` 驱动,账号需有创建数据库的权限),并提供可用的模型配置。
本地开发需提供 PostgreSQL、Redis 和与容器路径一致的挂载目录;可用 Compose 启动 `db` 和 `redis`。Vite 前端:
```bash
@@ -108,7 +115,7 @@ npm install
npm run dev
```
开发服务器将 `/api` 代理到 `localhost:8000`。测试中的 DeepSeek 请求使用 mock,不会使用真实 API Key。集成测试从仓库 `docs/` 复制阶段报告 1、2、3 与最终报告,并生成一份汇报 PDF 测试材料。
开发服务器将 `/api` 代理到 `localhost:8000`。集成测试从仓库 `docs/` 复制阶段报告 1、2、3 与最终报告,并生成一份汇报 PDF 测试材料。
## 故障定位