test: use PostgreSQL and live model integrations
This commit is contained in:
@@ -52,6 +52,7 @@ uv run pytest -q
|
|||||||
## 已验证与尚未验证
|
## 已验证与尚未验证
|
||||||
|
|
||||||
- 部署镜像内完整后端测试:**19 通过、0 跳过**,包括 DOCX/PPTX 转换与页面图像测试。宿主机运行结果为 18 通过、1 跳过,因为宿主机未安装 LibreOffice;部署容器内为 25.2.3.2。另已用课程 DOCX 和验收 PPTX 实际验证页数、文字及页面图片。
|
- 部署镜像内完整后端测试:**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` 也已验证。
|
- 前端生产构建通过;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` 并重新启动。接手后应观察是否反复超时。
|
||||||
- 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。
|
- 使用 `docs/` 的四份示例报告及生成的汇报 PPTX,经目录扫描、确认、课程资料入库、真实 BGE-M3/pgvector 检索和真实 DeepSeek API 完成批次 `3`:**22/22 项成功**。首轮有 1 项引文不匹配,重试后成功;保留了成功项。项目索引重建后有 316 段、平均 506 字,向量均为 1024 维;77 条评分证据均能匹配对应文档页的原文和页面图片。
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ WORKDIR /app
|
|||||||
COPY --from=ghcr.io/astral-sh/uv:0.11.0 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.11.0 /uv /uvx /bin/
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN uv sync --frozen --no-dev --extra ocr
|
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 . .
|
COPY . .
|
||||||
ENV PATH=/app/.venv/bin:$PATH PYTHONPATH=/app
|
ENV PATH=/app/.venv/bin:$PATH PYTHONPATH=/app
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
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 sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from wisegrade.config import get_settings
|
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.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
|
@pytest.fixture
|
||||||
@@ -24,14 +39,35 @@ def environment(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def session_factory(environment):
|
def session_factory(environment, monkeypatch, test_database_url):
|
||||||
engine = engine_for(f"sqlite:///{environment / 'test.db'}")
|
admin_engine = create_engine(test_database_url, isolation_level="AUTOCOMMIT")
|
||||||
Base.metadata.create_all(engine)
|
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)
|
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
with factory() as db:
|
with factory() as db:
|
||||||
seed_initial(db)
|
seed_initial(db)
|
||||||
yield factory
|
yield factory
|
||||||
|
finally:
|
||||||
|
if engine is not None:
|
||||||
engine.dispose()
|
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
|
@pytest.fixture
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def test_missing_material_is_explicit_and_reviewable(session_factory):
|
|||||||
client = client_for(session_factory)
|
client = client_for(session_factory)
|
||||||
try:
|
try:
|
||||||
batch_id = client.post("/api/scoring-batches", json={"course_version_id": 1, "rubric_version_id": 1}).json()["id"]
|
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()
|
status = client.get(f"/api/scoring-batches/{batch_id}/progress").json()
|
||||||
assert status["status"] == "partial" and status["progress_done"] == status["progress_total"] == 22
|
assert status["status"] == "partial" and status["progress_done"] == status["progress_total"] == 22
|
||||||
result = client.get(f"/api/scoring-batches/{batch_id}/scores").json()[0]
|
result = client.get(f"/api/scoring-batches/{batch_id}/scores").json()[0]
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from wisegrade.models import CourseObjective, CourseVersion, GraduationIndicator, RubricCriterion, RubricScoreLevel, RubricVersion, TopicDirection
|
from wisegrade.models import CourseObjective, CourseVersion, GraduationIndicator, RubricCriterion, RubricScoreLevel, RubricVersion, TopicDirection
|
||||||
from wisegrade.rules import RubricData, totals
|
from wisegrade.rules import RubricData, totals
|
||||||
from wisegrade.storage import classify, extract_identity, parse_student_folder, secure_path, sha256
|
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):
|
def test_seeded_course_and_weights(db):
|
||||||
version = db.scalar(select(CourseVersion))
|
version = db.scalar(select(CourseVersion))
|
||||||
rubric = db.scalar(select(RubricVersion))
|
rubric = db.scalar(select(RubricVersion))
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from wisegrade.llm import DeepSeekClient, ModelError, ScoreResult, validate_score
|
from wisegrade.llm import DeepSeekClient, FactResult, ModelError, ScoreResult, validate_score
|
||||||
from wisegrade.models import AppSetting, KnowledgeChunk
|
from wisegrade.models import AppSetting, KnowledgeChunk, Project, Student
|
||||||
from wisegrade.rag import ingest, local_encoder, retrieve, split_pages
|
from wisegrade.rag import ingest, local_encoder, retrieve, split_pages
|
||||||
from wisegrade.settings_store import model_config, save_model_config
|
from wisegrade.settings_store import model_config, save_model_config
|
||||||
|
|
||||||
|
|
||||||
def fake_embed(texts):
|
def make_project(db, number):
|
||||||
return [[1.0] + [0.0] * 1023 for _ in texts]
|
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():
|
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"]
|
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 = tmp_path / "page.png"
|
||||||
image.write_bytes(b"png fixture")
|
picture = Image.new("RGB", (320, 100), "white")
|
||||||
def handler(request):
|
ImageDraw.Draw(picture).text((20, 30), "TEST 123", fill="black")
|
||||||
assert request.url.path == "/chat/completions"
|
picture.save(image)
|
||||||
assert request.headers["Authorization"] == "Bearer fake-secret"
|
result = DeepSeekClient().json_call(
|
||||||
content = json.loads(request.content)["messages"][1]["content"]
|
"从用户提供的JSON中复制topic字段,原样返回到JSON对象的topic字段。",
|
||||||
assert content[1]["type"] == "image_url" and content[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
{"topic": "真实模型连通测试"}, FactResult, images=[str(image)])
|
||||||
return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"criterion_id": "x", "score": 0,
|
assert result.topic == "真实模型连通测试"
|
||||||
"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)
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_isolation_and_chunk_metadata(db):
|
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": "机器人实验数据和分析"}]
|
pages = [{"page_number": 3, "section": "实验", "text": "机器人实验数据和分析"}]
|
||||||
ingest(db, course_version_id=1, project_id=101, submission_file_id=None, title="学生A", category="stage3",
|
ingest(db, course_version_id=1, project_id=project_a, submission_file_id=None, title="学生A", category="stage3",
|
||||||
source_hash="a", pages=pages, embedder=fake_embed)
|
source_hash="a", pages=pages)
|
||||||
ingest(db, course_version_id=1, project_id=102, submission_file_id=None, title="学生B", category="stage3",
|
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": "另一个学生的秘密"}], embedder=fake_embed)
|
source_hash="b", pages=[{**pages[0], "text": "另一个学生的秘密"}])
|
||||||
hits = retrieve(db, course_version_id=1, project_id=101, category="stage3", query="实验数据", embedder=fake_embed)
|
hits = retrieve(db, course_version_id=1, project_id=project_a, category="stage3", query="实验数据")
|
||||||
assert hits and all(hit["project_id"] == 101 and hit["page"] == 3 for hit in hits)
|
assert hits and all(hit["project_id"] == project_a and hit["page"] == 3 for hit in hits)
|
||||||
assert "秘密" not in str(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):
|
def test_stale_knowledge_index_reuses_document_id(db):
|
||||||
|
project_id = make_project(db, 1)
|
||||||
pages = [{"page_number": 1, "text": "实验方案与测量结果"}]
|
pages = [{"page_number": 1, "text": "实验方案与测量结果"}]
|
||||||
document = ingest(db, course_version_id=1, project_id=101, submission_file_id=None,
|
document = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None,
|
||||||
title="报告", category="stage3", source_hash="same-source", pages=pages, embedder=fake_embed)
|
title="报告", category="stage3", source_hash="same-source", pages=pages)
|
||||||
old_chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
|
old_chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
|
||||||
old_chunk.text = "过时片段"
|
old_chunk.text = "过时片段"
|
||||||
document.index_version = 1
|
document.index_version = 1
|
||||||
db.commit()
|
db.commit()
|
||||||
reindexed = ingest(db, course_version_id=1, project_id=101, submission_file_id=None,
|
reindexed = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None,
|
||||||
title="报告", category="stage3", source_hash="same-source", pages=pages, embedder=fake_embed)
|
title="报告", category="stage3", source_hash="same-source", pages=pages)
|
||||||
assert reindexed.id == document.id and reindexed.index_version == 2
|
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"]
|
assert db.scalar(select(KnowledgeChunk.text).where(KnowledgeChunk.document_id == document.id)) == pages[0]["text"]
|
||||||
|
|
||||||
|
|||||||
@@ -4,37 +4,13 @@ from pathlib import Path
|
|||||||
import pymupdf as fitz
|
import pymupdf as fitz
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from wisegrade.config import get_settings
|
|
||||||
from wisegrade.db import get_db
|
from wisegrade.db import get_db
|
||||||
from wisegrade.llm import ConsistencyResult, FactResult, ScoreResult
|
|
||||||
from wisegrade.main import app
|
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.scoring import run_batch
|
||||||
from wisegrade.storage import scan
|
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):
|
def make_pdf(path, title):
|
||||||
pdf = fitz.open()
|
pdf = fitz.open()
|
||||||
page = pdf.new_page()
|
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})
|
response = client.post("/api/scoring-batches", json={"course_version_id": 1, "rubric_version_id": 1})
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
batch_id = response.json()["id"]
|
batch_id = response.json()["id"]
|
||||||
run_batch(batch_id, session_factory=session_factory, client_factory=lambda db, batch: FakeModel(), embedder=fake_embed,
|
run_batch(batch_id, session_factory=session_factory)
|
||||||
retriever=lambda db, **kwargs: retrieve(db, embedder=fake_embed, **kwargs))
|
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()
|
result = client.get(f"/api/projects/{project_id}/scores?batch_id={batch_id}").json()
|
||||||
assert result["totals"]["overall"] == 50
|
assert result["totals"]["overall"] is not None
|
||||||
assert all(row["evidence"] and row["evidence"][0]["page"] >= 1 for row in result["rows"])
|
assert all(row["status"] == "scored" and
|
||||||
assert client.get(result["rows"][0]["evidence"][0]["image_url"]).content[:8] == b"\x89PNG\r\n\x1a\n"
|
(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"]
|
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.status_code == 200
|
||||||
assert reviewed.json()["model_score"] != reviewed.json()["teacher_score"]
|
assert reviewed.json()["model_score"] != reviewed.json()["teacher_score"]
|
||||||
assert len(client.get(f"/api/criterion-scores/{score_id}/revisions").json()) == 1
|
assert len(client.get(f"/api/criterion-scores/{score_id}/revisions").json()) == 1
|
||||||
|
|||||||
@@ -13,6 +13,38 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
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:
|
api:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
|||||||
+9
-2
@@ -96,10 +96,17 @@ Python 依赖由 `backend/pyproject.toml` 管理:
|
|||||||
cd backend
|
cd backend
|
||||||
uv sync --extra ocr --group dev
|
uv sync --extra ocr --group dev
|
||||||
uv run alembic upgrade head
|
uv run alembic upgrade head
|
||||||
uv run pytest
|
|
||||||
uv run uvicorn wisegrade.main:app --reload
|
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 前端:
|
本地开发需提供 PostgreSQL、Redis 和与容器路径一致的挂载目录;可用 Compose 启动 `db` 和 `redis`。Vite 前端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -108,7 +115,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
开发服务器将 `/api` 代理到 `localhost:8000`。测试中的 DeepSeek 请求使用 mock,不会使用真实 API Key。集成测试从仓库 `docs/` 复制阶段报告 1、2、3 与最终报告,并生成一份汇报 PDF 测试材料。
|
开发服务器将 `/api` 代理到 `localhost:8000`。集成测试从仓库 `docs/` 复制阶段报告 1、2、3 与最终报告,并生成一份汇报 PDF 测试材料。
|
||||||
|
|
||||||
## 故障定位
|
## 故障定位
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user