diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..803dbf28
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,66 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ backend:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v10.0.1
+ with:
+ version: "0.12.14"
+ python-version: "3.12"
+ enable-cache: true
+ working-directory: backend
+
+ # 跳过 parsers 组(unstructured + torch/CUDA,数 GB):测试不需要它
+ - name: Install dependencies
+ run: uv sync --frozen --extra dev --no-group parsers
+
+ # 必须 --no-sync:否则 uv run 会重新同步默认组,把 parsers 装回来
+ - name: Lint
+ run: uv run --no-sync ruff check .
+
+ - name: Test
+ run: uv run --no-sync pytest -q
+
+ - name: Assert heavy deps excluded
+ run: uv run --no-sync python -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('torch') is None and importlib.util.find_spec('unstructured') is None else 1)"
+
+ frontend:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: front/package-lock.json
+
+ - name: Install dependencies
+ working-directory: front
+ run: npm ci
+
+ - name: Lint
+ working-directory: front
+ run: npm run lint
+
+ - name: Build
+ working-directory: front
+ run: npm run build
diff --git a/README.md b/README.md
index 32eee131..507c6553 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,9 @@
+
+
+
@@ -148,9 +151,13 @@ cd RAGNotebook
##### 后端依赖
```bash
cd backend
-uv sync
+uv sync --extra dev
```
+> 说明:文档解析依赖(`unstructured` + torch,数 GB)放在 `parsers`
+> 依赖组中,**默认随 `uv sync` 一起安装**,本地/Docker 行为不变;
+> CI 用 `uv sync --extra dev --no-group parsers` 跳过它以保持快速。
+
##### 前端依赖
```bash
cd front
diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py
index 4bc32e65..bd728319 100644
--- a/backend/app/agent/agent.py
+++ b/backend/app/agent/agent.py
@@ -10,25 +10,24 @@
from langchain_core.tools import BaseTool
from langgraph.types import Command
-from app.core.settings import settings
-
+from app.agent.agent_middleware import DEFAULT_APPROVAL_TOOLS
+from app.agent.agent_rag_tool import init_rag_guard, search_rag
from app.agent.agent_tools import (
create_note_tool,
get_note_stats_tool,
get_related_notes_tool,
+ get_thinking_callback_from_context,
get_today_reviews_tool,
get_user_info_tools,
- get_thinking_callback_from_context,
mark_reviewed_tool,
search_notes_tool,
set_current_user_id,
set_thinking_callback,
what_time_is_now,
)
-from app.agent.agent_middleware import DEFAULT_APPROVAL_TOOLS, get_middleware
-from app.agent.agent_rag_tool import init_rag_guard, search_rag
from app.agent.checkpoint.mysql_saver import MySQLCheckpointSaver
from app.core.logger_handler import logger
+from app.core.settings import settings
from app.services import session_manager as sm
from app.utils.prompt_loader import load_prompt
@@ -669,7 +668,6 @@ async def run_agent():
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
return
- response = agent_result_holder.get("response") or "抱歉,我无法理解您的请求。"
yield f"data: {json.dumps({'type': 'done', 'session_id': session_id}, ensure_ascii=False)}\n\n"
except Exception as e:
logger.error(f"【Agent流式响应】处理请求失败: {e}", exc_info=True)
diff --git a/backend/app/agent/agent_middleware.py b/backend/app/agent/agent_middleware.py
index dc7d04b8..6cd92c2a 100644
--- a/backend/app/agent/agent_middleware.py
+++ b/backend/app/agent/agent_middleware.py
@@ -69,6 +69,7 @@ def decorator(func):
from app.agent.agent_tools import get_thinking_callback_from_context
from app.core.logger_handler import logger
+
def _describe_create_note(tool_call, state, runtime) -> str:
"""创建笔记审批摘要:只带标题、正文长度与开头预览,避免把全文塞进审批负载。"""
args = (tool_call.get("args") if isinstance(tool_call, dict) else {}) or {}
@@ -218,4 +219,4 @@ def get_middleware(approval_tools: dict | None = None):
)
else:
logger.warning("HumanInTheLoopMiddleware 不可用,审批中间件已禁用(降级模式)。")
- return middleware
\ No newline at end of file
+ return middleware
diff --git a/backend/app/cache/redis_decorator.py b/backend/app/cache/redis_decorator.py
index ab048953..268ee9de 100644
--- a/backend/app/cache/redis_decorator.py
+++ b/backend/app/cache/redis_decorator.py
@@ -164,4 +164,4 @@ async def wrapper(*args, **kwargs):
# 使用RedisCache获取或设置缓存
return await RedisCache.get_or_set(key, func, *args, expire=expire, **kwargs)
return wrapper
- return decorator
\ No newline at end of file
+ return decorator
diff --git a/backend/app/core/failed_response.py b/backend/app/core/failed_response.py
index c23aecf9..e4582fd3 100644
--- a/backend/app/core/failed_response.py
+++ b/backend/app/core/failed_response.py
@@ -9,7 +9,7 @@
from starlette import status
# 项目配置唯一读取点在 app/core/settings.py,此处 re-export 兼容既有导入路径
-from app.core.settings import Settings, settings
+from app.core.settings import settings
# 生产环境强制关闭 DEBUG_MODE,双保险防止泄露
DEBUG_MODE = settings.DEBUG_MODE if settings.ENV != "prod" else False
diff --git a/backend/app/db/db_config.py b/backend/app/db/db_config.py
index 49e58ca7..9de9f656 100644
--- a/backend/app/db/db_config.py
+++ b/backend/app/db/db_config.py
@@ -74,8 +74,7 @@ async def init_db():
# 确保所有 Model 已导入,注册到 Base.metadata
from app.models import (
agent_checkpoint, # noqa: F401
- chat_history, graph, note, note_template, review_record, user_model,
- )
+ )
async with async_engine.begin() as conn:
# 先删除旧表,然后创建新表
@@ -101,9 +100,10 @@ async def get_db():
async def seed_test_user():
+ from sqlalchemy import select
+
from app.models.user_model import User, UserStatusChoice
from app.utils.auth_utils import hash_password
- from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).where(User.username == "admin"))
diff --git a/backend/app/graph/extraction/__init__.py b/backend/app/graph/extraction/__init__.py
index dea5af1a..7ffb1c0a 100644
--- a/backend/app/graph/extraction/__init__.py
+++ b/backend/app/graph/extraction/__init__.py
@@ -1 +1 @@
-"""图谱抽取子包:双链解析 + LLM 实体抽取。"""
\ No newline at end of file
+"""图谱抽取子包:双链解析 + LLM 实体抽取。"""
diff --git a/backend/app/graph/extraction/entity_extractor.py b/backend/app/graph/extraction/entity_extractor.py
index 230945f1..7fa2a606 100644
--- a/backend/app/graph/extraction/entity_extractor.py
+++ b/backend/app/graph/extraction/entity_extractor.py
@@ -3,7 +3,7 @@
import re
from app.core.logger_handler import logger
-from app.graph.schemas.graph import ExtractResult, ExtractedEntity, ExtractedRelation
+from app.graph.schemas.graph import ExtractedEntity, ExtractedRelation, ExtractResult
from app.utils.prompt_loader import load_prompt
@@ -62,4 +62,4 @@ async def extract_entities(title: str, content: str, chat_model) -> ExtractResul
except Exception as e2:
logger.error(f"实体抽取失败 title={title}: {e2}")
result = _fallback_extract(content or "", "")
- return result
\ No newline at end of file
+ return result
diff --git a/backend/app/graph/extraction/link_parser.py b/backend/app/graph/extraction/link_parser.py
index 573e42fc..49138887 100644
--- a/backend/app/graph/extraction/link_parser.py
+++ b/backend/app/graph/extraction/link_parser.py
@@ -13,4 +13,4 @@ def parse_links(content: str) -> list[str]:
if target and target not in seen:
seen.add(target)
result.append(target)
- return result
\ No newline at end of file
+ return result
diff --git a/backend/app/graph/routers/__init__.py b/backend/app/graph/routers/__init__.py
index ce80806d..12a8e459 100644
--- a/backend/app/graph/routers/__init__.py
+++ b/backend/app/graph/routers/__init__.py
@@ -1 +1 @@
-"""图谱 API 路由子包。"""
\ No newline at end of file
+"""图谱 API 路由子包。"""
diff --git a/backend/app/graph/routers/graph_router.py b/backend/app/graph/routers/graph_router.py
index 71097fd5..50541502 100644
--- a/backend/app/graph/routers/graph_router.py
+++ b/backend/app/graph/routers/graph_router.py
@@ -3,28 +3,24 @@
import functools
import json
-from fastapi import Depends, HTTPException
+from fastapi import Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from fastapi.routing import APIRouter
-
-from app.core.logger_handler import logger
-from app.graph.services.event_bus import event_bus
-from app.graph.storage.neo4j_client import GraphUnavailableError
-from app.utils.auth_utils import get_current_user_id
-
-graph_router = APIRouter(prefix="/api/graph", tags=["graph"])
-
-
-from fastapi import Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.core.logger_handler import logger
from app.core.success_response import success_response
from app.db.db_config import get_db
from app.graph.schemas.graph import EntityIn, MergeRequest, RelationIn, TypeIn
+from app.graph.services.event_bus import event_bus
from app.graph.services.graph_service import manual_re_extract
from app.graph.storage import get_graph_store
+from app.graph.storage.neo4j_client import GraphUnavailableError
from app.models.note import Note
+from app.utils.auth_utils import get_current_user_id
+
+graph_router = APIRouter(prefix="/api/graph", tags=["graph"])
def _graph_gate(fn):
@@ -83,7 +79,7 @@ async def neighbors(entity_id: str, depth: int = Query(1, ge=1, le=3),
async def entity_notes(entity_id: str, user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db)):
links = await get_graph_store(db).get_entity_notes(user_id, entity_id)
- return success_response(data=[l.model_dump() for l in links])
+ return success_response(data=[link.model_dump() for link in links])
@graph_router.get("/notes/{note_id}/related")
@@ -274,4 +270,4 @@ async def gen():
finally:
await event_bus.unsubscribe(user_id, q)
- return StreamingResponse(gen(), media_type="text/event-stream")
\ No newline at end of file
+ return StreamingResponse(gen(), media_type="text/event-stream")
diff --git a/backend/app/graph/services/__init__.py b/backend/app/graph/services/__init__.py
index 3c667053..807b6a96 100644
--- a/backend/app/graph/services/__init__.py
+++ b/backend/app/graph/services/__init__.py
@@ -1 +1 @@
-"""图谱服务子包。"""
\ No newline at end of file
+"""图谱服务子包。"""
diff --git a/backend/app/graph/services/event_bus.py b/backend/app/graph/services/event_bus.py
index 510366da..2585f54b 100644
--- a/backend/app/graph/services/event_bus.py
+++ b/backend/app/graph/services/event_bus.py
@@ -34,4 +34,4 @@ async def publish(self, user_id: str, event: dict) -> None:
q.put_nowait(event)
-event_bus = GraphEventBus()
\ No newline at end of file
+event_bus = GraphEventBus()
diff --git a/backend/app/graph/storage/graph_store.py b/backend/app/graph/storage/graph_store.py
index 2cefb7f4..4b5de651 100644
--- a/backend/app/graph/storage/graph_store.py
+++ b/backend/app/graph/storage/graph_store.py
@@ -151,4 +151,4 @@ async def sweep_orphan_entities(self, user_id: str, candidate_entity_ids: list[s
@abstractmethod
async def clear_all_docs(self, user_id: str) -> None:
- """清空用户全部文档图谱(Doc 节点/doc Chunk/doc 溯源关系),并清扫孤儿实体。"""
\ No newline at end of file
+ """清空用户全部文档图谱(Doc 节点/doc Chunk/doc 溯源关系),并清扫孤儿实体。"""
diff --git a/backend/app/rag/agentic_rag/evidence.py b/backend/app/rag/agentic_rag/evidence.py
index c6c0efaf..b31d17f1 100644
--- a/backend/app/rag/agentic_rag/evidence.py
+++ b/backend/app/rag/agentic_rag/evidence.py
@@ -2,7 +2,6 @@
from app.rag.agentic_rag.schemas import Evidence
-
_SOURCE_LABELS = {
"note": "笔记",
"knowledge_base": "知识库",
diff --git a/backend/app/rag/agentic_rag/planner.py b/backend/app/rag/agentic_rag/planner.py
index d5533e23..20588247 100644
--- a/backend/app/rag/agentic_rag/planner.py
+++ b/backend/app/rag/agentic_rag/planner.py
@@ -3,13 +3,10 @@
import re
from typing import Any
-from pydantic import ValidationError
-
from app.core.logger_handler import logger
from app.core.settings import settings
from app.rag.agentic_rag.schemas import RetrievalPlan, RetrievalStep
-
FRESHNESS_TERMS = (
"最新",
"现在",
diff --git a/backend/app/rag/agentic_rag/query_entity_extractor.py b/backend/app/rag/agentic_rag/query_entity_extractor.py
index 4ed39c0f..827bc147 100644
--- a/backend/app/rag/agentic_rag/query_entity_extractor.py
+++ b/backend/app/rag/agentic_rag/query_entity_extractor.py
@@ -87,7 +87,6 @@ async def extract(self, query: str) -> list[str]:
t0 = time.perf_counter()
used_llm = True
try:
- from langchain_core.messages import HumanMessage
prompt = self.prompt_template.replace("{query}", query)
response = await model.ainvoke(prompt)
content = getattr(response, "content", response)
diff --git a/backend/app/rag/agentic_rag/schemas.py b/backend/app/rag/agentic_rag/schemas.py
index 18edc2d2..c96b2e9e 100644
--- a/backend/app/rag/agentic_rag/schemas.py
+++ b/backend/app/rag/agentic_rag/schemas.py
@@ -2,7 +2,6 @@
from pydantic import BaseModel, Field
-
EvidenceSource = Literal["note", "knowledge_base", "graph", "web"]
RetrievalTool = Literal[
"search_notes",
diff --git a/backend/app/rag/agentic_rag/web_search.py b/backend/app/rag/agentic_rag/web_search.py
index 7ec2eedd..e81bfb82 100644
--- a/backend/app/rag/agentic_rag/web_search.py
+++ b/backend/app/rag/agentic_rag/web_search.py
@@ -7,7 +7,6 @@
from app.core.settings import settings
from app.rag.agentic_rag.schemas import Evidence
-
HttpClientFactory = Callable[[], httpx.AsyncClient]
diff --git a/backend/app/rag/vector_store.py b/backend/app/rag/vector_store.py
index f94530a4..e18325fa 100644
--- a/backend/app/rag/vector_store.py
+++ b/backend/app/rag/vector_store.py
@@ -362,4 +362,4 @@ async def main():
docs = await store.get_user_documents()
logger.debug(f"知识库文档数量: {len(docs)}")
- asyncio.run(main())
\ No newline at end of file
+ asyncio.run(main())
diff --git a/backend/app/router/chat.py b/backend/app/router/chat.py
index 71f71293..9c231803 100644
--- a/backend/app/router/chat.py
+++ b/backend/app/router/chat.py
@@ -20,7 +20,6 @@
read_pending_interrupt,
)
from app.agent.agent_rag_tool import build_pre_searched_queries
-from app.agent.checkpoint.mysql_saver import MySQLCheckpointSaver
from app.core.logger_handler import logger
from app.core.rate_limit import rate_limit
from app.core.success_response import success_response
diff --git a/backend/app/router/chat_service.py b/backend/app/router/chat_service.py
index bfcbd030..2cb42959 100644
--- a/backend/app/router/chat_service.py
+++ b/backend/app/router/chat_service.py
@@ -1,5 +1,4 @@
"""Chat 业务服务层:会话管理、Agent 问答、重排序(旧 RagService 链路已退役,检索统一走 Agentic RAG)。"""
-import uuid
from typing import Any
from fastapi import HTTPException
diff --git a/backend/app/router/note_router.py b/backend/app/router/note_router.py
index 55fedef9..841fd4cb 100644
--- a/backend/app/router/note_router.py
+++ b/backend/app/router/note_router.py
@@ -106,8 +106,8 @@ async def batch_download_notes(
"""
批量下载笔记为 ZIP 压缩包(内含 .md 文件)。
"""
- from urllib.parse import quote
from datetime import datetime
+ from urllib.parse import quote
zip_bytes = await init_manager.note_service.batch_export_zip(db, user_id, payload.ids)
date_str = datetime.now().strftime("%Y%m%d")
diff --git a/backend/app/services/note_template_service.py b/backend/app/services/note_template_service.py
index 8df11ced..579cf94b 100644
--- a/backend/app/services/note_template_service.py
+++ b/backend/app/services/note_template_service.py
@@ -2,10 +2,10 @@
笔记模板服务 —— CRUD + 内置模板初始化。
"""
-import uuid
import logging
+import uuid
-from sqlalchemy import select, func
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.note_template import NoteTemplate
@@ -56,7 +56,8 @@
"icon": "ListTodo",
"category": "project",
"title": "",
- "content": "## 项目概述\n\n\n## 目标\n- [ ] \n\n## 里程碑\n| 阶段 | 内容 | 截止日期 | 状态 |\n|------|------|----------|------|\n| 1 | | | 待开始 |\n\n## 备注\n\n",
+ "content": "## 项目概述\n\n\n## 目标\n- [ ] \n\n## 里程碑\n| 阶段 | 内容 | 截止日期 | 状态 |\n"
+ "|------|------|----------|------|\n| 1 | | | 待开始 |\n\n## 备注\n\n",
"tags": ["项目"],
},
{
diff --git a/backend/app/utils/auth_utils.py b/backend/app/utils/auth_utils.py
index 2779db7a..4bd1a9e5 100644
--- a/backend/app/utils/auth_utils.py
+++ b/backend/app/utils/auth_utils.py
@@ -3,7 +3,6 @@
import uuid
from typing import Any
-from dotenv import load_dotenv
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
diff --git a/backend/app/utils/file_handler.py b/backend/app/utils/file_handler.py
index 3b5635e4..1ba5ceab 100644
--- a/backend/app/utils/file_handler.py
+++ b/backend/app/utils/file_handler.py
@@ -153,6 +153,9 @@ async def markdown_loader(file_path: str) -> list[Document]:
try:
loader = UnstructuredMarkdownLoader(abs_file_path, mode="single")
return await asyncio.to_thread(loader.load)
+ except ImportError:
+ logger.error("【Markdown文件加载】unstructured 未安装(缺少 parsers 依赖组),已跳过;请执行 uv sync 安装")
+ return []
except Exception as e:
logger.error(f"【Markdown文件加载】加载文件 {abs_file_path} 时出错: {e}")
return []
@@ -168,6 +171,9 @@ async def ppt_loader(file_path: str) -> list[Document]:
try:
loader = UnstructuredPowerPointLoader(abs_file_path, mode="single")
return await asyncio.to_thread(loader.load)
+ except ImportError:
+ logger.error("【PPT文件加载】unstructured 未安装(缺少 parsers 依赖组),已跳过;请执行 uv sync 安装")
+ return []
except Exception as e:
logger.error(f"【PPT文件加载】加载文件 {abs_file_path} 时出错: {e}")
return []
@@ -267,6 +273,9 @@ def markdown_loader_sync(file_path: str) -> list[Document]:
try:
loader = UnstructuredMarkdownLoader(abs_file_path, mode="single")
return loader.load()
+ except ImportError:
+ logger.error("【Markdown文件加载】unstructured 未安装(缺少 parsers 依赖组),已跳过;请执行 uv sync 安装")
+ return []
except Exception as e:
logger.error(f"【Markdown文件加载】加载文件 {abs_file_path} 时出错: {e}")
return []
@@ -282,6 +291,9 @@ def ppt_loader_sync(file_path: str) -> list[Document]:
try:
loader = UnstructuredPowerPointLoader(abs_file_path, mode="single")
return loader.load()
+ except ImportError:
+ logger.error("【PPT文件加载】unstructured 未安装(缺少 parsers 依赖组),已跳过;请执行 uv sync 安装")
+ return []
except Exception as e:
logger.error(f"【PPT文件加载】加载文件 {abs_file_path} 时出错: {e}")
return []
diff --git a/backend/app/utils/input_sanitizer.py b/backend/app/utils/input_sanitizer.py
index 813feb5c..89dc849e 100644
--- a/backend/app/utils/input_sanitizer.py
+++ b/backend/app/utils/input_sanitizer.py
@@ -24,4 +24,4 @@ def sanitize_content(content: str) -> str:
content = re.sub(r'\n{3,}', '\n\n', content)
content = re.sub(r' {2,}', ' ', content)
- return content.strip()
\ No newline at end of file
+ return content.strip()
diff --git a/backend/app/utils/pdf_multimodal_loader.py b/backend/app/utils/pdf_multimodal_loader.py
index 54f43c91..b4ee573d 100644
--- a/backend/app/utils/pdf_multimodal_loader.py
+++ b/backend/app/utils/pdf_multimodal_loader.py
@@ -7,8 +7,8 @@
import fitz
from langchain_core.documents import Document
-from app.core.settings import settings
from app.core.logger_handler import logger
+from app.core.settings import settings
from app.utils.image_extractor import extract_images_from_pdf
from app.utils.path_tool import get_abstract_path
from app.utils.vision_service import VisionService
diff --git a/backend/main.py b/backend/main.py
index 9e898560..58181a21 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -11,6 +11,7 @@
from app.core.logger_handler import logger
from app.db.db_config import init_db, seed_test_user
from app.db.redis_config import close_redis, connect_redis
+from app.graph.routers.graph_router import graph_router
from app.router.chat import chat_router
from app.router.health import health_router
from app.router.knowledge_router import knowledge_router
@@ -18,7 +19,6 @@
from app.router.note_template_router import note_template_router
from app.router.review_router import review_router
from app.router.user import file_router, user_router
-from app.graph.routers.graph_router import graph_router
from app.services.database_session_manager import init_database_session_manager
# 加载环境变量
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 2644a26b..2b4c8f91 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -30,7 +30,6 @@ dependencies = [
"pypdf==6.9.2",
"python-magic==0.4.27",
"python-magic-bin==0.4.14; sys_platform == 'win32' or sys_platform == 'darwin'",
- "unstructured[all-docs]==0.22.20",
"langgraph-cli[inmem]==0.1.54",
"rank-bm25==0.2.2",
"pymupdf==1.25.0",
@@ -55,6 +54,13 @@ dev = [
"ruff==0.8.0",
]
+# 文档解析依赖组:unstructured[all-docs] 含 torch/CUDA 栈(数 GB),默认安装保证本地/Docker 行为不变;
+# CI 用 `uv sync --no-group parsers` 跳过
+[dependency-groups]
+parsers = [
+ "unstructured[all-docs]==0.22.20",
+]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
@@ -85,3 +91,4 @@ default = true
[tool.uv]
index-url = "https://pypi.tuna.tsinghua.edu.cn/simple"
+default-groups = ["parsers"]
diff --git a/backend/requirements.txt b/backend/requirements.txt
deleted file mode 100644
index 495b187e..00000000
--- a/backend/requirements.txt
+++ /dev/null
@@ -1,691 +0,0 @@
-# This file was autogenerated by uv via the following command:
-# uv pip compile pyproject.toml -o requirements.txt
-accelerate==1.13.0
- # via unstructured-inference
-aiofiles==25.1.0
- # via
- # backend (pyproject.toml)
- # unstructured-client
-aiohappyeyeballs==2.6.1
- # via aiohttp
-aiohttp==3.13.5
- # via langchain-community
-aiomysql==0.3.2
- # via backend (pyproject.toml)
-aiosignal==1.4.0
- # via aiohttp
-annotated-doc==0.0.4
- # via
- # fastapi
- # typer
-annotated-types==0.7.0
- # via pydantic
-anyio==4.13.0
- # via
- # httpx
- # langsmith
- # openai
- # starlette
-attrs==26.1.0
- # via aiohttp
-bcrypt==4.3.0
- # via backend (pyproject.toml)
-beautifulsoup4==4.14.3
- # via unstructured
-blis==1.3.3
- # via thinc
-catalogue==2.0.10
- # via
- # spacy
- # srsly
- # thinc
-certifi==2026.4.22
- # via
- # httpcore
- # httpx
- # requests
-cffi==2.0.0
- # via cryptography
-charset-normalizer==3.4.7
- # via
- # pdfminer-six
- # requests
- # unstructured
-click==8.3.3
- # via
- # langgraph-cli
- # python-oxmsg
- # typer
- # uvicorn
-cloudpathlib==0.24.0
- # via weasel
-colorama==0.4.6
- # via
- # click
- # tqdm
- # wasabi
-confection==1.3.3
- # via
- # spacy
- # thinc
- # weasel
-contourpy==1.3.3
- # via matplotlib
-cryptography==48.0.0
- # via
- # google-auth
- # msoffcrypto-tool
- # pdfminer-six
-cycler==0.12.1
- # via matplotlib
-cymem==2.0.13
- # via
- # preshed
- # spacy
- # thinc
-dataclasses-json==0.6.7
- # via langchain-community
-deprecated==1.3.1
- # via pikepdf
-distro==1.9.0
- # via
- # langsmith
- # openai
-dnspython==2.8.0
- # via email-validator
-ecdsa==0.19.2
- # via python-jose
-email-validator==2.3.0
- # via pydantic
-emoji==2.15.0
- # via unstructured
-et-xmlfile==2.0.0
- # via openpyxl
-fastapi==0.123.0
- # via backend (pyproject.toml)
-filelock==3.29.0
- # via
- # huggingface-hub
- # torch
- # unstructured
-filetype==1.2.0
- # via unstructured
-flatbuffers==25.12.19
- # via onnxruntime
-fonttools==4.63.0
- # via matplotlib
-frozenlist==1.8.0
- # via
- # aiohttp
- # aiosignal
-fsspec==2026.4.0
- # via
- # huggingface-hub
- # torch
-google-api-core==2.30.3
- # via google-cloud-vision
-google-auth==2.53.0
- # via
- # google-api-core
- # google-cloud-vision
-google-cloud-vision==3.14.0
- # via unstructured
-googleapis-common-protos==1.75.0
- # via
- # google-api-core
- # grpcio-status
-greenlet==3.5.0
- # via sqlalchemy
-grpcio==1.80.0
- # via
- # google-api-core
- # google-cloud-vision
- # grpcio-status
-grpcio-status==1.80.0
- # via google-api-core
-h11==0.16.0
- # via
- # httpcore
- # uvicorn
-hf-xet==1.5.0
- # via huggingface-hub
-html5lib==1.1
- # via unstructured
-httpcore==1.0.9
- # via
- # httpx
- # unstructured-client
-httpx==0.28.1
- # via
- # backend (pyproject.toml)
- # huggingface-hub
- # langgraph-sdk
- # langsmith
- # openai
- # unstructured-client
- # weasel
-httpx-sse==0.4.3
- # via langchain-community
-huggingface-hub==1.15.0
- # via
- # accelerate
- # timm
- # tokenizers
- # transformers
- # unstructured-inference
-idna==3.15
- # via
- # anyio
- # email-validator
- # httpx
- # requests
- # yarl
-imagehash==4.3.0
- # via backend (pyproject.toml)
-installer==0.7.0
- # via unstructured
-jinja2==3.1.6
- # via
- # spacy
- # torch
-jiter==0.16.0
- # via openai
-jsonpatch==1.33
- # via langchain-core
-jsonpointer==3.1.1
- # via jsonpatch
-kiwisolver==1.5.0
- # via matplotlib
-langchain==1.2.13
- # via backend (pyproject.toml)
-langchain-classic==1.0.3
- # via langchain-community
-langchain-community==0.4.1
- # via backend (pyproject.toml)
-langchain-core==1.2.31
- # via
- # backend (pyproject.toml)
- # langchain
- # langchain-classic
- # langchain-community
- # langchain-openai
- # langchain-text-splitters
- # langgraph
- # langgraph-checkpoint
- # langgraph-prebuilt
-langchain-openai==1.1.14
- # via backend (pyproject.toml)
-langchain-text-splitters==1.1.1
- # via
- # backend (pyproject.toml)
- # langchain-classic
-langdetect==1.0.9
- # via unstructured
-langgraph==1.1.6
- # via langchain
-langgraph-checkpoint==4.1.0
- # via
- # langgraph
- # langgraph-prebuilt
-langgraph-cli==0.1.54
- # via backend (pyproject.toml)
-langgraph-prebuilt==1.0.10
- # via langgraph
-langgraph-sdk==0.3.14
- # via langgraph
-langsmith==0.11.0
- # via
- # langchain-classic
- # langchain-community
- # langchain-core
-llvmlite==0.47.0
- # via numba
-lxml==6.1.0
- # via
- # pikepdf
- # python-docx
- # python-pptx
- # unstructured
-markdown==3.10.2
- # via unstructured
-markdown-it-py==4.2.0
- # via rich
-markupsafe==3.0.3
- # via jinja2
-marshmallow==3.26.2
- # via dataclasses-json
-matplotlib==3.10.9
- # via unstructured-inference
-mdurl==0.1.2
- # via markdown-it-py
-ml-dtypes==0.5.4
- # via onnx
-more-itertools==11.0.2
- # via openai-whisper
-mpmath==1.3.0
- # via sympy
-msoffcrypto-tool==6.0.0
- # via unstructured
-multidict==6.7.1
- # via
- # aiohttp
- # yarl
-murmurhash==1.0.15
- # via
- # preshed
- # spacy
- # thinc
-mypy-extensions==1.1.0
- # via typing-inspect
-neo4j==6.3.0
- # via backend (pyproject.toml)
-networkx==3.6.1
- # via
- # torch
- # unstructured
-numba==0.65.1
- # via
- # openai-whisper
- # unstructured
-numpy==2.4.5
- # via
- # accelerate
- # blis
- # contourpy
- # imagehash
- # langchain-community
- # matplotlib
- # ml-dtypes
- # numba
- # onnx
- # onnxruntime
- # openai-whisper
- # opencv-python
- # pandas
- # pywavelets
- # rank-bm25
- # scipy
- # spacy
- # thinc
- # torchvision
- # transformers
- # unstructured
- # unstructured-inference
-olefile==0.47
- # via
- # msoffcrypto-tool
- # python-oxmsg
-onnx==1.21.0
- # via unstructured-inference
-onnxruntime==1.26.0
- # via unstructured-inference
-openai==2.54.0
- # via langchain-openai
-openai-whisper==20250625
- # via unstructured
-opencv-python==4.13.0.92
- # via unstructured-inference
-openpyxl==3.1.5
- # via unstructured
-orjson==3.11.9
- # via
- # langgraph-sdk
- # langsmith
-ormsgpack==1.12.2
- # via langgraph-checkpoint
-packaging==26.2
- # via
- # accelerate
- # huggingface-hub
- # langchain-core
- # langsmith
- # marshmallow
- # matplotlib
- # onnxruntime
- # pikepdf
- # spacy
- # thinc
- # transformers
- # unstructured-pytesseract
- # weasel
-pandas==2.3.3
- # via
- # unstructured
- # unstructured-inference
-passlib==1.7.4
- # via backend (pyproject.toml)
-pdf2image==1.17.0
- # via unstructured
-pdfminer-six==20260107
- # via unstructured
-pi-heif==1.3.0
- # via unstructured
-pikepdf==10.6.0
- # via unstructured
-pillow==11.1.0
- # via
- # backend (pyproject.toml)
- # imagehash
- # matplotlib
- # pdf2image
- # pi-heif
- # pikepdf
- # python-pptx
- # torchvision
- # unstructured-pytesseract
-preshed==3.0.13
- # via
- # spacy
- # thinc
-propcache==0.5.2
- # via
- # aiohttp
- # yarl
-proto-plus==1.28.0
- # via
- # google-api-core
- # google-cloud-vision
-protobuf==6.33.6
- # via
- # google-api-core
- # google-cloud-vision
- # googleapis-common-protos
- # grpcio-status
- # onnx
- # onnxruntime
- # proto-plus
-psutil==7.2.2
- # via
- # accelerate
- # unstructured
-pyasn1==0.6.3
- # via
- # pyasn1-modules
- # python-jose
- # rsa
-pyasn1-modules==0.4.2
- # via google-auth
-pycparser==3.0
- # via cffi
-pydantic==2.12.5
- # via
- # backend (pyproject.toml)
- # fastapi
- # langchain
- # langchain-classic
- # langchain-core
- # langgraph
- # langsmith
- # openai
- # pydantic-settings
- # spacy
- # thinc
- # unstructured-client
- # weasel
-pydantic-core==2.41.5
- # via pydantic
-pydantic-settings==2.13.1
- # via
- # backend (pyproject.toml)
- # langchain-community
-pygments==2.20.0
- # via rich
-pymupdf==1.25.0
- # via backend (pyproject.toml)
-pymysql==1.1.3
- # via aiomysql
-pypandoc-binary==1.17
- # via unstructured
-pyparsing==3.3.2
- # via matplotlib
-pypdf==6.9.2
- # via
- # backend (pyproject.toml)
- # unstructured
- # unstructured-client
-pypdfium2==5.8.0
- # via
- # unstructured-client
- # unstructured-inference
-python-dateutil==2.9.0.post0
- # via
- # matplotlib
- # pandas
-python-docx==1.2.0
- # via unstructured
-python-dotenv==1.2.2
- # via
- # backend (pyproject.toml)
- # pydantic-settings
-python-iso639==2026.4.20
- # via unstructured
-python-jose==3.5.0
- # via backend (pyproject.toml)
-python-magic==0.4.27
- # via
- # backend (pyproject.toml)
- # unstructured
-python-magic-bin==0.4.14
- # via backend (pyproject.toml)
-python-multipart==0.0.22
- # via backend (pyproject.toml)
-python-oxmsg==0.0.2
- # via unstructured
-python-pptx==1.0.2
- # via unstructured
-pytz==2026.2
- # via
- # neo4j
- # pandas
-pywavelets==1.9.0
- # via imagehash
-pyyaml==6.0.3
- # via
- # backend (pyproject.toml)
- # accelerate
- # huggingface-hub
- # langchain-classic
- # langchain-community
- # langchain-core
- # timm
- # transformers
-rank-bm25==0.2.2
- # via backend (pyproject.toml)
-rapidfuzz==3.14.5
- # via
- # unstructured
- # unstructured-inference
-redis==7.3.0
- # via backend (pyproject.toml)
-regex==2026.5.9
- # via
- # tiktoken
- # transformers
- # unstructured
-requests==2.32.5
- # via
- # backend (pyproject.toml)
- # google-api-core
- # langchain-classic
- # langchain-community
- # langsmith
- # requests-toolbelt
- # spacy
- # tiktoken
- # unstructured
-requests-toolbelt==1.0.0
- # via
- # langsmith
- # unstructured-client
-rich==15.0.0
- # via typer
-rsa==4.9.1
- # via python-jose
-safetensors==0.7.0
- # via
- # accelerate
- # timm
- # transformers
-scipy==1.17.1
- # via
- # imagehash
- # unstructured-inference
-setuptools==81.0.0
- # via
- # spacy
- # thinc
- # torch
-shellingham==1.5.4
- # via typer
-shortuuid==1.0.11
- # via backend (pyproject.toml)
-six==1.17.0
- # via
- # ecdsa
- # html5lib
- # langdetect
- # python-dateutil
-smart-open==7.6.1
- # via weasel
-sniffio==1.3.1
- # via
- # langsmith
- # openai
-soupsieve==2.8.3
- # via beautifulsoup4
-spacy==3.8.14
- # via unstructured
-spacy-legacy==3.0.12
- # via spacy
-spacy-loggers==1.0.5
- # via spacy
-sqlalchemy==2.0.48
- # via
- # backend (pyproject.toml)
- # langchain-classic
- # langchain-community
-srsly==2.5.3
- # via
- # spacy
- # thinc
- # weasel
-starlette==0.50.0
- # via
- # backend (pyproject.toml)
- # fastapi
-sympy==1.14.0
- # via torch
-tenacity==9.1.4
- # via
- # langchain-community
- # langchain-core
-thinc==8.3.13
- # via spacy
-tiktoken==0.7.0
- # via
- # langchain-openai
- # openai-whisper
-timm==1.0.27
- # via unstructured-inference
-tokenizers==0.22.2
- # via transformers
-torch==2.13.0
- # via
- # accelerate
- # openai-whisper
- # timm
- # torchvision
- # unstructured-inference
-torchvision==0.28.0
- # via timm
-tqdm==4.67.3
- # via
- # huggingface-hub
- # openai
- # openai-whisper
- # spacy
- # transformers
- # unstructured
-transformers==5.8.1
- # via unstructured-inference
-typer==0.25.1
- # via
- # huggingface-hub
- # spacy
- # transformers
- # weasel
-typing-extensions==4.15.0
- # via
- # aiosignal
- # anyio
- # beautifulsoup4
- # fastapi
- # grpcio
- # huggingface-hub
- # langchain-core
- # langsmith
- # onnx
- # openai
- # pydantic
- # pydantic-core
- # python-docx
- # python-oxmsg
- # python-pptx
- # sqlalchemy
- # starlette
- # torch
- # typing-inspect
- # typing-inspection
- # unstructured
-typing-inspect==0.9.0
- # via dataclasses-json
-typing-inspection==0.4.2
- # via
- # pydantic
- # pydantic-settings
-tzdata==2026.2
- # via pandas
-unstructured==0.22.20
- # via backend (pyproject.toml)
-unstructured-client==0.44.0
- # via unstructured
-unstructured-inference==1.6.11
- # via unstructured
-unstructured-pytesseract==0.3.15
- # via unstructured
-urllib3==2.7.0
- # via requests
-uuid-utils==0.15.0
- # via
- # langchain-core
- # langsmith
-uvicorn==0.21.1
- # via backend (pyproject.toml)
-wasabi==1.1.3
- # via
- # spacy
- # thinc
- # weasel
-weasel==1.0.0
- # via spacy
-webencodings==0.5.1
- # via html5lib
-websockets==16.0
- # via langsmith
-wrapt==2.1.2
- # via
- # deprecated
- # smart-open
- # unstructured
-xlrd==2.0.2
- # via unstructured
-xlsxwriter==3.2.9
- # via python-pptx
-xxhash==3.7.0
- # via
- # langgraph
- # langsmith
-yarl==1.23.0
- # via aiohttp
-zstandard==0.25.0
- # via langsmith
diff --git a/backend/scripts/check_graph_data.py b/backend/scripts/check_graph_data.py
index 2f232190..e6ee453c 100644
--- a/backend/scripts/check_graph_data.py
+++ b/backend/scripts/check_graph_data.py
@@ -5,6 +5,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import asyncio
+
from sqlalchemy import func, select
from app.db.db_config import AsyncSessionLocal
@@ -46,8 +47,8 @@ async def main():
select(GraphExtractLog.source_type, GraphExtractLog.status, GraphExtractLog.new_count,
GraphExtractLog.error_message).order_by(GraphExtractLog.triggered_at)
)).all()
- for l in logs:
- print(f" [{l[0]}] {l[1]} new={l[2]} err={l[3]}")
+ for log in logs:
+ print(f" [{log[0]}] {log[1]} new={log[2]} err={log[3]}")
-asyncio.run(main())
\ No newline at end of file
+asyncio.run(main())
diff --git a/backend/scripts/eval_end2end.py b/backend/scripts/eval_end2end.py
index 592a79c9..1a52bd76 100644
--- a/backend/scripts/eval_end2end.py
+++ b/backend/scripts/eval_end2end.py
@@ -27,8 +27,8 @@
BACKEND = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BACKEND))
-from langchain_core.messages import HumanMessage, SystemMessage # noqa: E402
import pandas as pd # noqa: E402
+from langchain_core.messages import HumanMessage, SystemMessage # noqa: E402
from app.rag.agentic_rag.service import AgenticRagService # noqa: E402
from app.utils.factory import ChatModelFactory # noqa: E402
@@ -195,7 +195,7 @@ async def main() -> None:
(args.results_dir / "report.json").write_text(json.dumps({"summary": agg, "rows": rows}, ensure_ascii=False, indent=2), encoding="utf-8")
md = ["# Agentic RAG 端到端评测报告(自动合成 QA)", ""]
md.append(f"- 评测用户:`{user_id}` 样本:{len(rows)}(跳过 {skipped})")
- md.append(f"- 口径:题目从源正文自动生成,衡量**答案质量/忠实度/延迟**,**不衡量检索难度**")
+ md.append("- 口径:题目从源正文自动生成,衡量**答案质量/忠实度/延迟**,**不衡量检索难度**")
md.append("")
md.append("| 指标 | 值 |")
md.append("|---|---|")
diff --git a/backend/scripts/eval_graph_entity_recall.py b/backend/scripts/eval_graph_entity_recall.py
index 2b2ede76..ea20704c 100644
--- a/backend/scripts/eval_graph_entity_recall.py
+++ b/backend/scripts/eval_graph_entity_recall.py
@@ -14,7 +14,6 @@
import argparse
import asyncio
import json
-import math
import random
import statistics
import sys
@@ -117,7 +116,7 @@ async def main() -> None:
json.dumps({"summary": agg, "rows": rows}, ensure_ascii=False, indent=2), encoding="utf-8")
md = ["# 实体级 GraphRAG 检索评测", "",
f"- 用户:`{args.user_id}` 样本:{len(rows)} 实体",
- f"- 口径:从图中取实体构造问题,测真实 search_graph 路径(实体抽取→匹配)",
+ "- 口径:从图中取实体构造问题,测真实 search_graph 路径(实体抽取→匹配)",
"", "| 指标 | 值 |", "|---|---|"]
for k in TOP_Ks:
md.append(f"| 实体 hit@{k} | {agg[f'hit@{k}']:.4f} |")
diff --git a/backend/scripts/eval_graph_multihop.py b/backend/scripts/eval_graph_multihop.py
index c0f6f4ca..f8fd05fc 100644
--- a/backend/scripts/eval_graph_multihop.py
+++ b/backend/scripts/eval_graph_multihop.py
@@ -156,7 +156,8 @@ async def main() -> None:
g_ac_hit = p["a"] in g_ents and p["c"] in g_ents
g_text = _evidence_text(rg.evidences)
g_has_b_hit = p["b"] in g_text
- g_ac += int(g_ac_hit); g_has_b += int(g_has_b_hit)
+ g_ac += int(g_ac_hit)
+ g_has_b += int(g_has_b_hit)
# 文本路径
rt = await svc_text.run(q, args.user_id)
@@ -206,7 +207,9 @@ async def main() -> None:
f"- 图路径检索延迟均值:{agg['mean_graph_latency_ms']:.0f} ms", "",
"## 逐路径明细", "", "| 路径 | a/c命中 | 证据含b(G/T) | 答案(G/T) |", "|---|---|---|---|"]
for r in rows:
- md.append(f"| {r['path'][:48]} | {int(r['graph_ac_hit'])} | {int(r['graph_evidence_has_b'])}/{int(r['text_evidence_has_b'])} | {r['graph_answer_correct']}/{r['text_answer_correct']} |")
+ md.append(f"| {r['path'][:48]} | {int(r['graph_ac_hit'])} | "
+ f"{int(r['graph_evidence_has_b'])}/{int(r['text_evidence_has_b'])} | "
+ f"{r['graph_answer_correct']}/{r['text_answer_correct']} |")
(args.results_dir / "report.md").write_text("\n".join(md), encoding="utf-8")
print(json.dumps(agg, ensure_ascii=False, indent=2))
print(f"[done] -> {args.results_dir / 'report.json'}")
diff --git a/backend/scripts/eval_ingest.py b/backend/scripts/eval_ingest.py
index 68b4f2c2..4b89b129 100644
--- a/backend/scripts/eval_ingest.py
+++ b/backend/scripts/eval_ingest.py
@@ -14,7 +14,6 @@
import argparse
import asyncio
import json
-import os
import random
import sys
import time
@@ -115,7 +114,6 @@ async def main() -> None:
sampled_qids, relevant, doc_ids = sample(
queries, corpus, qrels, args.n_query, args.n_doc, args.seed
)
- qid_set = set(sampled_qids)
qtext = dict(zip(queries["_id"].astype(str), queries["text"].astype(str)))
ctext = dict(zip(corpus["_id"].astype(str), corpus["text"].astype(str)))
ctitle = dict(zip(corpus["_id"].astype(str), corpus["title"].astype(str)))
diff --git a/backend/scripts/eval_recall.py b/backend/scripts/eval_recall.py
index d8fdcb7c..68e581d2 100644
--- a/backend/scripts/eval_recall.py
+++ b/backend/scripts/eval_recall.py
@@ -24,8 +24,6 @@
import time
from pathlib import Path
-import pandas as pd
-
BACKEND = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BACKEND))
@@ -177,7 +175,8 @@ async def search(q: str):
md.append("| query_id | 相关文档数 | recall@5 | hit@5 | nDCG@10 | MAP | MRR | 延迟(ms) |")
md.append("|---|---|---|---|---|---|---|---|")
for r in rows[:30]:
- md.append(f"| {r['query_id'][:10]} | {r['relevant_count']} | {r['recall@5']:.3f} | {r['hit@5']:.1f} | {r['ndcg@10']:.3f} | {r['map']:.3f} | {r['mrr']:.3f} | {r['latency_ms']} |")
+ md.append(f"| {r['query_id'][:10]} | {r['relevant_count']} | {r['recall@5']:.3f} | "
+ f"{r['hit@5']:.1f} | {r['ndcg@10']:.3f} | {r['map']:.3f} | {r['mrr']:.3f} | {r['latency_ms']} |")
(args.results_dir / "report.md").write_text("\n".join(md), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
print(f"[done] -> {args.results_dir / 'report.json'}")
diff --git a/backend/scripts/fix_graph_docs.py b/backend/scripts/fix_graph_docs.py
index c7293246..a11f3ddc 100644
--- a/backend/scripts/fix_graph_docs.py
+++ b/backend/scripts/fix_graph_docs.py
@@ -24,4 +24,4 @@ async def main():
logger.info("graph_docs 补齐完成")
-asyncio.run(main())
\ No newline at end of file
+asyncio.run(main())
diff --git a/backend/tests/agent/test_agent.py b/backend/tests/agent/test_agent.py
index 66c72a14..71352c0b 100644
--- a/backend/tests/agent/test_agent.py
+++ b/backend/tests/agent/test_agent.py
@@ -637,7 +637,9 @@ def fake_create_agent(*args, **kwargs):
responses=[AIMessage(content="ok")]))
monkeypatch.setattr(agent_module, "get_checkpointer", lambda: object())
- factory = agent_module.AgentFactory()
+ # mock 打在单例实例上(agent_factory),必须用单例调用:新实例看不到实例级 mock,
+ # 会走到真实 _create_chat_model,在无凭证环境(CI)直接报错
+ factory = agent_module.agent_factory
agent = factory.create_agent(custom_tools=[])
assert seen["checkpointer"] is not None
assert isinstance(agent, CompiledStateGraph)
diff --git a/backend/tests/agent/test_agent_middleware.py b/backend/tests/agent/test_agent_middleware.py
index 5619aa02..121017d4 100644
--- a/backend/tests/agent/test_agent_middleware.py
+++ b/backend/tests/agent/test_agent_middleware.py
@@ -207,4 +207,4 @@ def test_describe_create_note_short_content_kept_whole():
{"name": "create_note_tool", "args": {"content": "hello"}}, {}, object())
assert "(无标题)" in desc
assert desc.endswith("hello")
- assert "…" not in desc
\ No newline at end of file
+ assert "…" not in desc
diff --git a/backend/tests/agent/test_mysql_saver.py b/backend/tests/agent/test_mysql_saver.py
index 5382e6f5..4306d1d2 100644
--- a/backend/tests/agent/test_mysql_saver.py
+++ b/backend/tests/agent/test_mysql_saver.py
@@ -3,7 +3,6 @@
覆盖:aput/aget_tuple roundtrip、parent 链、aput_writes 与 pending_writes、
alist 排序与 limit、adelete_thread、中断写(WRITES_IDX_MAP 下标)。
"""
-import json
import pytest
from langchain_core.messages import HumanMessage
@@ -88,7 +87,6 @@ async def test_adelete_thread_removes_checkpoints_and_writes(saver):
async def test_special_write_uses_writes_idx_map_index(saver):
"""interrupt 类特殊写必须以 WRITES_IDX_MAP 负下标落库(langgraph 依赖)。"""
- from langgraph.checkpoint.base import WRITES_IDX_MAP
await saver.aput(_config("thread-1"), _make_checkpoint("c1"), {"step": 0}, {})
cfg = _config("thread-1", "c1")
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 13eb3576..a6cc38ec 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -42,11 +42,10 @@ def from_buffer(self, content):
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models.chat_history import Base
-
from tests.fakes import (
+ TEST_USER_ID,
FakeReorderService,
FakeVectorStoreService,
- TEST_USER_ID,
install_fake_redis,
make_fake_chat_model,
)
@@ -212,12 +211,12 @@ async def client(session_factory, monkeypatch, fake_models):
使用 httpx ASGITransport 直连应用、不触发 lifespan(启动事件不会执行,
因此不会连接 MySQL/Redis)。
"""
- from main import app
- from app.utils.auth_utils import get_current_user_id, security
from fastapi.security import HTTPAuthorizationCredentials
# 1. 数据库:依赖注入 get_db → SQLite;模块内 AsyncSessionLocal → SQLite
from app.db.db_config import get_db
+ from app.utils.auth_utils import get_current_user_id, security
+ from main import app
async def _override_get_db():
async with session_factory() as session:
@@ -267,9 +266,8 @@ async def raw_client(session_factory, monkeypatch):
- 非法 Token → 401(get_current_user_id 内部逻辑)
- 黑名单 Token → 401
"""
- from main import app
-
from app.db.db_config import get_db
+ from main import app
patch_session_factory(monkeypatch, session_factory)
await install_fake_redis(monkeypatch)
@@ -304,4 +302,4 @@ def _factory(*args, **kwargs):
return state["instance"]
monkeypatch.setattr(vector_store_module, "VectorStoreService", _factory)
- return state["instance"] or FakeVectorStoreService(route_score=route_score, documents=documents)
\ No newline at end of file
+ return state["instance"] or FakeVectorStoreService(route_score=route_score, documents=documents)
diff --git a/backend/tests/fakes.py b/backend/tests/fakes.py
index 9afb8c9a..228845bc 100644
--- a/backend/tests/fakes.py
+++ b/backend/tests/fakes.py
@@ -3,11 +3,9 @@
统一策略:MySQL / Redis / LLM / 重排序模型全部用内存替身替换,
测试不依赖任何外部服务,可在任意环境直接运行。
"""
-import asyncio
import fnmatch
import itertools
import types
-import uuid as uuidlib
from langchain_core.documents import Document
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
@@ -157,4 +155,4 @@ async def aget_state(self, config: dict):
next=(),
values={"messages": self.messages},
interrupts=(),
- )
\ No newline at end of file
+ )
diff --git a/backend/tests/graph/conftest.py b/backend/tests/graph/conftest.py
index b51a7411..cc196d48 100644
--- a/backend/tests/graph/conftest.py
+++ b/backend/tests/graph/conftest.py
@@ -25,9 +25,8 @@
async def _cleanup():
"""每个用例结束后清理测试产生的 Neo4j 子图(pipe- 前缀用户 + API 测试的固定用户)。"""
yield
- from tests.fakes import TEST_USER_ID
-
from app.graph.storage import neo4j_client
+ from tests.fakes import TEST_USER_ID
driver = neo4j_client.get_neo4j_driver()
await driver.execute_query(
diff --git a/backend/tests/graph/test_graph_events.py b/backend/tests/graph/test_graph_events.py
index cafed93a..006d19d4 100644
--- a/backend/tests/graph/test_graph_events.py
+++ b/backend/tests/graph/test_graph_events.py
@@ -1,5 +1,4 @@
import asyncio
-import json
import pytest
@@ -28,8 +27,6 @@ async def test_publish_does_not_leak_across_users():
@pytest.mark.asyncio
async def test_events_endpoint_streams_extract_event(client, monkeypatch):
- from fastapi.testclient import TestClient
- from main import app
# SSE 端点需真实网络事件循环,这里只验证事件形状(通过 bus 直接 push)
bus = GraphEventBus()
@@ -37,4 +34,4 @@ async def test_events_endpoint_streams_extract_event(client, monkeypatch):
await bus.publish("u1", {"type": "extract_done", "note_id": "n1", "status": "success"})
evt = await asyncio.wait_for(q.get(), timeout=1)
assert evt["type"] == "extract_done"
- await bus.unsubscribe("u1", q)
\ No newline at end of file
+ await bus.unsubscribe("u1", q)
diff --git a/backend/tests/graph/test_graph_models.py b/backend/tests/graph/test_graph_models.py
index 568faf36..c566edf8 100644
--- a/backend/tests/graph/test_graph_models.py
+++ b/backend/tests/graph/test_graph_models.py
@@ -1,13 +1,9 @@
import pytest
-from sqlalchemy import inspect, select
+from sqlalchemy import inspect
from app.models.graph import (
GraphEntity,
- GraphEntityNote,
- GraphEntityType,
GraphExtractLog,
- GraphNoteEdge,
- GraphRelation,
)
diff --git a/backend/tests/graph/test_graph_schemas.py b/backend/tests/graph/test_graph_schemas.py
index 0a2c3168..44a5b2d1 100644
--- a/backend/tests/graph/test_graph_schemas.py
+++ b/backend/tests/graph/test_graph_schemas.py
@@ -1,8 +1,7 @@
from app.graph.schemas.graph import (
- Entity,
EntityIn,
- ExtractResult,
ExtractedEntity,
+ ExtractResult,
GraphEdge,
GraphNode,
GraphView,
diff --git a/backend/tests/graph/test_graph_store.py b/backend/tests/graph/test_graph_store.py
index d5c83956..e19be8e3 100644
--- a/backend/tests/graph/test_graph_store.py
+++ b/backend/tests/graph/test_graph_store.py
@@ -5,4 +5,4 @@
def test_graph_store_is_abstract():
with pytest.raises(TypeError):
- GraphStore() # ABC 不能实例化
\ No newline at end of file
+ GraphStore() # ABC 不能实例化
diff --git a/backend/tests/graph/test_neo4j_pipeline.py b/backend/tests/graph/test_neo4j_pipeline.py
index ae415104..8884ec79 100644
--- a/backend/tests/graph/test_neo4j_pipeline.py
+++ b/backend/tests/graph/test_neo4j_pipeline.py
@@ -147,7 +147,6 @@ async def test_doc_pipeline_with_prechunks(db_session, session_factory, monkeypa
async def test_note_pipeline_wiki_edges_first_match(db_session, session_factory, monkeypatch,
neo4j_env, _cleanup):
"""[[双链]] 命中已存在笔记才建 WIKI 边;重名笔记首条匹配生效,不得 MultipleResultsFound。"""
- from sqlalchemy import select
from app.graph.storage import neo4j_client
from app.models.note import Note
@@ -175,7 +174,6 @@ async def test_note_pipeline_wiki_edges_first_match(db_session, session_factory,
async def test_doc_pipeline_skips_note_edges(db_session, session_factory, monkeypatch,
neo4j_env, _cleanup):
"""文档正文里即便有 [[双链]] 语法也不生成笔记 WIKI 边;实体照常落图。"""
- from sqlalchemy import select
from app.graph.storage import neo4j_client
from app.models.graph import GraphDoc
@@ -266,7 +264,6 @@ async def test_cleanup_note_graph_removes_graph_and_logs(db_session, session_fac
async def test_extraction_replaces_stale_relations_keeps_manual(db_session, session_factory,
monkeypatch, neo4j_env, _cleanup):
"""重抽按来源溯源替换旧关系;无溯源标记的手动关系(图谱页手工连线)不受影响。"""
- from sqlalchemy import select
from app.graph.schemas.graph import EntityIn, RelationIn
from app.graph.storage import neo4j_client
diff --git a/backend/tests/rag/test_document_processor.py b/backend/tests/rag/test_document_processor.py
index 6ab22557..c3ab4abf 100644
--- a/backend/tests/rag/test_document_processor.py
+++ b/backend/tests/rag/test_document_processor.py
@@ -122,4 +122,4 @@ def test_short_document_stays_single_chunk(self):
def test_empty_documents(self):
processor = _processor()
- assert processor.split_documents_sync([]) == []
\ No newline at end of file
+ assert processor.split_documents_sync([]) == []
diff --git a/backend/tests/rag/test_md5_store.py b/backend/tests/rag/test_md5_store.py
index fa520443..95dd82e1 100644
--- a/backend/tests/rag/test_md5_store.py
+++ b/backend/tests/rag/test_md5_store.py
@@ -187,4 +187,4 @@ async def test_save_md5_hex_sync(tmp_path, user_id):
store = MD5Store()
store.base_dir = str(tmp_path / "sync_root")
store.save_md5_hex_sync("sync-md5", "s.txt", None, user_id)
- assert await store.check_md5_hex("sync-md5", user_id) is True
\ No newline at end of file
+ assert await store.check_md5_hex("sync-md5", user_id) is True
diff --git a/backend/tests/rag/test_reorder_service.py b/backend/tests/rag/test_reorder_service.py
index 400f52d5..af8d8056 100644
--- a/backend/tests/rag/test_reorder_service.py
+++ b/backend/tests/rag/test_reorder_service.py
@@ -1,5 +1,4 @@
"""reorder_service.py — ReorderService 云端 rerank 逻辑测试(不发真实网络请求)。"""
-import pytest
from app.rag.reorder_service import ReorderService
diff --git a/backend/tests/rag/test_sse_models.py b/backend/tests/rag/test_sse_models.py
index 41715adb..004a9d83 100644
--- a/backend/tests/rag/test_sse_models.py
+++ b/backend/tests/rag/test_sse_models.py
@@ -5,8 +5,8 @@
EVENT_DONE,
EVENT_ERROR,
EVENT_RESPONSE,
- SSEEvent,
SliceResult,
+ SSEEvent,
)
@@ -125,4 +125,4 @@ def test_to_dict_error(self):
result = SliceResult.error_result(1, "a.txt", "boom")
assert result.to_dict()["success"] is False
assert result.to_dict()["error"] == "boom"
- assert result.to_dict()["chunk_count"] == 0
\ No newline at end of file
+ assert result.to_dict()["chunk_count"] == 0
diff --git a/backend/tests/rag/test_task_queue.py b/backend/tests/rag/test_task_queue.py
index e2649208..43999ca1 100644
--- a/backend/tests/rag/test_task_queue.py
+++ b/backend/tests/rag/test_task_queue.py
@@ -113,4 +113,4 @@ def test_progress_calculation():
q.set_finished()
assert q.get_completed_count() == 4
assert q.get_total_count() == 4
- assert q.is_finished() is True
\ No newline at end of file
+ assert q.is_finished() is True
diff --git a/backend/tests/rag/test_text_spliter.py b/backend/tests/rag/test_text_spliter.py
index e6a4d7f0..4b6b1497 100644
--- a/backend/tests/rag/test_text_spliter.py
+++ b/backend/tests/rag/test_text_spliter.py
@@ -120,4 +120,4 @@ async def test_split_text_with_embedding_optimizes(self):
chunk_size=100, chunk_overlap=20, embedding_model=FakeEmbedder()
)
chunks = await splitter.split_text("word " * 60)
- assert len(chunks) == 1
\ No newline at end of file
+ assert len(chunks) == 1
diff --git a/backend/tests/services/test_database_session_manager.py b/backend/tests/services/test_database_session_manager.py
index 22a3be77..e9b326c3 100644
--- a/backend/tests/services/test_database_session_manager.py
+++ b/backend/tests/services/test_database_session_manager.py
@@ -12,7 +12,6 @@
from app.models.chat_history import ChatMessage, ChatSession
from app.services.database_session_manager import DatabaseSessionManager
-
from tests.conftest import patch_session_factory
@@ -216,7 +215,6 @@ async def test_set_and_get_pending_run_id(session_factory, monkeypatch):
from tests.conftest import patch_session_factory
patch_session_factory(monkeypatch, session_factory)
- from app.services import session_manager as sm
from app.services.database_session_manager import DatabaseSessionManager
mgr = DatabaseSessionManager()
@@ -226,4 +224,4 @@ async def test_set_and_get_pending_run_id(session_factory, monkeypatch):
assert await mgr.get_pending_run_id("s-pending", "u1") == "run-x"
await mgr.set_pending_run_id("s-pending", "u1", None)
assert await mgr.get_pending_run_id("s-pending", "u1") is None
- assert await mgr.get_pending_run_id("s-pending", "other-user") is None
\ No newline at end of file
+ assert await mgr.get_pending_run_id("s-pending", "other-user") is None
diff --git a/backend/tests/services/test_note_service.py b/backend/tests/services/test_note_service.py
index 7b9a5d95..e39a4af5 100644
--- a/backend/tests/services/test_note_service.py
+++ b/backend/tests/services/test_note_service.py
@@ -13,14 +13,12 @@
import zipfile
from datetime import datetime, timedelta
-import pytest
from sqlalchemy import select
from app.models.note import Note
from app.models.review_record import ReviewRecord
from app.schemas.models import NoteCreate, NoteUpdate
from app.services.note_service import NoteService
-
from tests.conftest import (
install_init_manager_fakes,
patch_session_factory,
@@ -328,8 +326,8 @@ async def test_get_category_stats(real_note_service, db_session):
async def test_delete_category_removes_notes(real_note_service, db_session, session_factory):
svc = real_note_service
- w1 = await _seed_note(db_session, USER, title="w1", category="work")
- w2 = await _seed_note(db_session, USER, title="w2", category="work")
+ await _seed_note(db_session, USER, title="w1", category="work")
+ await _seed_note(db_session, USER, title="w2", category="work")
s1 = await _seed_note(db_session, USER, title="s1", category="study")
deleted = await svc.delete_category(db_session, USER, "work")
@@ -547,4 +545,4 @@ def test_extract_json_plain_object():
def test_extract_json_no_json_returns_input():
text = "这里没有任何 JSON"
- assert NoteService._extract_json(text) == text
\ No newline at end of file
+ assert NoteService._extract_json(text) == text
diff --git a/backend/tests/services/test_note_template_service.py b/backend/tests/services/test_note_template_service.py
index 5bbfd9ee..261c2936 100644
--- a/backend/tests/services/test_note_template_service.py
+++ b/backend/tests/services/test_note_template_service.py
@@ -166,4 +166,4 @@ async def test_reorder_templates_success_updates_sort_order(db_session):
after = await svc.list_templates(db_session, user)
assert [t.id for t in after] == reversed_ids
- assert [t.sort_order for t in after] == list(range(6))
\ No newline at end of file
+ assert [t.sort_order for t in after] == list(range(6))
diff --git a/backend/tests/services/test_review_service.py b/backend/tests/services/test_review_service.py
index fec55587..b161d934 100644
--- a/backend/tests/services/test_review_service.py
+++ b/backend/tests/services/test_review_service.py
@@ -3,13 +3,11 @@
import uuid
from datetime import datetime, timedelta
-import pytest
from sqlalchemy import select
from app.models.note import Note
from app.models.review_record import ReviewRecord
from app.services.review_service import INTERVALS, ReviewService, get_next_interval
-
from tests.conftest import install_init_manager_fakes
from tests.fakes import make_fake_chat_model
@@ -251,4 +249,4 @@ async def test_get_review_question_for_note_found_generates_question(db_session,
svc = ReviewService()
result = await svc.get_review_question_for_note(db_session, note.id, user)
- assert result == payload
\ No newline at end of file
+ assert result == payload
diff --git a/backend/tests/test_auth_utils.py b/backend/tests/test_auth_utils.py
index 74a74635..bd4c20a4 100644
--- a/backend/tests/test_auth_utils.py
+++ b/backend/tests/test_auth_utils.py
@@ -26,7 +26,6 @@
hash_password,
verify_password,
)
-
from tests.conftest import patch_session_factory
from tests.fakes import install_fake_redis
@@ -246,4 +245,4 @@ async def test_get_user_info_from_redis_missing_user_returns_none(
if __name__ == "__main__":
- raise SystemExit(pytest.main([__file__, "-v"]))
\ No newline at end of file
+ raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/backend/tests/test_chat_api.py b/backend/tests/test_chat_api.py
index 881eab3f..9b336748 100644
--- a/backend/tests/test_chat_api.py
+++ b/backend/tests/test_chat_api.py
@@ -45,9 +45,8 @@ async def handle_reorder(self, query, documents):
def install_fake_chat_service(monkeypatch):
- from main import app
-
import app.router.chat as chat_module
+ from main import app
service = FakeChatService()
app.dependency_overrides[chat_module.get_router_service] = lambda: service
@@ -72,9 +71,8 @@ async def test_get_session(client, monkeypatch):
async def test_get_session_includes_pending_run_id(client, monkeypatch):
"""会话详情响应携带 pending_run_id(供前端恢复审批卡)。"""
- from main import app
-
import app.router.chat as chat_module
+ from main import app
class FakeService:
def __init__(self):
@@ -155,9 +153,9 @@ async def fake_agent_stream(query, session_id, user_id, custom_tools=None, rag_c
) as resp:
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
- lines = [l async for l in resp.aiter_lines()]
+ lines = [line async for line in resp.aiter_lines()]
- frames = [json.loads(l[6:]) for l in lines if l.startswith("data: ")]
+ frames = [json.loads(line[6:]) for line in lines if line.startswith("data: ")]
assert any(frame["type"] == "response" for frame in frames)
assert frames[-1]["type"] == "done"
response_frame = next(frame for frame in frames if frame["type"] == "response")
@@ -188,9 +186,9 @@ async def fake_agent_stream(query, session_id, user_id, custom_tools=None, rag_c
headers={"Authorization": "Bearer x"},
) as resp:
assert resp.status_code == 200
- lines = [l async for l in resp.aiter_lines()]
+ lines = [line async for line in resp.aiter_lines()]
- frames = [json.loads(l[6:]) for l in lines if l.startswith("data: ")]
+ frames = [json.loads(line[6:]) for line in lines if line.startswith("data: ")]
assert seen["rag_context"] == ""
assert frames[-1]["type"] == "done"
@@ -220,9 +218,9 @@ async def fake_agent_stream(query, session_id, user_id, custom_tools=None, rag_c
headers={"Authorization": "Bearer x"},
) as resp:
assert resp.status_code == 200
- lines = [l async for l in resp.aiter_lines()]
+ lines = [line async for line in resp.aiter_lines()]
- frames = [json.loads(l[6:]) for l in lines if l.startswith("data: ")]
+ frames = [json.loads(line[6:]) for line in lines if line.startswith("data: ")]
# 存在 RAG 思考事件 + agent 响应
assert any(f.get("type") == "thinking" for f in frames)
assert frames[-1]["type"] == "done"
@@ -253,9 +251,9 @@ async def fake_agent_stream(query, session_id, user_id, custom_tools=None, rag_c
headers={"Authorization": "Bearer x"},
) as resp:
assert resp.status_code == 200
- lines = [l async for l in resp.aiter_lines()]
+ lines = [line async for line in resp.aiter_lines()]
- frames = [json.loads(l[6:]) for l in lines if l.startswith("data: ")]
+ frames = [json.loads(line[6:]) for line in lines if line.startswith("data: ")]
assert calls == [("讲讲最新RAG", TEST_USER_ID)]
# 时序:路由占位 thinking(让前端折叠框立即出现)→ RAG 真实事件 → agent 响应 → done
assert [frame["type"] for frame in frames] == ["thinking", "thinking", "response", "done"]
diff --git a/backend/tests/test_config_handler.py b/backend/tests/test_config_handler.py
index b6eb44ab..ee24f011 100644
--- a/backend/tests/test_config_handler.py
+++ b/backend/tests/test_config_handler.py
@@ -43,4 +43,4 @@ def test_load_config_real_app_config_files_exist():
assert isinstance(prompt, dict) and prompt
assert isinstance(document, dict) and document
# empty file -> None, no exception
- assert load_config(get_abstract_path("app/config/agent.yaml")) is None
\ No newline at end of file
+ assert load_config(get_abstract_path("app/config/agent.yaml")) is None
diff --git a/backend/tests/test_core_responses.py b/backend/tests/test_core_responses.py
index 0eb833c9..843c6e49 100644
--- a/backend/tests/test_core_responses.py
+++ b/backend/tests/test_core_responses.py
@@ -308,4 +308,4 @@ async def test_dev_mode_masks_secrets_in_error_and_traceback(self):
assert secret not in data["error_detail"]
assert "***" in data["error_detail"]
assert secret not in data["traceback"]
- assert data["path"] == "http://test/"
\ No newline at end of file
+ assert data["path"] == "http://test/"
diff --git a/backend/tests/test_exception_register.py b/backend/tests/test_exception_register.py
index 6094b125..b091a788 100644
--- a/backend/tests/test_exception_register.py
+++ b/backend/tests/test_exception_register.py
@@ -73,4 +73,4 @@ async def boom_http():
body = http_resp.json()
assert body["code"] == 404
assert body["message"] == "接口不存在,请检查URL"
- assert body["data"] is None
\ No newline at end of file
+ assert body["data"] is None
diff --git a/backend/tests/test_file_api.py b/backend/tests/test_file_api.py
index e68ed0e8..13d0c9bd 100644
--- a/backend/tests/test_file_api.py
+++ b/backend/tests/test_file_api.py
@@ -8,8 +8,8 @@
async def test_upload_file(client, session_factory, monkeypatch, tmp_path):
"""上传成功:文件写入临时目录、avatar 更新、redis 缓存被清。"""
- from app.router import user as user_module
from app.models.user_model import User, UserStatusChoice
+ from app.router import user as user_module
from app.utils.auth_utils import hash_password
# 把媒体目录重定向到临时目录,避免污染仓库
@@ -44,6 +44,7 @@ async def test_upload_file(client, session_factory, monkeypatch, tmp_path):
# avatar 已更新
async with session_factory() as s:
from sqlalchemy import select
+
from app.models.user_model import User
result = await s.execute(select(User).where(User.uuid == TEST_USER_ID))
updated = result.scalar_one()
@@ -55,4 +56,4 @@ async def test_upload_file(client, session_factory, monkeypatch, tmp_path):
async def test_upload_file_requires_auth(raw_client):
resp = await raw_client.post("/file/upload/", files={"file": ("a.png", b"x", "image/png")})
- assert resp.status_code == 401
\ No newline at end of file
+ assert resp.status_code == 401
diff --git a/backend/tests/test_file_handler.py b/backend/tests/test_file_handler.py
index 0266d511..4fbae52c 100644
--- a/backend/tests/test_file_handler.py
+++ b/backend/tests/test_file_handler.py
@@ -181,4 +181,4 @@ def test_font_bbox_filter_suppresses_fontbox_lines():
if __name__ == "__main__":
- raise SystemExit(pytest.main([__file__, "-v"]))
\ No newline at end of file
+ raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index fd5dbd7e..4056957d 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -46,4 +46,4 @@ async def test_root(client):
async def test_hello_name(client):
resp = await client.get("/hello/world")
assert resp.status_code == 200
- assert resp.json() == {"message": "Hello world"}
\ No newline at end of file
+ assert resp.json() == {"message": "Hello world"}
diff --git a/backend/tests/test_image_extractor.py b/backend/tests/test_image_extractor.py
index 78c80cf1..2de4bbf5 100644
--- a/backend/tests/test_image_extractor.py
+++ b/backend/tests/test_image_extractor.py
@@ -85,4 +85,4 @@ def test_extract_images_from_pdf_relative_missing_returns_empty(fake_data_dir):
if __name__ == "__main__":
- raise SystemExit(pytest.main([__file__, "-v"]))
\ No newline at end of file
+ raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/backend/tests/test_input_sanitizer.py b/backend/tests/test_input_sanitizer.py
index ae42d1f9..9ee2392f 100644
--- a/backend/tests/test_input_sanitizer.py
+++ b/backend/tests/test_input_sanitizer.py
@@ -1,4 +1,3 @@
-import pytest
from app.utils.input_sanitizer import sanitize_content
diff --git a/backend/tests/test_knowledge_api.py b/backend/tests/test_knowledge_api.py
index 660440d9..af7c5e41 100644
--- a/backend/tests/test_knowledge_api.py
+++ b/backend/tests/test_knowledge_api.py
@@ -2,11 +2,9 @@
import base64
from fastapi import HTTPException
-from langchain_core.documents import Document
from app.rag.sse_models import SSEEvent
from app.router.knowledge_service import KnowledgeService
-from tests.conftest import install_fake_vector_store
from tests.fakes import TEST_USER_ID
@@ -70,9 +68,8 @@ async def handle_get_document_chunks(self, user_id, filename):
def install_fake_knowledge_service(monkeypatch):
- from main import app
-
import app.router.knowledge_router as kr
+ from main import app
service = FakeKnowledgeService()
app.dependency_overrides[kr.get_knowledge_service] = lambda: service
@@ -95,7 +92,7 @@ async def test_add_single(client, monkeypatch):
async def test_add_multiple(client, monkeypatch):
- service = install_fake_knowledge_service(monkeypatch)
+ install_fake_knowledge_service(monkeypatch)
resp = await client.post(
"/knowledge/add/multiple",
files=[("files", ("a.txt", b"aaa", "text/plain")), ("files", ("b.md", b"bbb", "text/markdown"))],
@@ -113,8 +110,8 @@ async def test_add_multiple_stream(client, monkeypatch):
headers={"Authorization": "Bearer x"},
) as resp:
assert resp.status_code == 200
- lines = [l async for l in resp.aiter_lines()]
- frames = [l for l in lines if l.startswith("event: ") or l.startswith("data: ")]
+ lines = [line async for line in resp.aiter_lines()]
+ frames = [line for line in lines if line.startswith("event: ") or line.startswith("data: ")]
assert frames
@@ -137,7 +134,7 @@ async def test_clear_md5(client, monkeypatch):
async def test_delete_single_md5(client, monkeypatch):
- service = install_fake_knowledge_service(monkeypatch)
+ install_fake_knowledge_service(monkeypatch)
resp = await client.delete("/knowledge/md5/delete/exists", headers={"Authorization": "Bearer x"})
assert resp.status_code == 200
assert "已成功删除MD5记录 exists" in resp.json()["message"]
@@ -148,7 +145,7 @@ async def test_delete_single_md5(client, monkeypatch):
async def test_delete_by_filename(client, monkeypatch):
- service = install_fake_knowledge_service(monkeypatch)
+ install_fake_knowledge_service(monkeypatch)
resp = await client.delete("/knowledge/delete/filename?filename=exists.txt", headers={"Authorization": "Bearer x"})
assert resp.status_code == 200
@@ -252,4 +249,4 @@ async def test_batch_images(client, monkeypatch, tmp_path):
assert "p0_i0.png" in data["images"]
assert data["images"]["p0_i0.png"].startswith("data:image/png;base64,")
payload = base64.b64decode(data["images"]["p0_i0.png"].split(",", 1)[1])
- assert payload == b"\x89PNG fake"
\ No newline at end of file
+ assert payload == b"\x89PNG fake"
diff --git a/backend/tests/test_models_orm.py b/backend/tests/test_models_orm.py
index a291758a..cc42bf6f 100644
--- a/backend/tests/test_models_orm.py
+++ b/backend/tests/test_models_orm.py
@@ -10,7 +10,6 @@
from app.models.review_record import ReviewRecord
from app.models.user_model import User, UserStatusChoice, generate_uuid
-
HEX24 = re.compile(r"^[0-9a-f]{24}$")
@@ -243,4 +242,4 @@ async def test_foreign_key_enforced(self, db_session):
# 指向不存在的 note 应报完整性错误(db_engine 已开启 PRAGMA foreign_keys)
db_session.add(ReviewRecord(id="rec-2", note_id="missing-note", user_id="u1"))
with pytest.raises(IntegrityError):
- await db_session.flush()
\ No newline at end of file
+ await db_session.flush()
diff --git a/backend/tests/test_note_api.py b/backend/tests/test_note_api.py
index 58e98ded..0e933c1a 100644
--- a/backend/tests/test_note_api.py
+++ b/backend/tests/test_note_api.py
@@ -1,11 +1,9 @@
"""笔记 API 集成测试(真实 NoteService + SQLite + 假 LLM)。"""
import asyncio
-import json
import zipfile
from tests.fakes import TEST_USER_ID
-
PASSWORD = None # unused
@@ -32,6 +30,7 @@ async def test_create_note(client, real_note_service, session_factory):
# 已入库
async with session_factory() as s:
from sqlalchemy import select
+
from app.models.note import Note
result = await s.execute(select(Note).where(Note.id == note["id"]))
assert result.scalar_one() is not None
@@ -39,12 +38,13 @@ async def test_create_note(client, real_note_service, session_factory):
async def test_create_note_starts_auto_tag_task(client, real_note_service, session_factory, monkeypatch):
"""未提供 tags/category 时,后台任务自动生成标签并创建回顾记录。"""
- from app.core.background_init import init_manager
from langchain_core.messages import AIMessage
- from app.models.review_record import ReviewRecord
- from app.models.note import Note
from sqlalchemy import select
+ from app.core.background_init import init_manager
+ from app.models.note import Note
+ from app.models.review_record import ReviewRecord
+
class CannedChat:
async def ainvoke(self, messages):
return AIMessage(content='{"tags": ["ai", "笔记"], "category": "work"}')
@@ -255,7 +255,7 @@ async def test_assist_stream(client, real_note_service):
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
lines = [line async for line in resp.aiter_lines()]
- data_frames = [l for l in lines if l.startswith("data: ")]
+ data_frames = [line for line in lines if line.startswith("data: ")]
assert data_frames
assert data_frames[-1] == "data: [DONE]"
@@ -312,4 +312,4 @@ async def test_export_and_download(client, real_note_service):
async def test_batch_operations_require_auth(raw_client):
resp = await raw_client.get("/note/list")
- assert resp.status_code == 401
\ No newline at end of file
+ assert resp.status_code == 401
diff --git a/backend/tests/test_note_template_api.py b/backend/tests/test_note_template_api.py
index c5515992..888f5af9 100644
--- a/backend/tests/test_note_template_api.py
+++ b/backend/tests/test_note_template_api.py
@@ -68,4 +68,4 @@ async def test_reorder_templates(client):
async def test_reorder_mismatch_fails(client):
# 传一个不属于用户的假 ID
resp = await client.put("/note-template/reorder", json={"ids": ["not-an-id"]}, headers={"Authorization": "Bearer x"})
- assert resp.json()["message"] == "排序失败,模板ID不匹配"
\ No newline at end of file
+ assert resp.json()["message"] == "排序失败,模板ID不匹配"
diff --git a/backend/tests/test_path_tool.py b/backend/tests/test_path_tool.py
index 7809f0cd..abf810d9 100644
--- a/backend/tests/test_path_tool.py
+++ b/backend/tests/test_path_tool.py
@@ -54,4 +54,4 @@ def test_get_config_path_contains_yaml_files():
assert os.path.isabs(cfg)
assert cfg == get_abstract_path("app/config")
assert os.path.isfile(os.path.join(cfg, "prompt.yaml"))
- assert os.path.isfile(os.path.join(cfg, "document.yaml"))
\ No newline at end of file
+ assert os.path.isfile(os.path.join(cfg, "document.yaml"))
diff --git a/backend/tests/test_pdf_multimodal_loader.py b/backend/tests/test_pdf_multimodal_loader.py
index f7ace02f..5c147944 100644
--- a/backend/tests/test_pdf_multimodal_loader.py
+++ b/backend/tests/test_pdf_multimodal_loader.py
@@ -4,11 +4,9 @@
视觉路径通过临时 monkeypatch 模块级开关单独验证,且不触碰真实 data 目录
(extract_images_from_pdf 全部打桩)。
"""
-import os
import fitz
import pytest
-from langchain_core.documents import Document
import app.utils.pdf_multimodal_loader as loader_mod
from app.utils.pdf_multimodal_loader import (
@@ -192,4 +190,4 @@ async def test_no_temp_pngs_left_in_vision_mode(self, tmp_path, monkeypatch):
pdf = _make_pdf(tmp_path)
docs = await pdf_multimodal_loader(str(pdf), "md5-1", "user-1")
- assert len(docs) == 2 # 正常返回,清理逻辑在 finally 中执行(不抛错)
\ No newline at end of file
+ assert len(docs) == 2 # 正常返回,清理逻辑在 finally 中执行(不抛错)
diff --git a/backend/tests/test_prompt_loader.py b/backend/tests/test_prompt_loader.py
index cdc36d4a..9d09661b 100644
--- a/backend/tests/test_prompt_loader.py
+++ b/backend/tests/test_prompt_loader.py
@@ -33,4 +33,4 @@ def test_default_uses_main_prompt():
def test_loaded_content_matches_file_on_disk():
for key, rel_path in prompt_config.items():
disk_content = open(get_abstract_path(rel_path), encoding="utf-8").read()
- assert load_prompt(key) == disk_content
\ No newline at end of file
+ assert load_prompt(key) == disk_content
diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py
index e716f948..591cbd2c 100644
--- a/backend/tests/test_rate_limit.py
+++ b/backend/tests/test_rate_limit.py
@@ -146,4 +146,4 @@ async def _send(message):
await middleware(_make_scope(), _receive, _send)
assert app.calls == 1
start = [m for m in sent if m["type"] == "http.response.start"]
- assert start and start[0]["status"] == 429
\ No newline at end of file
+ assert start and start[0]["status"] == 429
diff --git a/backend/tests/test_redis_decorator.py b/backend/tests/test_redis_decorator.py
index 85e493a1..bc123660 100644
--- a/backend/tests/test_redis_decorator.py
+++ b/backend/tests/test_redis_decorator.py
@@ -1,5 +1,4 @@
"""app/cache/redis_decorator.py 测试:缓存写入/命中、key 生成、删除与模式删除。"""
-import pytest
from app.cache.redis_decorator import RedisCache, cache_with_redis
from tests.fakes import install_fake_redis
@@ -195,4 +194,4 @@ async def load(user_id, page=1):
return {"user_id": user_id, "page": page}
await load("u1", page=1)
- assert redis._data["deco:u1:page:1"] is not None
\ No newline at end of file
+ assert redis._data["deco:u1:page:1"] is not None
diff --git a/backend/tests/test_review_api.py b/backend/tests/test_review_api.py
index 81e236a0..4a7df7d4 100644
--- a/backend/tests/test_review_api.py
+++ b/backend/tests/test_review_api.py
@@ -6,9 +6,10 @@
async def _seed_note_with_review(session_factory, note_id="note-1", title="值得回顾的笔记",
due_in_days=-1):
+ import uuid as uuidlib
+
from app.models.note import Note
from app.models.review_record import ReviewRecord
- import uuid as uuidlib
async with session_factory() as s:
note = Note(id=note_id, user_id=TEST_USER_ID, title=title, content="这是需要复习的内容", category="study")
@@ -58,9 +59,10 @@ async def test_mark_reviewed_missing_record(client):
async def test_review_question(client, session_factory, monkeypatch):
- from app.core.background_init import init_manager
from langchain_core.messages import AIMessage
+ from app.core.background_init import init_manager
+
await _seed_note_with_review(session_factory)
class CannedChat:
@@ -80,4 +82,4 @@ async def test_review_question_note_not_found(client):
resp = await client.get("/review/question/no-such-note", headers={"Authorization": "Bearer x"})
data = resp.json()["data"]
assert data["question"] == "笔记不存在"
- assert data["choices"] == []
\ No newline at end of file
+ assert data["choices"] == []
diff --git a/backend/tests/test_schemas_models.py b/backend/tests/test_schemas_models.py
index 14d99522..004b294c 100644
--- a/backend/tests/test_schemas_models.py
+++ b/backend/tests/test_schemas_models.py
@@ -12,7 +12,6 @@
ChunkInfo,
DocumentChunksResponse,
KnowledgeDocument,
- KnowledgeDocumentDetail,
KnowledgeListResponse,
MD5ListResponse,
MD5Record,
@@ -27,10 +26,10 @@
NoteUpdate,
PageRequest,
QueryRequest,
- ReorderRequest,
- ReorderResponse,
RelatedNoteItem,
RelatedNotesResponse,
+ ReorderRequest,
+ ReorderResponse,
SessionResponse,
)
@@ -313,4 +312,4 @@ class TestResponseModels:
def test_related_notes_response(self):
item = RelatedNoteItem(id="n1", title="t", content_preview="p", similarity=0.5, source="note")
model = RelatedNotesResponse(notes=[item])
- assert len(model.notes) == 1
\ No newline at end of file
+ assert len(model.notes) == 1
diff --git a/backend/tests/test_text_spliter.py b/backend/tests/test_text_spliter.py
index f27b3a2f..177943cc 100644
--- a/backend/tests/test_text_spliter.py
+++ b/backend/tests/test_text_spliter.py
@@ -200,4 +200,4 @@ def test_module_exports_async_text_splitter():
if __name__ == "__main__":
- raise SystemExit(pytest.main([__file__, "-v"]))
\ No newline at end of file
+ raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/backend/tests/test_user_api.py b/backend/tests/test_user_api.py
index 54d74c6f..4bcafa6f 100644
--- a/backend/tests/test_user_api.py
+++ b/backend/tests/test_user_api.py
@@ -276,9 +276,8 @@ async def test_protected_endpoint_invalid_token(raw_client):
async def test_protected_endpoint_revoked_token(raw_client, monkeypatch, session_factory):
"""Token 已被加入黑名单 → 401。"""
- from tests.fakes import FakeRedis
-
from app.utils.auth_utils import decode_django_jwt
+ from tests.fakes import FakeRedis
redis = FakeRedis()
await install_fake_redis(monkeypatch, redis)
@@ -289,4 +288,4 @@ async def test_protected_endpoint_revoked_token(raw_client, monkeypatch, session
await redis.set(f"blacklist:{payload['jti']}", "1")
resp = await raw_client.get("/user/detail/", headers={"Authorization": f"Bearer {token}"})
- assert resp.status_code == 401
\ No newline at end of file
+ assert resp.status_code == 401
diff --git a/backend/tests/test_user_schemas.py b/backend/tests/test_user_schemas.py
index d0e1d700..65fc987c 100644
--- a/backend/tests/test_user_schemas.py
+++ b/backend/tests/test_user_schemas.py
@@ -232,4 +232,4 @@ def test_user_detail_response(self):
def test_user_detail_response_requires_data(self):
with pytest.raises(ValidationError) as excinfo:
UserDetailResponse(success=True, message="ok", data={})
- assert _has_error_for(excinfo, "data")
\ No newline at end of file
+ assert _has_error_for(excinfo, "data")
diff --git a/backend/tests/test_vision_service.py b/backend/tests/test_vision_service.py
index 41b6f6f2..7e7c489e 100644
--- a/backend/tests/test_vision_service.py
+++ b/backend/tests/test_vision_service.py
@@ -1,6 +1,5 @@
"""vision_service.py — VisionService 测试(假视觉模型 + tmp 图片文件)。"""
import base64
-import os
import pytest
from langchain_core.messages import AIMessage
@@ -201,4 +200,4 @@ def test_hamming_distance(self):
assert VisionService.hamming_distance(h1, h2) == 0
assert VisionService.hamming_distance(h1, "f" * 16) == 64
assert VisionService.hamming_distance("", h2) == 999
- assert VisionService.hamming_distance(None, None) == 999
\ No newline at end of file
+ assert VisionService.hamming_distance(None, None) == 999
diff --git a/backend/uv.lock b/backend/uv.lock
index 5a5d254d..918cc229 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -246,7 +246,6 @@ dependencies = [
{ name = "shortuuid" },
{ name = "sqlalchemy" },
{ name = "starlette" },
- { name = "unstructured", extra = ["all-docs"] },
{ name = "uvicorn" },
]
@@ -261,6 +260,11 @@ dev = [
{ name = "ruff" },
]
+[package.dev-dependencies]
+parsers = [
+ { name = "unstructured", extra = ["all-docs"] },
+]
+
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = "==25.1.0" },
@@ -301,11 +305,13 @@ requires-dist = [
{ name = "shortuuid", specifier = "==1.0.11" },
{ name = "sqlalchemy", specifier = "==2.0.48" },
{ name = "starlette", specifier = "==0.50.0" },
- { name = "unstructured", extras = ["all-docs"], specifier = "==0.22.20" },
{ name = "uvicorn", specifier = "==0.21.1" },
]
provides-extras = ["dev"]
+[package.metadata.requires-dev]
+parsers = [{ name = "unstructured", extras = ["all-docs"], specifier = "==0.22.20" }]
+
[[package]]
name = "bcrypt"
version = "4.3.0"
diff --git a/front/eslint.config.js b/front/eslint.config.js
index ef614d25..c9c446a6 100644
--- a/front/eslint.config.js
+++ b/front/eslint.config.js
@@ -6,7 +6,8 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
- globalIgnores(['dist']),
+ // tailwind.config.cjs 混用 ESM/CJS 语法(经由构建工具链可运行),不纳入 lint
+ globalIgnores(['dist', 'tailwind.config.cjs']),
{
files: ['**/*.{ts,tsx}'],
extends: [
@@ -19,4 +20,9 @@ export default defineConfig([
globals: globals.browser,
},
},
+ {
+ // 路由文件天然同时导出组件与路由表,关闭 fast-refresh 误报(置于主规则之后以覆盖)
+ files: ['src/router/**/*.{ts,tsx}'],
+ rules: { 'react-refresh/only-export-components': 'off' },
+ },
])
diff --git a/front/src/components/common/AuthImage.tsx b/front/src/components/common/AuthImage.tsx
index 395c59b0..8d747230 100644
--- a/front/src/components/common/AuthImage.tsx
+++ b/front/src/components/common/AuthImage.tsx
@@ -11,32 +11,33 @@ export default function AuthImage({ src, alt, className }: AuthImageProps) {
const [loaded, setLoaded] = useState(false)
const mountedRef = useRef(true)
- useEffect(() => {
- mountedRef.current = true
+ // src 变化时在 render 阶段同步重置(与原 effect 语义一致,避免 effect 内同步 setState)
+ const [prevSrc, setPrevSrc] = useState(undefined)
+ if (src !== prevSrc) {
+ setPrevSrc(src)
setBlobUrl(null)
setLoaded(false)
+ }
- const token = localStorage.getItem('jwt_token')
- if (!token) {
- setBlobUrl(src)
- return
- }
+ useEffect(() => {
+ mountedRef.current = true
- fetch(src, { headers: { Authorization: `Bearer ${token}` } })
- .then((res) => {
- if (!res.ok) throw new Error('Auth image load failed')
- return res.blob()
- })
- .then((blob) => {
- if (mountedRef.current) {
- setBlobUrl(URL.createObjectURL(blob))
- }
- })
- .catch(() => {
- if (mountedRef.current) {
- setBlobUrl(src)
- }
- })
+ const token = localStorage.getItem('jwt_token')
+ const load: Promise = token
+ ? fetch(src, { headers: { Authorization: `Bearer ${token}` } })
+ .then((res) => {
+ if (!res.ok) throw new Error('Auth image load failed')
+ return res.blob()
+ })
+ .then((blob) => URL.createObjectURL(blob))
+ .catch(() => src)
+ : Promise.resolve(src)
+
+ load.then((url) => {
+ if (mountedRef.current) {
+ setBlobUrl(url)
+ }
+ })
return () => {
mountedRef.current = false
diff --git a/front/src/components/knowledge/DocumentDetailDrawer.tsx b/front/src/components/knowledge/DocumentDetailDrawer.tsx
index 3512c747..14847b08 100644
--- a/front/src/components/knowledge/DocumentDetailDrawer.tsx
+++ b/front/src/components/knowledge/DocumentDetailDrawer.tsx
@@ -20,14 +20,20 @@ export default function DocumentDetailDrawer({ filename, onClose }: DocumentDeta
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
+ // filename 变化时在 render 阶段同步重置(与原 effect 语义一致,避免 effect 内同步 setState)
+ const [prevFilename, setPrevFilename] = useState(undefined)
+ if (filename !== prevFilename) {
+ setPrevFilename(filename)
+ setDetail(null)
+ setLoading(!!filename)
+ setError(false)
+ setTab('content')
+ }
+
useEffect(() => {
if (!filename) {
- setDetail(null)
return
}
- setLoading(true)
- setError(false)
- setTab('content')
knowledgeApi.detail(filename)
.then((res) => setDetail(res.data))
.catch(() => setError(true))
diff --git a/front/src/components/note/CategoryManageDialog.tsx b/front/src/components/note/CategoryManageDialog.tsx
index e8a377ac..1bf163c1 100644
--- a/front/src/components/note/CategoryManageDialog.tsx
+++ b/front/src/components/note/CategoryManageDialog.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from 'react'
+import { useState, useRef } from 'react'
import { Trash2, Plus, FolderTree, X, GripVertical } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { notesApi } from '../../api/notes'
@@ -57,9 +57,12 @@ export default function CategoryManageDialog({
const [items, setItems] = useState([])
const dragItem = useRef(null)
- useEffect(() => {
+ // open 翻转时在 render 阶段同步重置本地 items(与原 effect 语义一致,避免 effect 内同步 setState)
+ const [prevOpen, setPrevOpen] = useState(undefined)
+ if (open !== prevOpen) {
+ setPrevOpen(open)
setItems([...categories])
- }, [open])
+ }
if (!open) return null
diff --git a/front/src/components/note/RelatedFragments.tsx b/front/src/components/note/RelatedFragments.tsx
index 884b7be4..0ac8e403 100644
--- a/front/src/components/note/RelatedFragments.tsx
+++ b/front/src/components/note/RelatedFragments.tsx
@@ -14,9 +14,16 @@ export default function RelatedFragments({ noteId, open, onClose }: Props) {
const [loading, setLoading] = useState(false)
const [expandedId, setExpandedId] = useState(null)
+ // 对话框目标变化时在 render 阶段同步置 loading(与原 effect 语义一致,避免 effect 内同步 setState)
+ const activeKey = open ? noteId : null
+ const [prevKey, setPrevKey] = useState(undefined)
+ if (activeKey !== prevKey) {
+ setPrevKey(activeKey)
+ if (activeKey !== null) setLoading(true)
+ }
+
useEffect(() => {
if (!open || !noteId) return
- setLoading(true)
notesApi.related(noteId)
.then((res) => setFragments(res.data ?? []))
.catch(() => {})
diff --git a/front/src/pages/AIChat.tsx b/front/src/pages/AIChat.tsx
index 2d5c7dac..1387228b 100644
--- a/front/src/pages/AIChat.tsx
+++ b/front/src/pages/AIChat.tsx
@@ -91,6 +91,15 @@ export default function AIChat() {
const [expandedApproval, setExpandedApproval] = useState>({})
const [showThinking, setShowThinking] = useState(true)
const [loadingHistory, setLoadingHistory] = useState(false)
+ // 会话切换时在 render 阶段同步重置审批与历史加载态(与原 effect 语义一致,避免 effect 内同步 setState)
+ const [prevSessionId, setPrevSessionId] = useState(null)
+ if (sessionId !== prevSessionId) {
+ setPrevSessionId(sessionId)
+ setPendingApproval(null)
+ setApprovalNotice(null)
+ setRejectReason('')
+ setLoadingHistory(!!sessionId)
+ }
const messagesEndRef = useRef(null)
const contentRef = useRef('')
const rafRef = useRef(null)
@@ -187,10 +196,6 @@ export default function AIChat() {
useEffect(() => {
if (sessionId) {
- setPendingApproval(null)
- setApprovalNotice(null)
- setRejectReason('')
- setLoadingHistory(true)
sessionsApi.get(sessionId).then((res) => {
const data = res.data as { history?: [string, string][]; pending_run_id?: string | null } | undefined
if (data?.history) {
diff --git a/front/src/pages/KnowledgeBase.tsx b/front/src/pages/KnowledgeBase.tsx
index 4fe6babf..05ca0552 100644
--- a/front/src/pages/KnowledgeBase.tsx
+++ b/front/src/pages/KnowledgeBase.tsx
@@ -43,6 +43,8 @@ export default function KnowledgeBase() {
}
}
+ // 挂载时拉取文档列表:loadDocs 首行的 setLoading(true) 仅服务于手动刷新路径(loading 初始值已为 true);单次提交,无级联渲染
+ // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { loadDocs() }, [])
const handleFilesSelected = (files: FileList) => {
diff --git a/front/src/pages/NoteList.tsx b/front/src/pages/NoteList.tsx
index 9f5a6f59..a8b08c00 100644
--- a/front/src/pages/NoteList.tsx
+++ b/front/src/pages/NoteList.tsx
@@ -137,12 +137,21 @@ export default function NoteList() {
}
}, [])
- useEffect(() => {
+ // 筛选条件变化时在 render 阶段同步重置页码(与原 effect 语义一致,避免 effect 内同步 setState)
+ const [prevCategory, setPrevCategory] = useState(null)
+ const [prevSearchQuery, setPrevSearchQuery] = useState(null)
+ if (category !== prevCategory || searchQuery !== prevSearchQuery) {
+ setPrevCategory(category)
+ setPrevSearchQuery(searchQuery)
setPage(1)
+ }
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- 筛选变化时重新拉取:setLoading(true) 是单次 fetch 开始标志,setPage(1) 已在 render 阶段同步;无级联渲染
loadNotes(1, true)
}, [category, searchQuery])
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- 分类统计刷新:setState 均在 await 请求回调之后触发,单次提交,无级联渲染
refreshCategories(extraCategories)
}, [extraCategories])
diff --git a/front/src/pages/Profile.tsx b/front/src/pages/Profile.tsx
index 5281f19c..8226e93a 100644
--- a/front/src/pages/Profile.tsx
+++ b/front/src/pages/Profile.tsx
@@ -40,7 +40,10 @@ export default function Profile() {
}
}, [token])
- useEffect(() => {
+ // userInfo 变化时在 render 阶段同步表单(store 已持久化,挂载时即可能有值;与原 effect 语义一致)
+ const [prevUserInfo, setPrevUserInfo] = useState(undefined)
+ if (userInfo !== prevUserInfo) {
+ setPrevUserInfo(userInfo)
if (userInfo) {
setForm({
username: (userInfo.username as string) || '',
@@ -50,7 +53,7 @@ export default function Profile() {
bio: (userInfo.bio as string) || '',
})
}
- }, [userInfo])
+ }
const handleSave = async () => {
setLoading(true)