feat: add admin dashboard, pipeline service, lightbox, and update dependencies

This commit is contained in:
2026-06-09 09:32:10 +08:00
parent 0d293422ac
commit 32978b3fc5
50 changed files with 4054 additions and 1618 deletions
+37 -51
View File
@@ -3,8 +3,7 @@
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from pathlib import Path
from datetime import date
from unittest.mock import AsyncMock, patch
import pytest
@@ -26,11 +25,27 @@ from app.services.pi_client import PiTimeoutError
from app.services.schemas import SummarySchema
from app.services.summarizer import (
_save_files,
_save_raw_output_only,
_update_summary_in_db,
summarize_batch,
summarize_one,
)
from app.utils import utc_now
# ── 共享 fixture ──────────────────────────────────────────────────────────
@pytest.fixture
def _summarize_tmp_paths(tmp_path):
"""将 data 目录重定向到 tmp_path(供 summarizer 测试使用)。"""
with (
patch("app.services.summarizer.paper_dir", lambda aid: tmp_path / "papers" / aid),
patch("app.services.pdf_downloader.PAPERS_DIR", tmp_path / "papers"),
patch("app.services.pdf_downloader.TMP_DIR", tmp_path / "tmp"),
patch("app.utils.PAPERS_DIR", tmp_path / "papers"),
patch("app.utils.TMP_DIR", tmp_path / "tmp"),
):
yield
# ═══════════════════════════════════════════════════════════════════════
@@ -130,7 +145,7 @@ class TestFileOperations:
def test_save_raw_output_only(self, tmp_path):
with patch("app.services.summarizer.paper_dir", lambda aid: tmp_path / aid):
_save_raw_output_only("2401.12345", "raw output")
_save_files("2401.12345", None, "raw output")
paper_dir = tmp_path / "2401.12345"
assert (paper_dir / "raw_output.txt").exists()
assert not (paper_dir / "summary.json").exists()
@@ -157,24 +172,9 @@ class TestFileOperations:
class TestSummarizeOneFlow:
"""summarize_one 的状态流转(mock pi 和 PDF)。"""
@pytest.fixture
def _patch_paths(self, tmp_path):
"""将 data 目录重定向到 tmp_path。"""
with (
patch(
"app.services.summarizer.paper_dir",
lambda aid: tmp_path / "papers" / aid,
),
patch("app.services.pdf_downloader.PAPERS_DIR", tmp_path / "papers"),
patch("app.services.pdf_downloader.TMP_DIR", tmp_path / "tmp"),
patch("app.utils.PAPERS_DIR", tmp_path / "papers"),
patch("app.utils.TMP_DIR", tmp_path / "tmp"),
):
yield
@pytest.mark.asyncio
async def test_full_success_path(
self, db_session, sample_paper, mock_pi_output, _patch_paths
self, db_session, sample_paper, mock_pi_output, _summarize_tmp_paths
):
"""pending → processing → done 全流程。"""
with (
@@ -209,7 +209,7 @@ class TestSummarizeOneFlow:
assert fts_row[0] == "测试论文中文标题"
@pytest.mark.asyncio
async def test_pdf_download_failure(self, db_session, sample_paper, _patch_paths):
async def test_pdf_download_failure(self, db_session, sample_paper, _summarize_tmp_paths):
"""PDF 下载失败 → error_type=pdf_download_failedtmp 被清理。"""
with (
patch(
@@ -228,7 +228,7 @@ class TestSummarizeOneFlow:
assert status.error_type == "pdf_download_failed"
@pytest.mark.asyncio
async def test_pi_timeout(self, db_session, sample_paper, _patch_paths):
async def test_pi_timeout(self, db_session, sample_paper, _summarize_tmp_paths):
"""pi 超时 → timeout 错误,retry_count 递增。"""
with (
patch("app.services.summarizer.download_pdf", new_callable=AsyncMock),
@@ -245,7 +245,7 @@ class TestSummarizeOneFlow:
assert result["retry_count"] == 1
@pytest.mark.asyncio
async def test_json_not_found(self, db_session, sample_paper, _patch_paths):
async def test_json_not_found(self, db_session, sample_paper, _summarize_tmp_paths):
"""pi 输出无 JSON → 验证循环重试 4 次后 ValueError (unknown)。"""
with (
patch("app.services.summarizer.download_pdf", new_callable=AsyncMock),
@@ -262,7 +262,7 @@ class TestSummarizeOneFlow:
@pytest.mark.asyncio
async def test_validation_fails_and_retries(
self, db_session, sample_paper, _patch_paths
self, db_session, sample_paper, _summarize_tmp_paths
):
"""验证失败(字段不符合要求)→ 重试多次后失败。"""
bad_json = json.dumps(
@@ -294,7 +294,7 @@ class TestSummarizeOneFlow:
@pytest.mark.asyncio
async def test_raw_output_saved_on_failure(
self, db_session, sample_paper, tmp_path, _patch_paths
self, db_session, sample_paper, tmp_path, _summarize_tmp_paths
):
"""失败时仍保存 raw_output.txt。"""
with (
@@ -313,7 +313,7 @@ class TestSummarizeOneFlow:
@pytest.mark.asyncio
async def test_tmp_cleaned_on_success(
self, db_session, sample_paper, mock_pi_output, tmp_path, _patch_paths
self, db_session, sample_paper, mock_pi_output, tmp_path, _summarize_tmp_paths
):
"""成功后清理 tmp 目录。"""
with (
@@ -331,7 +331,7 @@ class TestSummarizeOneFlow:
@pytest.mark.asyncio
async def test_tmp_cleaned_on_failure(
self, db_session, sample_paper, tmp_path, _patch_paths
self, db_session, sample_paper, tmp_path, _summarize_tmp_paths
):
"""失败后也清理 tmp 目录。"""
with (
@@ -347,7 +347,7 @@ class TestSummarizeOneFlow:
assert not tmp_paper.exists()
@pytest.mark.asyncio
async def test_skips_done_paper(self, db_session, sample_paper, _patch_paths):
async def test_skips_done_paper(self, db_session, sample_paper, _summarize_tmp_paths):
"""已完成的论文跳过。"""
sample_paper.summary_status.status = "done"
db_session.commit()
@@ -364,26 +364,12 @@ class TestSummarizeOneFlow:
class TestBatchSummarize:
"""批量总结测试。"""
@pytest.fixture
def _patch_paths(self, tmp_path):
with (
patch(
"app.services.summarizer.paper_dir",
lambda aid: tmp_path / "papers" / aid,
),
patch("app.services.pdf_downloader.PAPERS_DIR", tmp_path / "papers"),
patch("app.services.pdf_downloader.TMP_DIR", tmp_path / "tmp"),
patch("app.utils.PAPERS_DIR", tmp_path / "papers"),
patch("app.utils.TMP_DIR", tmp_path / "tmp"),
):
yield
@pytest.mark.asyncio
async def test_batch_multiple_papers(
self, db_session, db_engine, mock_pi_output, _patch_paths
self, db_session, db_engine, mock_pi_output, _summarize_tmp_paths
):
"""批量处理多篇论文。"""
now = datetime.now(timezone.utc)
now = utc_now()
for i in range(3):
p = Paper(
arxiv_id=f"2401.1234{i}",
@@ -426,10 +412,10 @@ class TestBatchSummarize:
@pytest.mark.asyncio
async def test_single_failure_no_block(
self, db_session, db_engine, mock_pi_output, _patch_paths
self, db_session, db_engine, mock_pi_output, _summarize_tmp_paths
):
"""一篇失败不阻塞其他。"""
now = datetime.now(timezone.utc)
now = utc_now()
for i in range(2):
p = Paper(
arxiv_id=f"2401.5678{i}",
@@ -451,7 +437,7 @@ class TestBatchSummarize:
call_count = 0
async def _mock_call_pi(meta_path, pdf_path):
async def _mock_call_pi(meta_path, pdf_path, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -468,7 +454,7 @@ class TestBatchSummarize:
assert result["failed"] == 1
@pytest.mark.asyncio
async def test_task_lock_conflict(self, db_session, _patch_paths):
async def test_task_lock_conflict(self, db_session, _summarize_tmp_paths):
"""TaskLock 防止并发 batch。"""
# 先插入一个 running 锁
db_session.add(
@@ -476,7 +462,7 @@ class TestBatchSummarize:
task="summarize",
lock_key="batch",
status="running",
acquired_at=datetime.now(timezone.utc),
acquired_at=utc_now(),
)
)
db_session.commit()
@@ -486,7 +472,7 @@ class TestBatchSummarize:
@pytest.mark.asyncio
async def test_task_lock_released(
self, db_session, db_engine, mock_pi_output, _patch_paths
self, db_session, db_engine, mock_pi_output, _summarize_tmp_paths
):
"""完成后释放 TaskLock。"""
from sqlalchemy.orm import sessionmaker as _sm
@@ -516,7 +502,7 @@ class TestBatchSummarize:
assert lock.released_at is not None
@pytest.mark.asyncio
async def test_batch_empty(self, db_session, _patch_paths):
async def test_batch_empty(self, db_session, _summarize_tmp_paths):
"""无 pending 论文时返回空结果。"""
result = await summarize_batch(db_session)
assert result["status"] == "success"