From b953409996a2c926d6b08e445de223786ed4b8d7 Mon Sep 17 00:00:00 2001 From: NieZiPeng Date: Tue, 15 Sep 2026 11:10:16 +0800 Subject: [PATCH 1/5] =?UTF-8?q?style=EF=BC=9A=E6=B8=85=E9=9B=B6=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20ruff=20=E5=AD=98=E9=87=8F=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动修复导入排序、未使用导入与文件末尾换行 - 合并 graph_router 重复导入块,删除 agent 流式 dead code - 测试与脚本的歧义变量改名、超长行与分号拆分 --- backend/app/agent/agent.py | 10 ++++----- backend/app/agent/agent_middleware.py | 3 ++- backend/app/cache/redis_decorator.py | 2 +- backend/app/core/failed_response.py | 2 +- backend/app/db/db_config.py | 6 ++--- backend/app/graph/extraction/__init__.py | 2 +- .../app/graph/extraction/entity_extractor.py | 4 ++-- backend/app/graph/extraction/link_parser.py | 2 +- backend/app/graph/routers/__init__.py | 2 +- backend/app/graph/routers/graph_router.py | 22 ++++++++----------- backend/app/graph/services/__init__.py | 2 +- backend/app/graph/services/event_bus.py | 2 +- backend/app/graph/storage/graph_store.py | 2 +- backend/app/rag/agentic_rag/evidence.py | 1 - backend/app/rag/agentic_rag/planner.py | 3 --- .../rag/agentic_rag/query_entity_extractor.py | 1 - backend/app/rag/agentic_rag/schemas.py | 1 - backend/app/rag/agentic_rag/web_search.py | 1 - backend/app/rag/vector_store.py | 2 +- backend/app/router/chat.py | 1 - backend/app/router/chat_service.py | 1 - backend/app/router/note_router.py | 2 +- backend/app/services/note_template_service.py | 7 +++--- backend/app/utils/auth_utils.py | 1 - backend/app/utils/input_sanitizer.py | 2 +- backend/app/utils/pdf_multimodal_loader.py | 2 +- backend/main.py | 2 +- backend/scripts/check_graph_data.py | 7 +++--- backend/scripts/eval_end2end.py | 4 ++-- backend/scripts/eval_graph_entity_recall.py | 3 +-- backend/scripts/eval_graph_multihop.py | 7 ++++-- backend/scripts/eval_ingest.py | 2 -- backend/scripts/eval_recall.py | 5 ++--- backend/scripts/fix_graph_docs.py | 2 +- backend/tests/agent/test_agent_middleware.py | 2 +- backend/tests/agent/test_mysql_saver.py | 2 -- backend/tests/conftest.py | 12 +++++----- backend/tests/fakes.py | 4 +--- backend/tests/graph/conftest.py | 3 +-- backend/tests/graph/test_graph_events.py | 5 +---- backend/tests/graph/test_graph_models.py | 6 +---- backend/tests/graph/test_graph_schemas.py | 3 +-- backend/tests/graph/test_graph_store.py | 2 +- backend/tests/graph/test_neo4j_pipeline.py | 3 --- backend/tests/rag/test_document_processor.py | 2 +- backend/tests/rag/test_md5_store.py | 2 +- backend/tests/rag/test_reorder_service.py | 1 - backend/tests/rag/test_sse_models.py | 4 ++-- backend/tests/rag/test_task_queue.py | 2 +- backend/tests/rag/test_text_spliter.py | 2 +- .../services/test_database_session_manager.py | 4 +--- backend/tests/services/test_note_service.py | 8 +++---- .../services/test_note_template_service.py | 2 +- backend/tests/services/test_review_service.py | 4 +--- backend/tests/test_auth_utils.py | 3 +-- backend/tests/test_chat_api.py | 22 +++++++++---------- backend/tests/test_config_handler.py | 2 +- backend/tests/test_core_responses.py | 2 +- backend/tests/test_exception_register.py | 2 +- backend/tests/test_file_api.py | 5 +++-- backend/tests/test_file_handler.py | 2 +- backend/tests/test_health.py | 2 +- backend/tests/test_image_extractor.py | 2 +- backend/tests/test_input_sanitizer.py | 1 - backend/tests/test_knowledge_api.py | 17 ++++++-------- backend/tests/test_models_orm.py | 3 +-- backend/tests/test_note_api.py | 14 ++++++------ backend/tests/test_note_template_api.py | 2 +- backend/tests/test_path_tool.py | 2 +- backend/tests/test_pdf_multimodal_loader.py | 4 +--- backend/tests/test_prompt_loader.py | 2 +- backend/tests/test_rate_limit.py | 2 +- backend/tests/test_redis_decorator.py | 3 +-- backend/tests/test_review_api.py | 8 ++++--- backend/tests/test_schemas_models.py | 7 +++--- backend/tests/test_text_spliter.py | 2 +- backend/tests/test_user_api.py | 5 ++--- backend/tests/test_user_schemas.py | 2 +- backend/tests/test_vision_service.py | 3 +-- 79 files changed, 130 insertions(+), 180 deletions(-) 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/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/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_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 From d31abde2a3842a66dc175597a401b97b24904932 Mon Sep 17 00:00:00 2001 From: NieZiPeng Date: Tue, 15 Sep 2026 11:10:30 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20eslint=20=E6=8A=A5=E9=94=99=E5=B9=B6=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E7=B1=BB=E5=9E=8B=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - effect 内同步 setState 改为 render 阶段同步重置(语义不变) - 路由文件关闭 react-refresh 误报,tailwind 配置移出 lint - 数据拉取 helper 的同步置位加豁免注释说明原因 --- front/eslint.config.js | 8 +++- front/src/components/common/AuthImage.tsx | 45 ++++++++++--------- .../knowledge/DocumentDetailDrawer.tsx | 14 ++++-- .../components/note/CategoryManageDialog.tsx | 9 ++-- .../src/components/note/RelatedFragments.tsx | 9 +++- front/src/pages/AIChat.tsx | 13 ++++-- front/src/pages/KnowledgeBase.tsx | 2 + front/src/pages/NoteList.tsx | 11 ++++- front/src/pages/Profile.tsx | 7 ++- 9 files changed, 80 insertions(+), 38 deletions(-) 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) From bba96c678ecf055595560ef02f98ee7a59060649 Mon Sep 17 00:00:00 2001 From: NieZiPeng Date: Tue, 15 Sep 2026 11:10:46 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore=EF=BC=9A=E6=96=87=E6=A1=A3=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=BE=9D=E8=B5=96=E6=8B=86=E5=88=86=E4=B8=BA=20parser?= =?UTF-8?q?s=20=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unstructured 移入 dependency-groups,默认安装行为不变 - md/ppt 解析缺依赖时给出明确日志而非静默跳过 - 删除已与 uv.lock 漂移的 requirements.txt --- backend/app/utils/file_handler.py | 12 + backend/pyproject.toml | 9 +- backend/requirements.txt | 691 ------------------------------ backend/uv.lock | 10 +- 4 files changed, 28 insertions(+), 694 deletions(-) delete mode 100644 backend/requirements.txt 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/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/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" From 615d942981c994422374a3699c35a03e1eda2c8a Mon Sep 17 00:00:00 2001 From: NieZiPeng Date: Tue, 15 Sep 2026 11:11:19 +0800 Subject: [PATCH 4/5] =?UTF-8?q?ci=EF=BC=9A=E6=96=B0=E5=A2=9E=20GitHub=20Ac?= =?UTF-8?q?tions=20=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 lint + 测试(跳过 torch 依赖组)与重依赖缺席断言 - 前端 lint + 类型检查 + 生产构建 - README 补充 CI 徽章与后端安装说明 --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++++ README.md | 9 +++++- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml 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 @@ Forks + + + CI Python @@ -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 From 2ff4ccb7827ab25431d0c4d13edd3c6beb11a776 Mon Sep 17 00:00:00 2001 From: NieZiPeng Date: Tue, 15 Sep 2026 11:19:28 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E5=8D=95?= =?UTF-8?q?=E4=BE=8B=20mock=20=E5=A4=B1=E6=95=88=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=20CI=20=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_agent 测试改用 agent_factory 单例,使实例级 mock 生效 - 根因:新实例绕过 mock 走真实模型,本地靠 .env 泄露的凭证偶然通过 --- backend/tests/agent/test_agent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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)