From 5bcc354811bf77e573ddc521408114758dac91b1 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Sun, 15 Mar 2026 16:30:22 +0800 Subject: [PATCH 1/9] fix(backend,frontend): implement Phase 0 bug fixes and architecture improvements - Fix adjacent chunk assembly producing duplicated/disordered context (P0) - Fix apply_resolution allowing skipped papers to be imported (P0) - Fix KaTeX CSS missing import for formula rendering - Fix PaddleOCR hardcoded English lang, default to ch (Chinese+English) - Fix index_node N+1 query with batch PaperChunk loading - Add RAGService.retrieve_only() to avoid redundant LLM calls in chat pipeline - Make retrieve_node top_k configurable via ChatState - Unify OCR chunking to use semantic chunk_text() instead of per-page splits - Add section and chunk_type to index metadata for richer RAG retrieval - Unify dedup thresholds in config.py, align pipeline with DedupService - Replace MemorySaver with AsyncSqliteSaver for persistent pipeline checkpoints - Remove duplicate /knowledge-bases API routes - Create LLMConfigResolver for unified LLM config across chat/RAG/writing Made-with: Cursor --- backend/app/api/v1/__init__.py | 3 - backend/app/config.py | 12 ++ backend/app/pipelines/chat/nodes.py | 7 +- backend/app/pipelines/chat/state.py | 2 + backend/app/pipelines/graphs.py | 21 +++- backend/app/pipelines/nodes.py | 49 ++++++--- backend/app/services/dedup_service.py | 8 +- backend/app/services/llm/client.py | 39 +------ backend/app/services/llm_config_resolver.py | 103 ++++++++++++++++++ backend/app/services/ocr_service.py | 2 +- backend/app/services/rag_service.py | 102 ++++++++++++++--- backend/app/services/user_settings_service.py | 32 +----- frontend/src/main.tsx | 1 + 13 files changed, 276 insertions(+), 105 deletions(-) create mode 100644 backend/app/services/llm_config_resolver.py diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index f5bde3f..a3b3ea4 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -27,9 +27,6 @@ api_router.include_router(projects.router, prefix="/projects") api_router.include_router(papers.router, prefix="/projects/{project_id}/papers") api_router.include_router(upload.router, prefix="/projects/{project_id}/papers") -api_router.include_router(projects.router, prefix="/knowledge-bases", tags=["knowledge-bases"]) -api_router.include_router(papers.router, prefix="/knowledge-bases/{project_id}/papers", tags=["knowledge-bases"]) -api_router.include_router(upload.router, prefix="/knowledge-bases/{project_id}/papers", tags=["knowledge-bases"]) api_router.include_router(keywords.router) api_router.include_router(search.router) api_router.include_router(dedup.router) diff --git a/backend/app/config.py b/backend/app/config.py index d139a2b..f4cf1af 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -69,6 +69,16 @@ class Settings(BaseSettings): embedding_api_key: str = "" reranker_model: str = "BAAI/bge-reranker-v2-m3" + # OCR + ocr_lang: str = "ch" # PaddleOCR language: ch (Chinese+English) | en (English only) + + # Dedup thresholds + dedup_title_hard_threshold: float = 0.90 + dedup_title_llm_threshold: float = 0.80 + + # LangGraph + langgraph_checkpoint_dir: str = "" + # GPU cuda_visible_devices: str = "0,3" @@ -95,6 +105,8 @@ def __init__(self, **kwargs): self.ocr_output_dir = f"{self.data_dir}/ocr_output" if not self.chroma_db_dir: self.chroma_db_dir = f"{self.data_dir}/chroma_db" + if not self.langgraph_checkpoint_dir: + self.langgraph_checkpoint_dir = f"{self.data_dir}/langgraph_checkpoints" @property def cors_origin_list(self) -> list[str]: diff --git a/backend/app/pipelines/chat/nodes.py b/backend/app/pipelines/chat/nodes.py index aad4a25..d4ad3e7 100644 --- a/backend/app/pipelines/chat/nodes.py +++ b/backend/app/pipelines/chat/nodes.py @@ -157,7 +157,8 @@ async def retrieve_node(state: ChatState, config: RunnableConfig) -> dict[str, A detail=f"Searching in {len(kb_ids)} knowledge base(s)...", ) - tasks = [rag.query(project_id=kb_id, question=state["message"], top_k=5, include_sources=True) for kb_id in kb_ids] + top_k = state.get("rag_top_k") or 10 + tasks = [rag.retrieve_only(project_id=kb_id, question=state["message"], top_k=top_k) for kb_id in kb_ids] results = await asyncio.gather(*tasks, return_exceptions=True) all_sources: list[dict[str, Any]] = [] @@ -165,8 +166,8 @@ async def retrieve_node(state: ChatState, config: RunnableConfig) -> dict[str, A if isinstance(result, Exception): logger.warning("RAG query failed for a KB: %s", result) continue - if result.get("sources"): - all_sources.extend(result["sources"]) + if isinstance(result, list): + all_sources.extend(result) _emit_thinking( writer, diff --git a/backend/app/pipelines/chat/state.py b/backend/app/pipelines/chat/state.py index 244ce10..d7909f8 100644 --- a/backend/app/pipelines/chat/state.py +++ b/backend/app/pipelines/chat/state.py @@ -36,6 +36,8 @@ class ChatState(TypedDict, total=False): tool_mode: str conversation_id: int | None model: str + rag_top_k: int + use_reranker: bool # --- Intermediate (between nodes) --- rag_results: list[dict[str, Any]] diff --git a/backend/app/pipelines/graphs.py b/backend/app/pipelines/graphs.py index 6ccfc70..da5958e 100644 --- a/backend/app/pipelines/graphs.py +++ b/backend/app/pipelines/graphs.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, StateGraph @@ -25,6 +26,22 @@ _memory_saver = MemorySaver() +def _get_checkpointer(): + """Return a persistent SQLite checkpointer if available, else MemorySaver.""" + try: + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + from app.config import settings + + cp_dir = settings.langgraph_checkpoint_dir + os.makedirs(cp_dir, exist_ok=True) + cp_path = os.path.join(cp_dir, "checkpoints.db") + return AsyncSqliteSaver.from_conn_string(cp_path) + except Exception: + logger.warning("SQLite checkpointer unavailable, falling back to MemorySaver") + return _memory_saver + + def _route_after_dedup(state: PipelineState) -> str: if state.get("conflicts"): return "hitl_dedup" @@ -64,7 +81,7 @@ def create_search_pipeline(checkpointer=None): graph.add_edge("ocr", "index") graph.add_edge("index", END) - return graph.compile(checkpointer=checkpointer or _memory_saver) + return graph.compile(checkpointer=checkpointer or _get_checkpointer()) def create_upload_pipeline(checkpointer=None): @@ -99,4 +116,4 @@ def create_upload_pipeline(checkpointer=None): graph.add_edge("ocr", "index") graph.add_edge("index", END) - return graph.compile(checkpointer=checkpointer or _memory_saver) + return graph.compile(checkpointer=checkpointer or _get_checkpointer()) diff --git a/backend/app/pipelines/nodes.py b/backend/app/pipelines/nodes.py index c64c6a8..b9c48e5 100644 --- a/backend/app/pipelines/nodes.py +++ b/backend/app/pipelines/nodes.py @@ -59,6 +59,7 @@ async def dedup_node(state: PipelineState) -> dict[str, Any]: """Check for duplicates against existing papers in the knowledge base.""" from sqlalchemy import select + from app.config import settings from app.database import async_session_factory from app.models import Paper from app.services.dedup_service import DedupService @@ -68,6 +69,7 @@ async def dedup_node(state: PipelineState) -> dict[str, Any]: if not new_papers: return {"papers": [], "conflicts": [], "stage": "dedup", "progress": 40} + title_threshold = settings.dedup_title_hard_threshold conflicts: list[dict] = [] clean_papers: list[dict] = [] @@ -103,7 +105,7 @@ async def dedup_node(state: PipelineState) -> dict[str, Any]: if not norm_new or not enorm: continue sim = SequenceMatcher(None, norm_new, enorm).ratio() - if sim >= 0.85: + if sim >= title_threshold: match = next((p for p in existing if p.id == eid), None) if match: conflicts.append( @@ -153,7 +155,7 @@ async def apply_resolution_node(state: PipelineState) -> dict[str, Any]: for res in resolved: action = res.get("action", "skip") new_paper = res.get("new_paper", {}) - if action in ("keep_new", "skip") and new_paper: + if action == "keep_new" and new_paper: clean_papers.append(new_paper) return {"papers": clean_papers, "stage": "resolved"} @@ -265,19 +267,21 @@ async def ocr_node(state: PipelineState) -> dict[str, Any]: paper.status = PaperStatus.ERROR continue - chunk_idx = 0 - for page in result.get("pages", []): - text = page.get("text", "").strip() - if text: - db.add( - PaperChunk( - paper_id=paper.id, - content=text, - page_number=page.get("page_number", 0), - chunk_index=chunk_idx, - ) + pages = result.get("pages", []) + chunks = ocr.chunk_text(pages, chunk_size=1024, overlap=100) + + for chunk_data in chunks: + db.add( + PaperChunk( + paper_id=paper.id, + content=chunk_data["content"], + page_number=chunk_data.get("page_number", 0), + chunk_index=chunk_data["chunk_index"], + chunk_type=chunk_data.get("chunk_type", "text"), + section=chunk_data.get("section", ""), + token_count=chunk_data.get("token_count", 0), ) - chunk_idx += 1 + ) paper.status = PaperStatus.OCR_COMPLETE processed += 1 @@ -313,10 +317,23 @@ async def index_node(state: PipelineState) -> dict[str, Any]: papers = (await db.execute(stmt)).scalars().all() rag = RAGService() + paper_ids = [p.id for p in papers] + all_chunks = ( + (await db.execute(select(PaperChunk).where(PaperChunk.paper_id.in_(paper_ids)))).scalars().all() + if paper_ids + else [] + ) + + from collections import defaultdict + + chunks_by_paper: dict[int, list] = defaultdict(list) + for c in all_chunks: + chunks_by_paper[c.paper_id].append(c) + for paper in papers: if state.get("cancelled"): break - chunks = (await db.execute(select(PaperChunk).where(PaperChunk.paper_id == paper.id))).scalars().all() + chunks = chunks_by_paper.get(paper.id, []) if not chunks: continue @@ -328,6 +345,8 @@ async def index_node(state: PipelineState) -> dict[str, Any]: "content": c.content, "page_number": c.page_number, "chunk_index": c.chunk_index, + "chunk_type": c.chunk_type, + "section": c.section, } for c in chunks ] diff --git a/backend/app/services/dedup_service.py b/backend/app/services/dedup_service.py index 5317966..7a25a74 100644 --- a/backend/app/services/dedup_service.py +++ b/backend/app/services/dedup_service.py @@ -33,9 +33,13 @@ def __init__(self, db: AsyncSession, llm: LLMClient | None = None): async def run_full_dedup(self, project_id: int) -> dict: """Run all 3 stages of dedup and return results.""" + from app.config import settings + stage1 = await self.doi_hard_dedup(project_id) - stage2 = await self.title_similarity_dedup(project_id, threshold=0.90) - stage3_candidates = await self.find_llm_dedup_candidates(project_id, threshold=0.80) + stage2 = await self.title_similarity_dedup(project_id, threshold=settings.dedup_title_hard_threshold) + stage3_candidates = await self.find_llm_dedup_candidates( + project_id, threshold=settings.dedup_title_llm_threshold + ) remaining = ( await self.db.execute(select(func.count(Paper.id)).where(Paper.project_id == project_id)) diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index c24a692..ecf83ae 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -15,7 +15,6 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage -from app.config import settings from app.schemas.llm import LLMConfig from app.services.llm.factory import get_chat_model @@ -24,41 +23,9 @@ def _resolve_config(provider: str | None = None) -> LLMConfig: """Build an LLMConfig from env settings for the given (or default) provider.""" - prov = provider or settings.llm_provider - - key_map: dict[str, tuple[str, str, str]] = { - "openai": ( - getattr(settings, "openai_api_key", ""), - "", - getattr(settings, "openai_model", "gpt-4o-mini"), - ), - "anthropic": ( - getattr(settings, "anthropic_api_key", ""), - "", - getattr(settings, "anthropic_model", "claude-sonnet-4-20250514"), - ), - "aliyun": (settings.aliyun_api_key, settings.aliyun_base_url, settings.aliyun_model), - "volcengine": ( - settings.volcengine_api_key, - settings.volcengine_base_url, - settings.volcengine_model, - ), - "ollama": ( - "", - getattr(settings, "ollama_base_url", "http://localhost:11434"), - getattr(settings, "ollama_model", "llama3"), - ), - "mock": ("", "", "mock-model"), - } - - api_key, base_url, model = key_map.get(prov, ("", "", "")) - - return LLMConfig( - provider=prov, - api_key=api_key, - base_url=base_url, - model=model, - ) + from app.services.llm_config_resolver import LLMConfigResolver + + return LLMConfigResolver.from_env(provider=provider) def _to_langchain_messages(messages: list[dict[str, str]]) -> list: diff --git a/backend/app/services/llm_config_resolver.py b/backend/app/services/llm_config_resolver.py new file mode 100644 index 0000000..a3e1aa0 --- /dev/null +++ b/backend/app/services/llm_config_resolver.py @@ -0,0 +1,103 @@ +"""Unified LLM configuration resolver. + +All modules should obtain LLM instances through this resolver to ensure +consistent provider/model/temperature resolution. Priority order: + + explicit parameter > user DB settings > env / config.py defaults +""" + +from __future__ import annotations + +import logging + +from app.config import settings +from app.schemas.llm import LLMConfig + +logger = logging.getLogger(__name__) + +_PROVIDER_KEY_MAP_FIELDS: dict[str, tuple[str, str, str]] = { + "openai": ("openai_api_key", "", "openai_model"), + "anthropic": ("anthropic_api_key", "", "anthropic_model"), + "aliyun": ("aliyun_api_key", "aliyun_base_url", "aliyun_model"), + "volcengine": ("volcengine_api_key", "volcengine_base_url", "volcengine_model"), + "ollama": ("", "ollama_base_url", "ollama_model"), + "mock": ("", "", ""), +} + + +class LLMConfigResolver: + """Centralised config builder so every module resolves LLM the same way.""" + + @staticmethod + def from_env( + *, + provider: str | None = None, + model: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + ) -> LLMConfig: + """Build an ``LLMConfig`` from env / ``config.py`` defaults. + + Callers may override individual fields; anything left ``None`` falls + back to the values declared in ``Settings``. + """ + prov = provider or settings.llm_provider + + field_keys = _PROVIDER_KEY_MAP_FIELDS.get(prov, ("", "", "")) + api_key_attr, base_url_attr, model_attr = field_keys + + api_key = getattr(settings, api_key_attr, "") if api_key_attr else "" + base_url = getattr(settings, base_url_attr, "") if base_url_attr else "" + default_model = getattr(settings, model_attr, "") if model_attr else "mock-model" + + return LLMConfig( + provider=prov, + api_key=api_key, + base_url=base_url, + model=model or default_model, + temperature=temperature if temperature is not None else settings.llm_temperature, + max_tokens=max_tokens if max_tokens is not None else settings.llm_max_tokens, + ) + + @staticmethod + def from_merged( + merged_settings, + *, + temperature: float | None = None, + max_tokens: int | None = None, + ) -> LLMConfig: + """Build an ``LLMConfig`` from a ``MergedSettings`` object (user DB + env). + + Used by endpoints that honour per-user overrides (chat, rewrite, etc.). + """ + provider = merged_settings.llm_provider + + key_map: dict[str, tuple[str, str, str]] = { + "openai": (merged_settings.openai_api_key, "", merged_settings.openai_model or "gpt-4o-mini"), + "anthropic": ( + merged_settings.anthropic_api_key, + "", + merged_settings.anthropic_model or "claude-sonnet-4-20250514", + ), + "aliyun": (merged_settings.aliyun_api_key, merged_settings.aliyun_base_url, merged_settings.aliyun_model), + "volcengine": ( + merged_settings.volcengine_api_key, + merged_settings.volcengine_base_url, + merged_settings.volcengine_model, + ), + "ollama": ("", merged_settings.ollama_base_url, merged_settings.ollama_model), + "mock": ("", "", "mock-model"), + } + api_key, base_url, model = key_map.get(provider, ("", "", "")) + + if merged_settings.llm_model: + model = merged_settings.llm_model + + return LLMConfig( + provider=provider, + api_key=api_key, + base_url=base_url, + model=model, + temperature=temperature if temperature is not None else settings.llm_temperature, + max_tokens=max_tokens if max_tokens is not None else settings.llm_max_tokens, + ) diff --git a/backend/app/services/ocr_service.py b/backend/app/services/ocr_service.py index 9199c73..6a5c8ae 100644 --- a/backend/app/services/ocr_service.py +++ b/backend/app/services/ocr_service.py @@ -114,7 +114,7 @@ def _get_paddle_ocr(self): use_doc_orientation_classify=False, use_doc_unwarping=False, use_textline_orientation=False, - lang="en", + lang=getattr(settings, "ocr_lang", "ch"), use_gpu=self.use_gpu, gpu_id=self.gpu_id, ) diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index f31d646..6c99447 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -122,6 +122,7 @@ async def index_chunks( "chunk_type": chunk.get("chunk_type", "text"), "page_number": chunk.get("page_number", 0), "chunk_index": chunk.get("chunk_index", 0), + "section": chunk.get("section", ""), }, excluded_embed_metadata_keys=["paper_id", "chunk_index"], excluded_llm_metadata_keys=["paper_id", "chunk_index"], @@ -164,29 +165,45 @@ async def index_documents( await asyncio.to_thread(index.insert_nodes, nodes) return {"indexed": len(nodes), "collection": f"project_{project_id}"} + @staticmethod + def _smart_truncate(text: str, max_len: int = 800) -> str: + if len(text) <= max_len: + return text + truncated = text[:max_len] + last_break = max(truncated.rfind("。"), truncated.rfind(". "), truncated.rfind("\n")) + if last_break > max_len * 0.5: + return truncated[: last_break + 1] + "..." + return truncated + "..." + def _get_adjacent_chunks( self, collection: chromadb.Collection, paper_id: int, chunk_index: int, window: int = 1, - ) -> str: + ) -> tuple[str, str]: """Fetch adjacent chunks for context expansion. - Returns combined text of [chunk_index - window, ..., chunk_index + window]. + Returns ``(prev_text, next_text)`` so the caller can assemble + ``[prev] \\n [main] \\n [next]`` in the correct order. """ - target_ids = [ - f"paper_{paper_id}_chunk_{chunk_index + offset}" for offset in range(-window, window + 1) if offset != 0 - ] - if not target_ids: - return "" + prev_ids = [f"paper_{paper_id}_chunk_{chunk_index + offset}" for offset in range(-window, 0)] + next_ids = [f"paper_{paper_id}_chunk_{chunk_index + offset}" for offset in range(1, window + 1)] + prev_text = "" + next_text = "" try: - result = collection.get(ids=target_ids, include=["documents"]) - docs = result.get("documents") or [] - return "\n".join(d for d in docs if d) + if prev_ids: + result = collection.get(ids=prev_ids, include=["documents"]) + docs = result.get("documents") or [] + prev_text = "\n".join(d for d in docs if d) + if next_ids: + result = collection.get(ids=next_ids, include=["documents"]) + docs = result.get("documents") or [] + next_text = "\n".join(d for d in docs if d) except Exception: - return "" + pass + return prev_text, next_text async def query( self, @@ -224,16 +241,17 @@ async def query( paper_id = meta.get("paper_id") chunk_idx = meta.get("chunk_index") - adjacent_text = "" + prev_text, next_text = "", "" if paper_id is not None and chunk_idx is not None: - adjacent_text = await asyncio.to_thread( + prev_text, next_text = await asyncio.to_thread( self._get_adjacent_chunks, collection, paper_id, chunk_idx, ) - full_context = f"{adjacent_text}\n{text}\n{adjacent_text}".strip() if adjacent_text else text + parts = [p for p in [prev_text, text, next_text] if p] + full_context = "\n".join(parts) contexts.append( f"[Source: {meta.get('paper_title', 'Unknown')}, p.{meta.get('page_number', '?')}]\n{full_context}" @@ -245,7 +263,7 @@ async def query( "page_number": meta.get("page_number"), "chunk_type": meta.get("chunk_type", "text"), "relevance_score": round(float(score), 3), - "excerpt": full_context[:800] + "..." if len(full_context) > 800 else full_context, + "excerpt": self._smart_truncate(full_context), } ) @@ -264,6 +282,60 @@ async def query( "confidence": round(avg_score, 3), } + async def retrieve_only( + self, + project_id: int, + question: str, + top_k: int = 10, + ) -> list[dict]: + """Retrieve relevant chunks without LLM generation. + + Designed for the Chat Pipeline where the LLM call happens + downstream in the generate node, avoiding a redundant call here. + """ + collection = self._get_collection(project_id) + if collection.count() == 0: + return [] + + import asyncio + + index = self._get_index(project_id) + retriever = index.as_retriever(similarity_top_k=min(top_k, collection.count())) + retrieved_nodes = await asyncio.to_thread(retriever.retrieve, question) + + sources: list[dict] = [] + for node_with_score in retrieved_nodes: + node = node_with_score.node + meta = node.metadata or {} + score = node_with_score.score or 0.0 + text = node.get_content() + + paper_id = meta.get("paper_id") + chunk_idx = meta.get("chunk_index") + prev_text, next_text = "", "" + if paper_id is not None and chunk_idx is not None: + prev_text, next_text = await asyncio.to_thread( + self._get_adjacent_chunks, + collection, + paper_id, + chunk_idx, + ) + parts = [p for p in [prev_text, text, next_text] if p] + full_context = "\n".join(parts) + + sources.append( + { + "paper_id": paper_id, + "paper_title": meta.get("paper_title", ""), + "page_number": meta.get("page_number"), + "chunk_type": meta.get("chunk_type", "text"), + "section": meta.get("section", ""), + "relevance_score": round(float(score), 3), + "excerpt": self._smart_truncate(full_context), + } + ) + return sources + async def _generate_answer(self, question: str, context: str) -> str: prompt = ( "Based on the following scientific literature excerpts, answer the question.\n" diff --git a/backend/app/services/user_settings_service.py b/backend/app/services/user_settings_service.py index f015639..3fbc6cf 100644 --- a/backend/app/services/user_settings_service.py +++ b/backend/app/services/user_settings_service.py @@ -171,35 +171,11 @@ def _resolve(key: str, env_val: str | float | int) -> str: async def get_merged_llm_config(self) -> LLMConfig: """Build an LLMConfig from merged settings for the active provider.""" + from app.services.llm_config_resolver import LLMConfigResolver + merged = await self.get_merged_settings(mask_sensitive=False) - provider = merged.llm_provider - - key_map: dict[str, tuple[str, str, str]] = { - "openai": (merged.openai_api_key, "", merged.openai_model or "gpt-4o-mini"), - "anthropic": ( - merged.anthropic_api_key, - "", - merged.anthropic_model or "claude-sonnet-4-20250514", - ), - "aliyun": (merged.aliyun_api_key, merged.aliyun_base_url, merged.aliyun_model), - "volcengine": ( - merged.volcengine_api_key, - merged.volcengine_base_url, - merged.volcengine_model, - ), - "ollama": ("", merged.ollama_base_url, merged.ollama_model), - "mock": ("", "", "mock-model"), - } - api_key, base_url, model = key_map.get(provider, ("", "", "")) - - if merged.llm_model: - model = merged.llm_model - - return LLMConfig( - provider=provider, - api_key=api_key, - base_url=base_url, - model=model, + return LLMConfigResolver.from_merged( + merged, temperature=merged.llm_temperature, max_tokens=merged.llm_max_tokens, ) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 45f8ea9..87433c6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import 'katex/dist/katex.min.css' import './i18n' import './index.css' import App from './App' From ea339c47dd54f09c34528dbce74f96c56991b4d2 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Sun, 15 Mar 2026 17:50:27 +0800 Subject: [PATCH 2/9] feat(backend): integrate MinerU PDF parsing engine with dual-tier OCR architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MinerU HTTP client (mineru_client.py) for standalone API service - Refactor OCRService with async process_pdf_async() method: MinerU → pdfplumber → PaddleOCR fallback - Add chunk_mineru_markdown() for structured Markdown parsing into text/table/figure_caption chunks - Extend PaperChunk model with has_formula and figure_path fields + Alembic migration - Update pipeline nodes (ocr_node, index_node) and paper_processor to use new async path - Propagate has_formula/figure_path metadata through RAG indexing pipeline - Add MinerU deployment guide (docs/deployment/mineru-setup.md) - Update config defaults: pdf_parser, mineru_api_url, mineru_backend (pipeline), mineru_timeout Made-with: Cursor --- .env.example | 10 + ...39f_add_has_formula_and_figure_path_to_.py | 36 +++ backend/app/config.py | 6 + backend/app/models/chunk.py | 4 +- backend/app/pipelines/nodes.py | 21 +- backend/app/services/mineru_client.py | 109 ++++++++ backend/app/services/ocr_service.py | 239 ++++++++++++++++-- backend/app/services/paper_processor.py | 12 +- backend/app/services/rag_service.py | 6 +- docs/deployment/mineru-setup.md | 178 +++++++++++++ 10 files changed, 590 insertions(+), 31 deletions(-) create mode 100644 backend/alembic/versions/f2bee250c39f_add_has_formula_and_figure_path_to_.py create mode 100644 backend/app/services/mineru_client.py create mode 100644 docs/deployment/mineru-setup.md diff --git a/.env.example b/.env.example index eec80bd..438b190 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,16 @@ LLM_PROVIDER=mock EMBEDDING_MODEL=BAAI/bge-m3 RERANKER_MODEL=BAAI/bge-reranker-v2-m3 +# --- PDF Parsing --- +# Parser selection: auto (pdfplumber first, fallback to MinerU) | mineru | pdfplumber +PDF_PARSER=auto +# MinerU independent API service URL +MINERU_API_URL=http://localhost:8010 +# MinerU backend: pipeline | hybrid-auto-engine | vlm-auto-engine +MINERU_BACKEND=pipeline +# Timeout per PDF in seconds +MINERU_TIMEOUT=300 + # --- GPU --- # Comma-separated GPU IDs for OCR/embedding tasks CUDA_VISIBLE_DEVICES=0,3 diff --git a/backend/alembic/versions/f2bee250c39f_add_has_formula_and_figure_path_to_.py b/backend/alembic/versions/f2bee250c39f_add_has_formula_and_figure_path_to_.py new file mode 100644 index 0000000..7f835c1 --- /dev/null +++ b/backend/alembic/versions/f2bee250c39f_add_has_formula_and_figure_path_to_.py @@ -0,0 +1,36 @@ +"""add has_formula and figure_path to paper_chunks + +Revision ID: f2bee250c39f +Revises: e8f2a3b1c4d5 +Create Date: 2026-03-15 17:30:22.425479 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f2bee250c39f" +down_revision: str | Sequence[str] | None = "e8f2a3b1c4d5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("paper_chunks", sa.Column("has_formula", sa.Boolean(), server_default=sa.text("0"), nullable=False)) + op.add_column( + "paper_chunks", sa.Column("figure_path", sa.String(length=500), server_default=sa.text("''"), nullable=False) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("paper_chunks", "figure_path") + op.drop_column("paper_chunks", "has_formula") + # ### end Alembic commands ### diff --git a/backend/app/config.py b/backend/app/config.py index f4cf1af..7402c78 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -72,6 +72,12 @@ class Settings(BaseSettings): # OCR ocr_lang: str = "ch" # PaddleOCR language: ch (Chinese+English) | en (English only) + # PDF Parsing / MinerU + pdf_parser: str = "auto" # auto | mineru | pdfplumber + mineru_api_url: str = "http://localhost:8010" + mineru_backend: str = "pipeline" # pipeline | hybrid-auto-engine | vlm-auto-engine + mineru_timeout: int = 300 + # Dedup thresholds dedup_title_hard_threshold: float = 0.90 dedup_title_llm_threshold: float = 0.80 diff --git a/backend/app/models/chunk.py b/backend/app/models/chunk.py index 494dcdd..1dbe567 100644 --- a/backend/app/models/chunk.py +++ b/backend/app/models/chunk.py @@ -2,7 +2,7 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -20,6 +20,8 @@ class PaperChunk(Base): chunk_index: Mapped[int] = mapped_column(Integer, default=0) chroma_id: Mapped[str] = mapped_column(String(255), default="", index=True) token_count: Mapped[int] = mapped_column(Integer, default=0) + has_formula: Mapped[bool] = mapped_column(Boolean, default=False) + figure_path: Mapped[str] = mapped_column(String(500), default="") created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) paper = relationship("Paper", back_populates="chunks") diff --git a/backend/app/pipelines/nodes.py b/backend/app/pipelines/nodes.py index b9c48e5..dea9545 100644 --- a/backend/app/pipelines/nodes.py +++ b/backend/app/pipelines/nodes.py @@ -236,7 +236,11 @@ async def crawl_node(state: PipelineState) -> dict[str, Any]: async def ocr_node(state: PipelineState) -> dict[str, Any]: - """Run OCR on downloaded PDFs and create text chunks.""" + """Run OCR on downloaded PDFs and create text chunks. + + Uses MinerU (if available) for deep parsing with formula/table/figure + recognition, falling back to pdfplumber + PaddleOCR. + """ from sqlalchemy import select from app.database import async_session_factory @@ -260,15 +264,16 @@ async def ocr_node(state: PipelineState) -> dict[str, Any]: if state.get("cancelled"): break try: - import asyncio - - result = await asyncio.to_thread(ocr.process_pdf, paper.pdf_path) + result = await ocr.process_pdf_async(paper.pdf_path) if result.get("error"): paper.status = PaperStatus.ERROR continue - pages = result.get("pages", []) - chunks = ocr.chunk_text(pages, chunk_size=1024, overlap=100) + if result.get("method") == "mineru": + chunks = ocr.chunk_mineru_markdown(result["md_content"], chunk_size=1024, overlap=100) + else: + pages = result.get("pages", []) + chunks = ocr.chunk_text(pages, chunk_size=1024, overlap=100) for chunk_data in chunks: db.add( @@ -280,6 +285,8 @@ async def ocr_node(state: PipelineState) -> dict[str, Any]: chunk_type=chunk_data.get("chunk_type", "text"), section=chunk_data.get("section", ""), token_count=chunk_data.get("token_count", 0), + has_formula=chunk_data.get("has_formula", False), + figure_path=chunk_data.get("figure_path", ""), ) ) @@ -347,6 +354,8 @@ async def index_node(state: PipelineState) -> dict[str, Any]: "chunk_index": c.chunk_index, "chunk_type": c.chunk_type, "section": c.section, + "has_formula": c.has_formula, + "figure_path": c.figure_path, } for c in chunks ] diff --git a/backend/app/services/mineru_client.py b/backend/app/services/mineru_client.py new file mode 100644 index 0000000..15f7058 --- /dev/null +++ b/backend/app/services/mineru_client.py @@ -0,0 +1,109 @@ +"""MinerU API client — communicates with the standalone MinerU FastAPI service.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class MinerUClient: + """Async HTTP client for the MinerU PDF parsing service.""" + + def __init__( + self, + base_url: str | None = None, + backend: str | None = None, + timeout: int | None = None, + ): + self.base_url = (base_url or settings.mineru_api_url).rstrip("/") + self.backend = backend or settings.mineru_backend + self.timeout = timeout or settings.mineru_timeout + + async def health_check(self) -> bool: + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(f"{self.base_url}/docs") + return resp.status_code == 200 + except Exception: + return False + + async def parse_pdf( + self, + pdf_path: str | Path, + *, + backend: str | None = None, + formula_enable: bool = True, + table_enable: bool = True, + return_content_list: bool = False, + lang_list: list[str] | None = None, + start_page: int = 0, + end_page: int = 99999, + ) -> dict[str, Any]: + """Send a PDF to MinerU for parsing and return the result. + + Returns dict with keys: md_content, content_list (optional), backend, version. + On failure returns {"error": "..."}. + """ + pdf_path = Path(pdf_path) + if not pdf_path.exists(): + return {"error": f"File not found: {pdf_path}"} + + use_backend = backend or self.backend + data: dict[str, Any] = { + "backend": use_backend, + "return_md": "true", + "return_content_list": str(return_content_list).lower(), + "return_images": "false", + "formula_enable": str(formula_enable).lower(), + "table_enable": str(table_enable).lower(), + "start_page_id": str(start_page), + "end_page_id": str(end_page), + } + if lang_list: + for lang in lang_list: + data.setdefault("lang_list", []).append(lang) + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + with pdf_path.open("rb") as f: + files = {"files": (pdf_path.name, f, "application/pdf")} + resp = await client.post( + f"{self.base_url}/file_parse", + data=data, + files=files, + ) + + if resp.status_code != 200: + return {"error": f"MinerU API returned {resp.status_code}: {resp.text[:500]}"} + + body = resp.json() + + if isinstance(body, dict) and "error" in body: + return {"error": body["error"]} + + results = body.get("results", {}) + if not results: + return {"error": "MinerU returned empty results"} + + file_result = next(iter(results.values())) + return { + "md_content": file_result.get("md_content", ""), + "content_list": file_result.get("content_list", []), + "backend": body.get("backend", use_backend), + "version": body.get("version", "unknown"), + } + + except httpx.TimeoutException: + return {"error": f"MinerU API timeout after {self.timeout}s"} + except httpx.ConnectError: + return {"error": f"Cannot connect to MinerU at {self.base_url}"} + except Exception as e: + logger.error("MinerU parse failed for %s: %s", pdf_path, e, exc_info=True) + return {"error": str(e)} diff --git a/backend/app/services/ocr_service.py b/backend/app/services/ocr_service.py index 6a5c8ae..5f28ec4 100644 --- a/backend/app/services/ocr_service.py +++ b/backend/app/services/ocr_service.py @@ -1,7 +1,15 @@ -"""OCR service — extract text from PDFs using marker-pdf, pdfplumber, and PaddleOCR.""" +"""OCR service — extract text from PDFs. + +Supports two extraction tiers: + 1. MinerU API (deep parsing with formula/table/figure recognition) + 2. pdfplumber (lightweight native text extraction, always available) + +Fallback chain: MinerU → pdfplumber → PaddleOCR (scanned PDFs). +""" import json import logging +import re from pathlib import Path import pdfplumber @@ -12,13 +20,14 @@ class OCRService: - """Extracts text from PDFs. Priority: native pdfplumber → marker-pdf → PaddleOCR.""" + """Extracts text from PDFs with MinerU + pdfplumber dual-tier architecture.""" def __init__(self, use_gpu: bool = True, gpu_id: int = 0): self.use_gpu = use_gpu self.gpu_id = gpu_id self._paddle_ocr = None self._marker_converter = None + self._mineru_client = None self.output_dir = Path(settings.ocr_output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) @@ -173,8 +182,44 @@ def extract_text_ocr(self, pdf_path: str) -> list[dict]: return pages + async def _extract_with_mineru(self, pdf_path: str) -> dict | None: + """Try MinerU API. Returns result dict or None if unavailable/failed.""" + if settings.pdf_parser == "pdfplumber": + return None + + from app.services.mineru_client import MinerUClient + + if self._mineru_client is None: + self._mineru_client = MinerUClient() + + if not await self._mineru_client.health_check(): + logger.info("MinerU service not available, skipping") + return None + + result = await self._mineru_client.parse_pdf(pdf_path) + if result.get("error"): + logger.warning("MinerU failed for %s: %s", pdf_path, result["error"]) + return None + + md_content = result.get("md_content", "") + if not md_content or len(md_content.strip()) < 50: + logger.info("MinerU returned insufficient content for %s", pdf_path) + return None + + return { + "method": "mineru", + "md_content": md_content, + "content_list": result.get("content_list", []), + "backend": result.get("backend", ""), + "version": result.get("version", ""), + "total_chars": len(md_content), + } + def process_pdf(self, pdf_path: str, force_ocr: bool = False) -> dict: - """Process a PDF: native → marker-pdf → PaddleOCR fallback chain.""" + """Process a PDF: pdfplumber → PaddleOCR fallback chain (sync path). + + For MinerU integration, use ``process_pdf_async`` instead. + """ path = Path(pdf_path) if not path.exists(): return {"error": f"File not found: {pdf_path}", "pages": []} @@ -195,19 +240,7 @@ def process_pdf(self, pdf_path: str, force_ocr: bool = False) -> dict: "pages_with_text": pages_with_text, } - logger.info("Native extraction insufficient for %s, trying marker-pdf", pdf_path) - marker_pages = self.extract_text_marker(pdf_path) - if marker_pages: - total_chars = sum(p["char_count"] for p in marker_pages) - return { - "method": "marker", - "pages": marker_pages, - "total_pages": len(marker_pages), - "total_chars": total_chars, - "pages_with_text": sum(1 for p in marker_pages if p.get("has_text")), - } - - logger.info("marker-pdf unavailable/failed for %s, trying PaddleOCR", pdf_path) + logger.info("Native extraction insufficient for %s, trying PaddleOCR", pdf_path) pages = self.extract_text_ocr(pdf_path) total_chars = sum(len(p.get("text", "")) for p in pages) @@ -224,9 +257,6 @@ def process_pdf(self, pdf_path: str, force_ocr: bool = False) -> dict: native_chars = sum(p["char_count"] for p in native_pages) native_with_text = sum(1 for p in native_pages if p["has_text"]) if native_chars > 0: - logger.info( - "All OCR methods failed, using native extraction with %d chars for %s", native_chars, pdf_path - ) return { "method": "native", "pages": native_pages, @@ -243,12 +273,183 @@ def process_pdf(self, pdf_path: str, force_ocr: bool = False) -> dict: "pages_with_text": 0, } + async def process_pdf_async(self, pdf_path: str, force_ocr: bool = False) -> dict: + """Process a PDF with MinerU priority, falling back to pdfplumber/PaddleOCR. + + Returns either: + - MinerU result: {"method": "mineru", "md_content": "...", ...} + - Legacy result: {"method": "native"|"paddleocr"|"failed", "pages": [...], ...} + """ + if not force_ocr and settings.pdf_parser != "pdfplumber": + mineru_result = await self._extract_with_mineru(pdf_path) + if mineru_result: + return mineru_result + + import asyncio + + return await asyncio.to_thread(self.process_pdf, pdf_path, force_ocr) + def save_result(self, paper_id: int, result: dict) -> Path: """Save OCR result to JSON file.""" output_path = self.output_dir / f"paper_{paper_id}.json" output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2)) return output_path + def chunk_mineru_markdown(self, md_content: str, chunk_size: int = 1024, overlap: int = 100) -> list[dict]: + """Parse MinerU Markdown output into typed chunks (text/table/figure_caption). + + MinerU outputs Markdown with: + - ``$...$`` / ``$$...$$`` for formulas + - ``|...|`` pipe-delimited tables + - ``![...]()`` image references + - ``# ...`` section headings + """ + chunks: list[dict] = [] + chunk_index = 0 + current_section = "" + current_text = "" + current_page = 1 + + lines = md_content.split("\n") + i = 0 + while i < len(lines): + line = lines[i] + + heading_match = re.match(r"^(#{1,4})\s+(.+)$", line) + if heading_match: + if current_text.strip(): + chunks.extend( + self._flush_text_chunk( + current_text, current_section, current_page, chunk_index, chunk_size, overlap + ) + ) + chunk_index = len(chunks) + current_section = heading_match.group(2).strip() + current_text = "" + i += 1 + continue + + if line.startswith("|") and i + 1 < len(lines) and re.match(r"^\|[\s\-:|]+\|", lines[i + 1]): + if current_text.strip(): + chunks.extend( + self._flush_text_chunk( + current_text, current_section, current_page, chunk_index, chunk_size, overlap + ) + ) + chunk_index = len(chunks) + current_text = "" + + table_lines = [] + while i < len(lines) and lines[i].startswith("|"): + table_lines.append(lines[i]) + i += 1 + table_text = "\n".join(table_lines) + chunks.append( + { + "content": table_text, + "page_number": current_page, + "chunk_index": chunk_index, + "chunk_type": "table", + "section": current_section, + "has_formula": "$" in table_text, + "token_count": len(table_text.split()), + } + ) + chunk_index += 1 + continue + + img_match = re.match(r"!\[([^\]]*)\]\(([^)]+)\)", line) + if img_match: + if current_text.strip(): + chunks.extend( + self._flush_text_chunk( + current_text, current_section, current_page, chunk_index, chunk_size, overlap + ) + ) + chunk_index = len(chunks) + current_text = "" + + caption = img_match.group(1).strip() + figure_path = img_match.group(2).strip() + if caption: + chunks.append( + { + "content": caption, + "page_number": current_page, + "chunk_index": chunk_index, + "chunk_type": "figure_caption", + "section": current_section, + "figure_path": figure_path, + "has_formula": "$" in caption, + "token_count": len(caption.split()), + } + ) + chunk_index += 1 + i += 1 + continue + + current_text += line + "\n" + i += 1 + + if current_text.strip(): + chunks.extend( + self._flush_text_chunk(current_text, current_section, current_page, chunk_index, chunk_size, overlap) + ) + + return chunks + + def _flush_text_chunk( + self, + text: str, + section: str, + page_number: int, + start_index: int, + chunk_size: int, + overlap: int, + ) -> list[dict]: + """Split accumulated text into sized chunks, preserving paragraph boundaries.""" + chunks: list[dict] = [] + paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] + current = "" + idx = start_index + + for para in paragraphs: + if len(current) + len(para) > chunk_size and current: + has_formula = "$" in current + chunks.append( + { + "content": current.strip(), + "page_number": page_number, + "chunk_index": idx, + "chunk_type": "text", + "section": section, + "has_formula": has_formula, + "token_count": len(current.split()), + } + ) + words = current.split() + overlap_text = " ".join(words[-overlap:]) if len(words) > overlap else "" + current = overlap_text + " " + para + idx += 1 + else: + current += "\n\n" + para if current else para + + if current.strip(): + has_formula = "$" in current + chunks.append( + { + "content": current.strip(), + "page_number": page_number, + "chunk_index": idx, + "chunk_type": "text", + "section": section, + "has_formula": has_formula, + "token_count": len(current.split()), + } + ) + + return chunks + def chunk_text(self, pages: list[dict], chunk_size: int = 1024, overlap: int = 100) -> list[dict]: """Split extracted text into chunks for RAG indexing.""" chunks = [] diff --git a/backend/app/services/paper_processor.py b/backend/app/services/paper_processor.py index 2c02264..1f7a4ee 100644 --- a/backend/app/services/paper_processor.py +++ b/backend/app/services/paper_processor.py @@ -6,7 +6,6 @@ from __future__ import annotations -import asyncio import logging from sqlalchemy import select @@ -55,7 +54,7 @@ async def _process_papers(project_id: int, paper_ids: list[int]) -> None: continue try: - result = await asyncio.to_thread(ocr.process_pdf, paper.pdf_path) + result = await ocr.process_pdf_async(paper.pdf_path) if result.get("error"): paper.status = PaperStatus.ERROR @@ -64,7 +63,10 @@ async def _process_papers(project_id: int, paper_ids: list[int]) -> None: ocr.save_result(paper.id, result) - chunks = ocr.chunk_text(result.get("pages", [])) + if result.get("method") == "mineru": + chunks = ocr.chunk_mineru_markdown(result["md_content"]) + else: + chunks = ocr.chunk_text(result.get("pages", [])) for chunk_data in chunks: db.add( PaperChunk( @@ -74,6 +76,8 @@ async def _process_papers(project_id: int, paper_ids: list[int]) -> None: page_number=chunk_data.get("page_number"), chunk_index=chunk_data["chunk_index"], token_count=chunk_data.get("token_count", 0), + has_formula=chunk_data.get("has_formula", False), + figure_path=chunk_data.get("figure_path", ""), ) ) @@ -108,6 +112,8 @@ async def _process_papers(project_id: int, paper_ids: list[int]) -> None: "page_number": chunk.page_number or 0, "chunk_index": chunk.chunk_index or 0, "content": chunk.content, + "has_formula": chunk.has_formula, + "figure_path": chunk.figure_path, } ) diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index 6c99447..a1af81b 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -123,9 +123,11 @@ async def index_chunks( "page_number": chunk.get("page_number", 0), "chunk_index": chunk.get("chunk_index", 0), "section": chunk.get("section", ""), + "has_formula": chunk.get("has_formula", False), + "figure_path": chunk.get("figure_path", ""), }, - excluded_embed_metadata_keys=["paper_id", "chunk_index"], - excluded_llm_metadata_keys=["paper_id", "chunk_index"], + excluded_embed_metadata_keys=["paper_id", "chunk_index", "has_formula", "figure_path"], + excluded_llm_metadata_keys=["paper_id", "chunk_index", "figure_path"], ) node.relationships[NodeRelationship.SOURCE] = RelatedNodeInfo(node_id=ref_doc_id) nodes.append(node) diff --git a/docs/deployment/mineru-setup.md b/docs/deployment/mineru-setup.md new file mode 100644 index 0000000..528eabf --- /dev/null +++ b/docs/deployment/mineru-setup.md @@ -0,0 +1,178 @@ +# MinerU 独立服务部署指南 + +MinerU 是 Omelette 的 PDF 深度解析引擎,通过独立 FastAPI 服务部署,与主后端通过 HTTP API 通信。 + +## 系统要求 + +| 项目 | 要求 | +|------|------| +| Python | 3.10 - 3.13 | +| GPU | 6GB+ VRAM(pipeline 后端)/ 10GB+(hybrid 后端) | +| 内存 | 16GB+,推荐 32GB | +| 磁盘 | 20GB+ SSD(含模型文件) | +| CUDA | 11.8 / 12.4 / 12.6 / 12.8 | + +## 安装步骤 + +### 1. 创建独立 conda 环境 + +MinerU 的依赖与 Omelette 主后端有冲突(如 pydantic 版本),必须使用独立环境。 + +```bash +conda create -n mineru python=3.12 -y +conda activate mineru +``` + +### 2. 安装 MinerU + +```bash +# 升级 pip +/path/to/miniconda3/envs/mineru/bin/pip install --upgrade pip + +# 安装 mineru(v2.x 包名为 mineru,非旧版 magic-pdf) +/path/to/miniconda3/envs/mineru/bin/pip install -U "mineru[all]" +``` + +### 3. 下载模型 + +模型文件约 5-10GB(取决于选择),首次下载需要较长时间。 + +**推荐使用 ModelScope 源(国内加速):** + +```bash +MINERU_MODEL_SOURCE=modelscope /path/to/miniconda3/envs/mineru/bin/mineru-models-download +``` + +下载时会提示选择模型源和类型: +- **模型源**:选择 `modelscope`(国内)或 `huggingface`(国际) +- **模型类型**: + - `pipeline` — 传统规则+小模型,体积小,推荐先安装 + - `vlm` — VLM 视觉语言模型(1.2B 参数),需额外 GPU 显存 + - `all` — 全部安装,体积较大(约 8GB),如需使用 hybrid 后端则必选 + +> 提示:如果只使用 `pipeline` 后端,选择 `pipeline` 类型即可,下载更快。 +> `hybrid` 后端需要 `all` 类型的模型。 + +### 4. 配置 LaTeX 分隔符 + +模型下载完成后会自动生成 `~/mineru.json`,确认其中包含正确的 LaTeX 分隔符配置: + +```json +{ + "latex-delimiter-config": { + "inline": { "left": "$", "right": "$" }, + "display": { "left": "$$", "right": "$$" } + } +} +``` + +此配置确保输出的 LaTeX 公式与前端 KaTeX 的 `remark-math` 插件兼容。 + +### 5. 启动 API 服务 + +```bash +# 指定 GPU(避免与 Omelette 的 embedding 模型争用) +# 设置 MINERU_MODEL_SOURCE=local 使用本地已下载的模型 +CUDA_VISIBLE_DEVICES=6 MINERU_MODEL_SOURCE=local \ + /path/to/miniconda3/envs/mineru/bin/mineru-api --host 0.0.0.0 --port 8010 +``` + +验证:访问 `http://localhost:8010/docs` 查看 Swagger 文档。 + +### 6. 测试解析 + +```bash +curl -s -X POST http://localhost:8010/file_parse \ + -F "files=@test.pdf" \ + -F "backend=pipeline" \ + -F "return_md=true" \ + -F "formula_enable=true" \ + -F "table_enable=true" +``` + +## API 参考 + +### `POST /file_parse` + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `files` | file[] | (必填) | 上传的 PDF 文件 | +| `backend` | str | `hybrid-auto-engine` | 解析后端 | +| `return_md` | bool | `true` | 返回 Markdown 内容 | +| `return_content_list` | bool | `false` | 返回结构化内容列表 | +| `return_images` | bool | `false` | 返回提取的图片 | +| `formula_enable` | bool | `true` | 启用公式解析 | +| `table_enable` | bool | `true` | 启用表格解析 | +| `lang_list` | str[] | `["ch"]` | OCR 语言(ch 支持中英混合) | +| `start_page_id` | int | `0` | 起始页码(从 0 开始) | +| `end_page_id` | int | `99999` | 结束页码 | + +### 可用后端 + +| 后端 | 精度 | 速度 | GPU 需求 | 说明 | +|------|------|------|----------|------| +| `pipeline` | 中 (82+) | 快 | 6GB | 传统规则+小模型,多语言,无幻觉 | +| `hybrid-auto-engine` | 高 (90+) | 中 | 10GB | VLM + pipeline 融合,需下载 all 模型 | +| `vlm-auto-engine` | 高 | 中 | 8GB | 纯 VLM,仅支持中英文 | + +### 响应格式 + +```json +{ + "backend": "pipeline", + "version": "2.7.6", + "results": { + "": { + "md_content": "# Title\n\nContent with $E=mc^2$ formulas..." + } + } +} +``` + +## Omelette 配置 + +在 Omelette 的 `.env` 中添加: + +```env +PDF_PARSER=auto +MINERU_API_URL=http://localhost:8010 +MINERU_BACKEND=pipeline +MINERU_TIMEOUT=300 +``` + +## 可选:systemd 服务(生产环境) + +创建 `/etc/systemd/system/mineru-api.service`: + +```ini +[Unit] +Description=MinerU PDF Parsing API +After=network.target + +[Service] +User=djx +Environment=CUDA_VISIBLE_DEVICES=6 +Environment=MINERU_MODEL_SOURCE=local +ExecStart=/home/djx/miniconda3/envs/mineru/bin/mineru-api --host 0.0.0.0 --port 8010 +WorkingDirectory=/home/djx +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now mineru-api +sudo systemctl status mineru-api +``` + +## 故障排除 + +| 问题 | 原因 | 解决 | +|------|------|------| +| `Max retries exceeded` HuggingFace | 未设置 `MINERU_MODEL_SOURCE=local` | 启动时加 `MINERU_MODEL_SOURCE=local` | +| `Engine core initialization failed` | hybrid 后端缺少 VLM 模型 | 重新下载模型选 `all`,或改用 `pipeline` 后端 | +| `address already in use` | 端口被占用 | `kill $(lsof -ti:8010)` 或换端口 | +| 首次请求很慢 | 模型加载到 GPU | 正常,后续请求会快很多 | From 3b940014e4c9abe0f52f15f18764728c536459b1 Mon Sep 17 00:00:00 2001 From: sylvanding Date: Sun, 15 Mar 2026 17:50:55 +0800 Subject: [PATCH 3/9] docs(prd): add V3 PRD documents and Phase 4 innovation features plan - PRD v3: overview, chat logic, knowledge base, settings, architecture, innovation features, implementation roadmap, code audit, technical deep dive - Phase 4 plan: smart autocomplete, citation graph, literature review, PDF AI assistant Made-with: Cursor --- ...15-feat-phase4-innovation-features-plan.md | 712 ++++++++++++++ docs/prd/v3/00-overview.md | 156 +++ docs/prd/v3/01-chat-logic.md | 465 +++++++++ docs/prd/v3/02-knowledge-base.md | 918 ++++++++++++++++++ docs/prd/v3/03-settings-and-integrations.md | 302 ++++++ docs/prd/v3/04-architecture.md | 715 ++++++++++++++ docs/prd/v3/05-innovation.md | 321 ++++++ docs/prd/v3/06-implementation-roadmap.md | 290 ++++++ docs/prd/v3/07-code-audit-and-fixes.md | 408 ++++++++ docs/prd/v3/08-technical-deep-dive.md | 845 ++++++++++++++++ 10 files changed, 5132 insertions(+) create mode 100644 docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md create mode 100644 docs/prd/v3/00-overview.md create mode 100644 docs/prd/v3/01-chat-logic.md create mode 100644 docs/prd/v3/02-knowledge-base.md create mode 100644 docs/prd/v3/03-settings-and-integrations.md create mode 100644 docs/prd/v3/04-architecture.md create mode 100644 docs/prd/v3/05-innovation.md create mode 100644 docs/prd/v3/06-implementation-roadmap.md create mode 100644 docs/prd/v3/07-code-audit-and-fixes.md create mode 100644 docs/prd/v3/08-technical-deep-dive.md diff --git a/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md b/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md new file mode 100644 index 0000000..f0ee38d --- /dev/null +++ b/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md @@ -0,0 +1,712 @@ +--- +title: "Phase 4 — 创新功能:智能补全 / 文献图谱 / Literature Review / PDF AI 助手" +type: feat +status: active +date: 2026-03-15 +--- + +# Phase 4 — 创新功能 + +## Overview + +Phase 4 是 Omelette V3 的差异化竞争力阶段,包含四大创新功能: + +1. **智能补全** — 输入框实时预测补全,Tab 接受 +2. **文献关系图谱** — 基于 Semantic Scholar 引用数据的 D3 力导向图 +3. **自动 Literature Review** — 组合 outline + RAG 生成综述草稿 +4. **PDF 阅读器 AI 助手** — 内嵌 PDF 阅读器 + 选区问答 + +预计工期:2 周(可并行开发)。 + +## 依赖关系 + +```mermaid +flowchart LR + P1["Phase 1: 对话引擎
(已完成)"] --> AC["智能补全"] + P1 --> PDF["PDF AI 助手"] + P3["Phase 3: 设置与调度
(已完成)"] --> LR["Literature Review"] + P2["Phase 2: 知识库
(已完成)"] --> CG["文献图谱"] + P1 --> LR +``` + +**前置条件均已满足**: +- Phase 1 对话引擎(意图识别、引用增强)✅ +- Phase 2 知识库增强(MinerU、分块策略)✅ +- Phase 3 设置与调度(多模型分级、RAG 混合检索)✅ + +--- + +## Phase 4A — 智能补全 (3d) + +### 目标 + +用户在聊天输入框输入时,系统实时预测后续内容,以灰色文本展示在光标后,按 Tab 接受。 + +### 技术方案 + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 ChatInput + participant API as POST /chat/complete + participant LLM as LLM (Routing Tier) + + U->>FE: 输入文本 (onChange) + FE->>FE: debounce 400ms + FE->>API: {prefix, conversation_id, knowledge_base_ids} + API->>LLM: 补全请求 (max_tokens=50, temperature=0.3) + LLM-->>API: completion text + API-->>FE: {completion, confidence} + FE->>FE: 渲染灰色补全文本 + U->>FE: Tab → 接受 / Esc → 忽略 / 继续输入 → 取消 +``` + +### 任务分解 + +| # | 任务 | 文件 | 工作量 | +|---|------|------|--------| +| 4A-1 | 后端 `POST /api/v1/chat/complete` 端点 | `backend/app/api/v1/chat.py` | 0.5d | +| 4A-2 | 补全服务逻辑(debounce 保护、prompt 构建、LLM 调用) | `backend/app/services/completion_service.py` (新建) | 1d | +| 4A-3 | 前端 `CompletionSuggestion` 组件 | `frontend/src/components/playground/CompletionSuggestion.tsx` (新建) | 1d | +| 4A-4 | `ChatInput` 集成:debounce、API 调用、Tab/Esc 键绑定 | `frontend/src/components/playground/ChatInput.tsx` | 0.5d | + +### 4A-1: 后端补全 API + +**文件**: `backend/app/api/v1/chat.py` + +新增端点: + +```python +class CompletionRequest(BaseModel): + prefix: str = Field(..., min_length=10, max_length=2000) + conversation_id: int | None = None + knowledge_base_ids: list[int] = [] + recent_messages: list[dict] = [] + +class CompletionResponse(BaseModel): + completion: str + confidence: float + +@router.post("/complete", response_model=ApiResponse[CompletionResponse]) +async def complete(req: CompletionRequest): + svc = CompletionService() + result = await svc.complete( + prefix=req.prefix, + conversation_id=req.conversation_id, + knowledge_base_ids=req.knowledge_base_ids, + recent_messages=req.recent_messages, + ) + return ApiResponse(data=result) +``` + +### 4A-2: 补全服务 + +**文件**: `backend/app/services/completion_service.py` (新建) + +```python +class CompletionService: + async def complete( + self, + prefix: str, + conversation_id: int | None = None, + knowledge_base_ids: list[int] | None = None, + recent_messages: list[dict] | None = None, + ) -> dict: + # 1. 加载最近 3 轮历史(从 conversation_id 或 recent_messages) + # 2. 构建 system prompt(科研补全上下文) + # 3. 调用 routing tier LLM(max_tokens=50, temperature=0.3) + # 4. 返回 {completion, confidence} + ... +``` + +**Prompt 模板**: + +``` +你是一个科研写作助手。根据用户已输入的文本,预测并补全后续内容。 +只返回补全的部分(不要重复用户已输入的内容),最多 50 个字符。 +如果无法合理预测,返回空字符串。 + +用户已输入:{prefix} +``` + +**关键约束**: +- 使用 Routing Tier 模型(轻量、低延迟) +- `max_tokens=50`,`temperature=0.3` +- 输入 < 10 字符时直接返回空 +- 超时 2 秒直接返回空 + +### 4A-3: 前端 CompletionSuggestion 组件 + +**文件**: `frontend/src/components/playground/CompletionSuggestion.tsx` (新建) + +```tsx +interface CompletionSuggestionProps { + completion: string; + onAccept: () => void; + onDismiss: () => void; +} + +// 灰色半透明文本,紧跟在光标后 +// 显示 "Tab ↹" 提示 +``` + +### 4A-4: ChatInput 集成 + +**文件**: `frontend/src/components/playground/ChatInput.tsx` + +修改点: +- 新增 `useState` 管理 `completion` 状态 +- `onChange` 中 debounce 400ms 后调用 `/chat/complete` +- `onKeyDown` 中 Tab → 接受补全插入文本,Esc → 清除 +- 继续输入时取消当前请求(`AbortController`) +- 补全文本紧跟在 Textarea 内容后,灰色样式 + +### 验收标准 + +- [ ] 输入 ≥ 10 字符且停顿 400ms 后展示灰色补全建议 +- [ ] Tab 接受补全,文本插入输入框 +- [ ] Esc 或继续输入清除建议 +- [ ] 补全延迟 < 2s(routing tier 模型) +- [ ] 无补全建议时不展示任何 UI + +--- + +## Phase 4B — 文献关系图谱 (4d) + +### 目标 + +以种子论文为中心,展示引用/被引/相似文献的关系力导向图,支持交互式探索。 + +### 技术方案 + +```mermaid +flowchart TB + subgraph 数据层 + S2["Semantic Scholar API"] + DB["Paper 表 (source_id)"] + end + + subgraph 服务层 + CG["CitationGraphService"] + end + + subgraph API层 + EP["GET /papers/{id}/citation-graph"] + end + + subgraph 前端 + D3["D3 力导向图"] + Panel["详情面板"] + end + + S2 --> CG + DB --> CG + CG --> EP + EP --> D3 + D3 --> Panel +``` + +### 任务分解 + +| # | 任务 | 文件 | 工作量 | +|---|------|------|--------| +| 4B-1 | S2 引用/被引 API 封装 | `backend/app/services/citation_graph_service.py` (新建) | 1d | +| 4B-2 | 图谱 API 端点 | `backend/app/api/v1/citation_graph.py` (新建) | 0.5d | +| 4B-3 | 前端 D3 力导向图组件 | `frontend/src/components/citation-graph/` (新建) | 2d | +| 4B-4 | 集成到 PapersPage / 新页面 | `frontend/src/pages/project/` | 0.5d | + +### 4B-1: CitationGraphService + +**文件**: `backend/app/services/citation_graph_service.py` (新建) + +```python +class CitationGraphService: + S2_API = "https://api.semanticscholar.org/graph/v1" + + async def get_citation_graph( + self, + paper_id: int, + depth: int = 1, + max_nodes: int = 50, + ) -> dict: + """获取论文引用关系图。 + + Returns: + { + "nodes": [{"id", "title", "year", "citation_count", "is_local", "s2_id"}], + "edges": [{"source", "target", "type": "cites"|"cited_by"}], + "center_id": paper_id + } + """ + # 1. 从 Paper 表获取 source_id(S2 paperId) + # 2. 调用 S2 GET /paper/{s2_id}/citations?fields=...&limit=20 + # 3. 调用 S2 GET /paper/{s2_id}/references?fields=...&limit=20 + # 4. 标记哪些论文在本地知识库中 (is_local=True) + # 5. 构建 nodes + edges 图结构 + ... + + async def _fetch_s2_citations(self, s2_id: str) -> list[dict]: + """调用 S2 citations endpoint。""" + ... + + async def _fetch_s2_references(self, s2_id: str) -> list[dict]: + """调用 S2 references endpoint。""" + ... +``` + +**S2 API 字段**:`title,year,citationCount,externalIds,authors` + +**关键约束**: +- S2 API 速率限制:1 req/s(无 API Key)或 10 req/s(有 API Key) +- 使用 `settings.semantic_scholar_api_key` 认证 +- `depth=1` 只获取直接引用/被引;`depth=2` 获取二级(后续扩展) +- `max_nodes` 限制节点数量,避免前端渲染卡顿 + +### 4B-2: 图谱 API + +**文件**: `backend/app/api/v1/citation_graph.py` (新建) + +```python +@router.get("/projects/{project_id}/papers/{paper_id}/citation-graph") +async def get_citation_graph( + project_id: int, + paper_id: int, + depth: int = Query(1, ge=1, le=2), + max_nodes: int = Query(50, ge=10, le=200), +): + svc = CitationGraphService() + graph = await svc.get_citation_graph(paper_id, depth=depth, max_nodes=max_nodes) + return ApiResponse(data=graph) +``` + +### 4B-3: 前端 D3 力导向图 + +**新增依赖**: `d3`, `@types/d3` + +**文件**: `frontend/src/components/citation-graph/` (新建目录) + +| 文件 | 职责 | +|------|------| +| `CitationGraphView.tsx` | 主容器:加载数据、管理状态 | +| `ForceGraph.tsx` | D3 力导向图渲染 | +| `GraphControls.tsx` | 缩放、重置、过滤控件 | +| `NodeDetailPanel.tsx` | 点击节点后显示论文详情侧边栏 | + +**图谱视觉设计**: + +| 元素 | 视觉编码 | +|------|----------| +| 节点大小 | 引用数量(citationCount),对数缩放 | +| 节点颜色 | 年份(新 → 蓝色,旧 → 灰色);本地已有 → 绿色高亮 | +| 边方向 | 箭头指向被引方 | +| 边颜色 | `cites` → 蓝色,`cited_by` → 橙色 | +| 中心节点 | 加粗边框 + 脉冲动画 | + +**交互**: +- 拖拽节点、缩放画布 +- 点击节点 → 右侧详情面板(标题、作者、年份、引用数、摘要) +- 双击本地节点 → 跳转到论文详情页 +- 过滤:按年份范围、仅显示本地文献 + +### 4B-4: 页面集成 + +在 `PapersPage` 的论文列表中,每篇论文添加"引用图谱"按钮。点击后弹出全屏图谱视图(Dialog 或独立路由)。 + +### 验收标准 + +- [ ] 可查看任意论文的引用/被引关系图谱 +- [ ] 节点大小、颜色正确编码(引用数、年份) +- [ ] 本地知识库中的论文绿色高亮 +- [ ] 点击节点显示详情面板 +- [ ] 支持拖拽、缩放、过滤 +- [ ] S2 API 速率限制正确处理(指数退避) + +--- + +## Phase 4C — 自动 Literature Review (3d) + +### 目标 + +基于知识库自动生成结构化综述草稿,带引用和章节结构。 + +### 技术方案 + +```mermaid +flowchart TB + subgraph 输入 + KB["知识库 papers"] + Topic["用户主题/提示"] + end + + subgraph 生成流程 + Outline["生成提纲 (WritingService)"] + RAG["为每个章节 RAG 检索"] + Draft["逐章节生成草稿 (SSE)"] + end + + subgraph 输出 + MD["Markdown 综述草稿"] + Cite["[1][2] 引用标注"] + end + + KB --> Outline + Topic --> Outline + Outline --> RAG + RAG --> Draft + Draft --> MD + Draft --> Cite +``` + +### 任务分解 + +| # | 任务 | 文件 | 工作量 | +|---|------|------|--------| +| 4C-1 | 综述草稿生成服务 | `backend/app/services/writing_service.py` (扩展) | 1.5d | +| 4C-2 | SSE 流式综述 API | `backend/app/api/v1/writing.py` (扩展) | 0.5d | +| 4C-3 | 前端综述生成 UI | `frontend/src/pages/project/WritingPage.tsx` (扩展) | 1d | + +### 4C-1: 综述草稿生成服务 + +**文件**: `backend/app/services/writing_service.py` + +新增方法: + +```python +async def generate_literature_review( + self, + project_id: int, + topic: str = "", + style: str = "narrative", # narrative | systematic | thematic + citation_format: str = "numbered", # numbered | apa | gb_t_7714 + on_progress: Callable | None = None, +) -> AsyncGenerator[str, None]: + """三步生成综述草稿:提纲 → RAG 检索 → 逐章节生成。 + + Yields SSE-compatible text chunks. + """ + # Step 1: 生成提纲(复用 generate_review_outline) + outline = await self.generate_review_outline(project_id, topic) + + # Step 2: 为每个章节做 RAG 检索 + rag = RAGService() + sections = parse_outline_sections(outline["outline"]) + for section in sections: + sources = await rag.retrieve_only(project_id, section["query"], top_k=8) + section["sources"] = sources + + # Step 3: 逐章节流式生成 + for section in sections: + async for chunk in self._generate_section_draft(section, citation_format): + yield chunk +``` + +**Prompt 模板(章节生成)**: + +``` +你是一个学术综述写作助手。请为以下章节撰写综述段落。 + +章节标题:{section_title} +相关文献摘录: +{formatted_sources} + +要求: +1. 使用学术语言,逻辑清晰 +2. 在适当位置使用 [1][2] 格式引用 +3. 每个引用必须对应提供的文献 +4. 段落长度 200-400 字 +``` + +### 4C-2: SSE 流式综述 API + +**文件**: `backend/app/api/v1/writing.py` + +新增端点: + +```python +class ReviewDraftRequest(BaseModel): + topic: str = "" + style: str = "narrative" + citation_format: str = "numbered" + +@router.post("/review-draft/stream") +async def stream_review_draft( + project_id: int, + req: ReviewDraftRequest, +): + svc = WritingService(llm=get_llm_client()) + return StreamingResponse( + svc.generate_literature_review( + project_id=project_id, + topic=req.topic, + style=req.style, + citation_format=req.citation_format, + ), + media_type="text/event-stream", + ) +``` + +### 4C-3: 前端综述生成 UI + +**文件**: `frontend/src/pages/project/WritingPage.tsx` + +修改点: +- 新增 "生成综述草稿" 按钮 +- 点击后弹出配置 Dialog:主题、风格(叙述/系统/主题)、引用格式 +- 确认后调用 SSE API,流式展示生成结果 +- 生成结果支持 Markdown 渲染(含公式、表格) +- 支持复制、下载为 `.md` 文件 +- 引用标注 `[1][2]` 可悬停查看来源 + +### 验收标准 + +- [ ] 可基于知识库自动生成结构化综述草稿 +- [ ] 草稿带 `[1][2]` 引用标注,对应知识库文献 +- [ ] 支持流式展示生成过程 +- [ ] 支持叙述/系统/主题三种综述风格 +- [ ] 可复制或下载生成的 Markdown + +--- + +## Phase 4D — PDF 阅读器 AI 助手 (3d) + +### 目标 + +内嵌 PDF 阅读器,支持选中文本向 AI 提问、解释、翻译、找引用。 + +### 技术方案 + +```mermaid +flowchart LR + subgraph PDF阅读器 + Viewer["PDF.js 渲染器"] + Selection["文本选区"] + end + + subgraph AI侧边栏 + QA["选区问答"] + Actions["快捷操作"] + History["问答历史"] + end + + subgraph 后端 + Chat["POST /chat/stream"] + RAG["RAG 检索"] + end + + Selection --> QA + QA --> Chat + Chat --> RAG + RAG --> QA + Actions --> Chat +``` + +### 任务分解 + +| # | 任务 | 文件 | 工作量 | +|---|------|------|--------| +| 4D-1 | PDF 阅读器组件(react-pdf / pdfjs-dist) | `frontend/src/components/pdf-reader/PDFViewer.tsx` (新建) | 1d | +| 4D-2 | 阅读器布局 + AI 侧边栏 | `frontend/src/components/pdf-reader/PDFReaderLayout.tsx` (新建) | 0.5d | +| 4D-3 | 选区问答组件 + 快捷操作 | `frontend/src/components/pdf-reader/SelectionQA.tsx` (新建) | 1d | +| 4D-4 | 路由 + PapersPage 入口 | `frontend/src/App.tsx`, `PapersPage.tsx` | 0.5d | + +### 4D-1: PDF 阅读器组件 + +**新增依赖**: `react-pdf` (基于 pdfjs-dist) + +**文件**: `frontend/src/components/pdf-reader/PDFViewer.tsx` (新建) + +```tsx +interface PDFViewerProps { + url: string; // PDF 文件 URL(/api/v1/papers/{id}/pdf 或本地路径) + onTextSelect?: (text: string, pageNumber: number) => void; +} + +// 功能: +// - 渲染 PDF 页面(虚拟化,仅渲染可见页) +// - 支持缩放、翻页、搜索 +// - 文本选区 → 调用 onTextSelect +// - 高亮选中区域 +``` + +### 4D-2: 阅读器布局 + +**文件**: `frontend/src/components/pdf-reader/PDFReaderLayout.tsx` (新建) + +```tsx +// 左侧 70%: PDFViewer +// 右侧 30%: AI 侧边栏(可折叠) +// 顶部: 工具栏(缩放、页码、搜索) +``` + +布局使用 `ResizablePanel` (shadcn/ui) 实现左右分栏,用户可拖拽调整比例。 + +### 4D-3: 选区问答组件 + +**文件**: `frontend/src/components/pdf-reader/SelectionQA.tsx` (新建) + +```tsx +interface SelectionQAProps { + selectedText: string; + paperId: int; + projectId: int; +} + +// 快捷操作按钮(选中文本后浮现): +// - "解释这段话" +// - "翻译成中文/英文" +// - "在知识库中找相关引用" +// - "自由提问"(输入框) + +// 使用现有 POST /chat/stream 端点 +// tool_mode: "qa" +// 将选中文本作为用户消息的前缀 +``` + +**Prompt 增强**: + +选区问答时,system prompt 注入论文上下文: + +``` +你正在阅读论文「{paper_title}」。用户选中了以下文本并提出问题。 +请基于论文上下文和知识库内容回答。 + +选中文本:{selected_text} +``` + +### 4D-4: 路由与入口 + +**文件**: `frontend/src/App.tsx` + +新增路由: +```tsx +} /> +``` + +**文件**: `frontend/src/pages/project/PapersPage.tsx` + +在论文列表中,点击论文标题或"阅读"按钮 → 导航到 PDF 阅读器页面。 + +**后端**:需新增 PDF 文件服务端点(如果还没有): + +```python +@router.get("/projects/{project_id}/papers/{paper_id}/pdf") +async def serve_pdf(project_id: int, paper_id: int): + # 从 paper.pdf_path 读取并返回文件 + ... +``` + +### 验收标准 + +- [ ] 可在应用内打开并阅读 PDF +- [ ] 支持缩放、翻页、文本搜索 +- [ ] 选中文本后弹出快捷操作菜单 +- [ ] 可对选中文本提问、解释、翻译、找引用 +- [ ] AI 回答在右侧侧边栏展示 +- [ ] 问答历史在侧边栏保留 + +--- + +## 实施顺序建议 + +```mermaid +gantt + title Phase 4 实施甘特图 + dateFormat YYYY-MM-DD + axisFormat %m/%d + + section 4A 智能补全 + 后端补全 API + 服务 :4a1, 2026-04-09, 1.5d + 前端 CompletionSuggestion :4a2, after 4a1, 1.5d + + section 4B 文献图谱 + S2 引用服务 + API :4b1, 2026-04-09, 1.5d + D3 力导向图组件 :4b2, after 4b1, 2d + 页面集成 :4b3, after 4b2, 0.5d + + section 4C Literature Review + 综述生成服务 :4c1, 2026-04-12, 1.5d + SSE API + 前端 UI :4c2, after 4c1, 1.5d + + section 4D PDF AI 助手 + PDF 阅读器组件 :4d1, 2026-04-12, 1d + 布局 + 选区问答 :4d2, after 4d1, 2d +``` + +**可并行**:4A + 4B 可与 4C + 4D 并行开发,两组无依赖。 + +--- + +## 系统级影响分析 + +### 交互图 + +- **智能补全**: `ChatInput → /chat/complete → CompletionService → LLMClient(routing tier)` — 新增独立路径,不影响现有对话流 +- **文献图谱**: `PapersPage → /papers/{id}/citation-graph → CitationGraphService → S2 API` — 新增独立路径 +- **Literature Review**: `WritingPage → /writing/review-draft/stream → WritingService → RAGService + LLMClient` — 扩展现有 WritingService +- **PDF AI 助手**: `PDFReader → /chat/stream (with paper context) → ChatPipeline` — 复用现有对话引擎 + +### 错误传播 + +| 场景 | 错误处理 | +|------|----------| +| S2 API 不可用 | 图谱页面展示"无法获取引用数据",不影响其他功能 | +| 补全 LLM 超时 | 前端静默忽略(不展示建议),用户无感 | +| 综述生成中断 | SSE 关闭,展示已生成部分 + "生成中断"提示 | +| PDF 加载失败 | 展示错误提示 + "下载 PDF"降级链接 | + +### API 表面新增 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/v1/chat/complete` | POST | 智能补全 | +| `/api/v1/projects/{id}/papers/{pid}/citation-graph` | GET | 引用图谱 | +| `/api/v1/projects/{id}/writing/review-draft/stream` | POST | 综述草稿流式生成 | +| `/api/v1/projects/{id}/papers/{pid}/pdf` | GET | PDF 文件服务 | + +--- + +## 依赖与风险 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| S2 API 速率限制(1 req/s 无 Key) | 图谱加载慢 | 使用 API Key(10 req/s);缓存结果到 DB | +| 补全延迟 > 2s | 用户体验差 | Routing tier 模型 + 超时 2s 熔断 | +| PDF.js 包体积大 | 前端加载慢 | lazy import + CDN worker | +| 综述生成幻觉 | 引用错误 | Prompt 强约束 + 仅允许引用检索到的来源 | + +### 前端新增依赖 + +| 包 | 用途 | 版本 | +|----|------|------| +| `d3` / `d3-force` / `d3-selection` | 力导向图可视化 | latest | +| `react-pdf` | PDF 阅读器 | latest | +| `@types/d3` | D3 类型定义 | latest | + +--- + +## 成功指标 + +| 指标 | 目标 | +|------|------| +| 补全接受率 | > 20% 的补全被 Tab 接受 | +| 图谱加载时间 | < 3s(50 节点) | +| 综述生成速度 | < 2min(含 RAG 检索 + 生成) | +| PDF 阅读器打开时间 | < 2s(本地 PDF) | + +--- + +## Sources & References + +### Internal References + +- PRD 对话逻辑: `docs/prd/v3/01-chat-logic.md` (智能补全设计) +- PRD 创新功能: `docs/prd/v3/05-innovation.md` (文献图谱、Literature Review、PDF AI 助手) +- PRD 架构: `docs/prd/v3/04-architecture.md` (LLM 分级、SSE 协议) +- PRD 实施路线: `docs/prd/v3/06-implementation-roadmap.md` (Phase 4 甘特图) + +### External References + +- Semantic Scholar API: https://api.semanticscholar.org/api-docs/graph +- D3.js 力导向图: https://d3js.org/d3-force +- react-pdf: https://github.com/wojtekmaj/react-pdf +- Connected Papers (参考): https://connectedpapers.com diff --git a/docs/prd/v3/00-overview.md b/docs/prd/v3/00-overview.md new file mode 100644 index 0000000..68fdb2c --- /dev/null +++ b/docs/prd/v3/00-overview.md @@ -0,0 +1,156 @@ +# Omelette V3 PRD — 产品总览 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 1. 产品定位 + +**Omelette** 是一款 **AI 原生的科研文献智能 IDE 平台**,定位为科研人员的"文献 Copilot"。 + +- **对标产品**:Google NotebookLM(知识管理)、Zotero(文献管理)、Cursor/Windsurf(AI IDE 体验) +- **核心差异**:比 NotebookLM 更垂直深入,比 Zotero 更智能化,比通用 AI IDE 更懂科研 + +### 1.1 愿景 + +> 构建智能体时代下的科研文献管理新范式 —— AI 时代的智能文献辅助 IDE 平台 + +### 1.2 目标用户 + +| 角色 | 核心需求 | 使用频率 | +|------|----------|----------| +| 科研人员 | 系统性跟踪前沿、撰写论文与基金申请 | 日常 | +| 博士研究生 | 开题调研、文献综述、论文写作 | 高频 | +| 基金申请人 | 研究背景撰写、文献支撑、创新点论证 | 阶段性 | +| 科研团队 | 知识共享、协同文献管理 | 持续 | + +### 1.3 核心痛点 + +1. **文献检索不完整**:单一数据源覆盖有限,关键词体系混乱 +2. **工作流割裂**:检索、去重、下载、OCR、索引分散在多个工具 +3. **知识复用差**:已读文献难以结构化沉淀,写作时无法快速引用 +4. **前沿跟踪滞后**:缺乏增量订阅机制 +5. **AI 融合浅层**:现有工具的 AI 功能多为"锦上添花",未深入科研工作流 +6. **工具间不互通**:Zotero、Mendeley 等工具数据孤岛 + +--- + +## 2. 产品架构总览 + +### 2.1 核心模块划分 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Omelette IDE Platform │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ 对话引擎 │ │ 知识库管理 │ │ 写作助手 │ │ +│ │ Chat Engine │ │ KB Manager │ │ Writing Assistant │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ +│ │ │ │ │ +│ ┌──────┴────────────────┴─────────────────────┴──────────┐ │ +│ │ 智能体编排层 (LangGraph) │ │ +│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────────┐ │ │ +│ │ │意图识别 │ │记忆管理 │ │工具调度 │ │流水线编排 │ │ │ +│ │ │Intent │ │Memory │ │Tool │ │Pipeline │ │ │ +│ │ └─────────┘ └──────────┘ └─────────┘ └─────────────┘ │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 基础能力层 │ │ +│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ +│ │ │RAG │ │Search│ │Dedup │ │OCR │ │Embed │ │ │ +│ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │ +│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ +│ │ │Crawl │ │Sub │ │LLM │ │MCP │ │ │ +│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 数据层 │ │ +│ │ SQLite(元数据) + ChromaDB(向量) + 文件系统(PDF) │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 模块清单 + +| # | 模块 | 状态 | 描述 | +|---|------|------|------| +| 1 | 对话引擎 | V2已有,V3重构 | 意图识别、记忆管理、启发式交互 | +| 2 | 引用查询 | V2已有,V3增强 | 知识库检索、段落级引用、来源追踪 | +| 3 | 智能补全 | V3新增 | 实时输入预测、上下文感知 | +| 4 | 斜杠命令 | V3新增 | 快捷指令、模式切换、知识库加载 | +| 5 | 知识库管理 | V2已有,V3增强 | AI建库、Zotero导入 | +| 6 | 文献上传 | V2已有,V3优化 | 智能去重、并行OCR、自动索引 | +| 7 | 订阅管理 | V2已有,V3增强 | 多源订阅、增量更新、定时任务 | +| 8 | 设置中心 | V2已有,V3重构 | 多模型分级、Provider管理 | +| 9 | Zotero联动 | V3新增 | 文库导入、双向同步 | +| 10 | MCP Server | V2已有 | AI IDE集成 | + +--- + +## 3. 技术栈 + +### 3.1 已确定 + +| 层 | 技术 | 版本 | +|----|------|------| +| 后端框架 | FastAPI + SQLAlchemy async | latest | +| LLM编排 | LangGraph + LangChain | latest | +| RAG引擎 | LlamaIndex + ChromaDB | latest | +| 前端框架 | React 18 + TypeScript + Vite | latest | +| UI组件 | TailwindCSS v4 + shadcn/ui + Radix | latest | +| 流式协议 | SSE (Vercel AI SDK 5.0 Data Stream Protocol) | 5.0 | +| 国际化 | i18next | latest | +| 数据库 | SQLite + Alembic | latest | + +### 3.2 V3 新增/评估 + +| 技术 | 用途 | 状态 | +|------|------|------| +| mem0 | 长短期记忆管理 | 评估中 | +| Reranker (Cohere/BGE) | RAG重排序 | 待引入 | +| 混合检索 | BM25 + 向量检索 | 待引入 | +| WebSocket | 实时补全 | 评估中 | +| Zotero API | 文库同步 | 待引入 | + +--- + +## 4. 产品原则 + +1. **AI 原生**:AI 不是附加功能,而是产品核心驱动力 +2. **本地优先**:数据存储于用户本地,保护隐私 +3. **模块解耦**:各模块可独立使用、独立开发、独立测试 +4. **渐进增强**:用户可从简单功能开始,逐步解锁高级功能 +5. **反馈透明**:每一步操作都有可视化进度和状态反馈 +6. **开放协议**:通过 MCP 协议与外部 AI IDE 互通 + +--- + +## 5. 文档索引 + +| 文档 | 内容 | 状态 | +|------|------|------| +| [01-chat-logic.md](./01-chat-logic.md) | 对话逻辑模块:意图识别、引用增强、智能补全、斜杠命令 | ✅ | +| [02-knowledge-base.md](./02-knowledge-base.md) | 知识库管理:AI建库、文献上传、分级去重、Zotero联动、订阅 | ✅ | +| [03-settings-and-integrations.md](./03-settings-and-integrations.md) | 设置与集成:多模型管理、Zotero集成、系统配置 | ✅ | +| [04-architecture.md](./04-architecture.md) | 架构设计:分层架构、Pipeline持久化、RAG增强、LLM分级 | ✅ | +| [05-innovation.md](./05-innovation.md) | 竞品调研(10款产品)与8项创新功能设计 | ✅ | +| [06-implementation-roadmap.md](./06-implementation-roadmap.md) | 5阶段实施路线图、团队分工、风险管控 | ✅ | +| [07-code-audit-and-fixes.md](./07-code-audit-and-fixes.md) | 代码深度审计:2个P0 BUG + 6个P1问题 + 4个优化项 | ✅ | +| [08-technical-deep-dive.md](./08-technical-deep-dive.md) | 数据库选型(mem0兼容)、PDF解析(MinerU)、RAG深度评估、公式/表格/图片策略 | ✅ | + +--- + +## 6. 里程碑规划(概览) + +| 阶段 | 目标 | 预期 | +|------|------|------| +| Phase 1 | 对话引擎重构 + 记忆系统 + 意图识别 | 2周 | +| Phase 2 | 智能补全 + 斜杠命令 + 引用增强 | 2周 | +| Phase 3 | 知识库增强 + Zotero联动 | 2周 | +| Phase 4 | 设置重构 + 模型分级 + 性能优化 | 1周 | +| Phase 5 | 集成测试 + 文档完善 + 发布 | 1周 | + +--- + +*本文档为产品总览,各模块详细 PRD 见对应子文档。* diff --git a/docs/prd/v3/01-chat-logic.md b/docs/prd/v3/01-chat-logic.md new file mode 100644 index 0000000..602b5f1 --- /dev/null +++ b/docs/prd/v3/01-chat-logic.md @@ -0,0 +1,465 @@ +# Omelette V3 PRD — 对话逻辑模块 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 1. 模块概述 + +对话逻辑模块是 Omelette 的核心交互入口,负责处理用户与 AI 的对话流程、意图识别、引用查询、智能补全和快捷指令。本 PRD 细化四个子功能的设计与实现方案。 + +### 1.1 与现有架构的关系 + +- **现有 Chat Pipeline**:`understand → [has KB?] → retrieve → rank → clean → generate → persist`(6 节点 LangGraph) +- **流式协议**:Vercel AI SDK 5.0 Data Stream Protocol(SSE) +- **前端**:`useChat` + `DefaultChatTransport` + `MessageBubbleV2` + `ThinkingChain` + `CitationCardList` + +### 1.2 模块边界 + +| 子模块 | 状态 | 说明 | +|--------|------|------| +| 正常提问(意图感知) | V3 新增 | 无 KB/功能块时的智能路由与提示 | +| 引用查询 | V2 已有,V3 增强 | 段落级引用、不同颜色标注、悬停来源 | +| 智能补全 | V3 新增 | 输入时实时预测 | +| 斜杠命令 | V3 新增 | `/` 调出快捷指令菜单 | + +--- + +## 2. 正常提问(意图感知对话) + +### 2.1 用户故事 + +| ID | 角色 | 需求 | 验收标准 | +|----|------|------|----------| +| U1 | 科研人员 | 未选知识库时提问,希望系统识别我可能需要引用某知识库 | 系统提示「是否选择知识库 X?」 | +| U2 | 科研人员 | 未选功能模式时提问「帮我找这段话的引用」,希望系统建议激活引用模式 | 系统提示「是否切换到引用查询模式?」 | +| U3 | 科研人员 | 普通闲聊或通用问题,不希望被强制选知识库 | 直接回答,可选 RAG 增强 | +| U4 | 科研人员 | 对话结束后希望有下一步建议 | 展示 2–4 个启发式按钮供快速选择 | + +### 2.2 交互流程 + +```mermaid +flowchart TD + subgraph 用户输入 + A[用户输入消息] + end + + subgraph 知识嵌入 + B[短期记忆: 最近 N 轮对话] + C[长期记忆: mem0 评估] + D[功能列表: qa/citation/outline/gap] + E[知识库列表: id+name+description] + end + + subgraph 意图识别 + F{意图分类} + F --> G[功能需求] + F --> H[需要知识库] + F --> I[无特殊意图] + end + + subgraph 响应分支 + G --> J[提示: 是否激活该功能?] + H --> K[提示: 是否选择知识库?] + I --> L[正常对话 + 可选RAG] + end + + subgraph 启发式后续 + M[对话完毕] + M --> N[预测 2-4 个启发式按钮] + N --> O[用户点击快速发起下一轮] + end + + A --> B + A --> C + A --> D + A --> E + B --> F + C --> F + D --> F + E --> F + L --> M + J --> M + K --> M +``` + +**流程说明**: + +1. **知识嵌入**:在 `understand` 节点前或内,将以下上下文注入 system prompt 或单独传给意图分类: + - 短期记忆:当前会话最近 5–10 轮消息(已有 `history_messages`) + - 长期记忆: + - 功能列表:`qa`、`citation_lookup`、`review_outline`、`gap_analysis` 及描述 + - 知识库列表:`GET /api/v1/projects` 返回的 `id`、`name`、`description` + +2. **意图识别**:单次 LLM 调用,返回结构化 JSON:`{ "intent": "function"|"kb"|"general", "suggested_function"?: "citation_lookup", "suggested_kb_ids"?: [1,2] }` + +3. **分支处理**: + - `function`:流式输出提示文案 + 发射 `data-intent-suggestion` 事件,前端展示确认按钮 + - `kb`:同上,提示选择知识库 + - `general`:走现有 `generate` 流程,可选 RAG(若用户有历史选过的 KB,可弱化检索) + +4. **启发式后续**:`generate` 完成后,再调用一次轻量 LLM,基于对话内容预测 2–4 个后续问题,通过 `data-suggested-followups` 事件下发,前端渲染为可点击按钮。 + +### 2.3 LangGraph 节点设计 + +在现有 6 节点基础上扩展: + +| 节点 | 职责 | 前置条件 | 输出 | +|------|------|----------|------| +| `understand` | 加载历史、构建 system prompt、**新增**:调用意图识别(仅当 `knowledge_base_ids` 为空且 `tool_mode` 为默认时) | 入口 | `intent_result`, `history_messages`, `system_prompt` | +| `intent_route` | 条件边:根据 `intent_result` 决定走 `suggest` 或 `retrieve`/`generate` | understand 后 | 路由 | +| `suggest`(新增) | 生成提示文案 + 发射 `data-intent-suggestion`,不调用 RAG | intent 为 function/kb | 流式文案 | +| `retrieve` | 不变 | 有 KB | `rag_results` | +| `rank` | 不变 | retrieve 后 | `citations` | +| `clean` | 不变 | rank 后 | `citations`(更新) | +| `generate` | 不变,**新增**:完成后触发 followup 预测 | 所有路径汇聚 | `assistant_content` | +| `predict_followups`(新增) | 轻量 LLM 调用,预测 2–4 个后续问题,发射 `data-suggested-followups` | generate 后、persist 前 | 无 state 更新 | +| `persist` | 不变 | 所有路径 | `conversation_id` | + +**图结构变更**: + +```mermaid +flowchart LR + understand --> intent_route + intent_route -->|general + 有KB| retrieve + intent_route -->|general + 无KB| generate + intent_route -->|function/kb| suggest + suggest --> persist + retrieve --> rank --> clean --> generate + generate --> predict_followups + predict_followups --> persist + persist --> END +``` + + + +### 2.4 API 设计 + +**请求**:沿用 `POST /api/v1/chat/stream`,请求体不变。 + +**响应**:新增 SSE 事件类型: + +| 事件类型 | 时机 | 数据格式 | 前端处理 | +|----------|------|----------|----------| +| `data-intent-suggestion` | 意图为 function/kb 时 | `{ "type": "function"|"kb", "message": "是否激活引用查询模式?", "suggested_function"?: "citation_lookup", "suggested_kb_ids"?: [1,2] }` | 展示确认/取消按钮,确认后自动切换模式/KB 并重发 | +| `data-suggested-followups` | 对话完成后 | `{ "items": ["总结这篇文献", "找出相关引用", ...] }` | 渲染为可点击按钮,点击即作为新消息发送 | + +### 2.5 前端组件设计 + +| 组件 | 职责 | 位置 | +|------|------|------| +| `IntentSuggestionBanner` | 展示意图建议(功能/知识库),提供「确认」「忽略」按钮 | 消息流中,assistant 消息下方 | +| `SuggestedFollowupButtons` | 展示 2–4 个启发式按钮,点击发送 | 每条 assistant 消息下方 | +| `ThinkingChain` | 已有,展示 `data-thinking` 各步骤 | 不变 | + +**反馈机制**: + +- 意图识别中:`data-thinking` 步骤 `intent`,label 为「识别意图」 +- 建议展示时:Banner 高亮,确认后显示「已切换模式」toast +- 启发式预测中:可选 `data-thinking` 步骤 `followup`,或静默(避免过多步骤感) + +--- + +## 3. 引用查询 + +### 3.1 用户故事 + +| ID | 角色 | 需求 | 验收标准 | +|----|------|------|----------| +| U5 | 科研人员 | 选定知识库后输入文本,希望每段话下方用不同颜色虚线标注来源 | 段落与引用颜色一一对应 | +| U6 | 科研人员 | 不修改我的原文,除非有错误 | 输出保持用户原文,仅增加标注 | +| U7 | 科研人员 | 引用内容在对话框下方统一呈现 | 已有 CitationCardList | +| U8 | 科研人员 | 鼠标悬停显示来源文献和具体段落 | 已有 InlineCitationTag HoverCard | +| U9 | 科研人员 | 引用完整、准确,不遗漏或错误关联 | 优化检索与匹配流程 | + +### 3.2 交互流程 + +```mermaid +flowchart TD + A[用户选定知识库 + 引用模式] --> B[输入文本/粘贴段落] + B --> C[POST /chat/stream] + C --> D[retrieve: 对每段/整段做 RAG 检索] + D --> E[rank: 构建 citation 列表] + E --> F[clean: 清洗 excerpt] + F --> G[generate: citation_lookup 模式] + G --> H[输出: 原文 + 段落级引用标注] + H --> I[前端: 段落下虚线 + 不同颜色] + I --> J[CitationCardList 统一展示] + J --> K[悬停 InlineCitationTag 显示来源] +``` + +**核心差异**:`citation_lookup` 模式下,`generate` 的 system prompt 要求「保持用户原文不变,仅在对应位置标注 [1][2]」,且需按段落粒度匹配引用。 + +### 3.3 段落级引用与颜色映射 + +| 需求 | 实现方案 | +|------|----------| +| 每段话不同颜色 | `CITATION_COLORS` 按 `paper_id` 或 `citation.index` 取模,同一文章同一颜色 | +| 虚线标注 | 在 `InlineCitationTag` 或父级 `span` 上使用 `border-bottom: 1.5px dashed ${color}`(已有) | +| 不修改原文 | `citation_lookup` 的 prompt 明确约束:「Do not alter the user's text. Only add citation markers [N].」 | + +### 3.4 LangGraph 节点设计 + +引用查询复用现有 `retrieve → rank → clean → generate` 链路,需做以下调整: + +- `understand`:当 `tool_mode === "citation_lookup"` 时,使用 `TOOL_MODE_PROMPTS["citation_lookup"]`,并**强化**「不修改原文」的约束 +- `retrieve`:对长文本可按段落拆分后分别检索,再合并去重;或整段检索后由 rank 做段落级分配 + + +> **代码审计发现的关键问题**(详见 [07-code-audit-and-fixes.md](./07-code-audit-and-fixes.md)): +> +> 1. **retrieve_node 硬编码 `top_k=5`**:引用查询模式需要更高的 `top_k`(建议 15-20)以确保引用覆盖率。应改为从 state 中读取可配置值,`citation_lookup` 模式自动提高 `top_k`。 +> 2. **RAG query 内部 LLM 冗余**:`rag_service.query()` 内部会调 LLM 生成答案,但 `generate_node` 又会调一次。应新增 `RAGService.retrieve_only()` 方法仅做检索。 +> 3. **相邻 chunk 上下文拼接 BUG**:`_get_adjacent_chunks` 返回的 prev+next 文本被重复拼接在主 chunk 两侧,导致 excerpt 内容错乱。这直接影响引用查询的准确性。 +> 4. **多 KB 检索结果无去重**:同一论文在多个 KB 中索引时会产生重复引用。`rank_node` 中需按 `paper_id + chunk_index` 去重。 + +### 3.5 API 设计 + +无新增 API,沿用 `POST /api/v1/chat/stream`。请求体需包含 `tool_mode: "citation_lookup"` 和 `knowledge_base_ids`。 + +**响应**:沿用 `data-citation`、`text-delta` 等,确保 `citation_lookup` 模式下 LLM 输出格式为「原文 + [1][2]」。 + +### 3.6 前端组件设计 + +| 组件 | 现状 | V3 增强 | +|------|------|---------| +| `InlineCitationTag` | 已有,按 index 取色,悬停显示 | 确保按 `paper_id` 取色,同一文章同色 | +| `CitationCardList` | 已有,展示在消息下方 | 引用模式下可考虑折叠/展开优化 | +| `remark-citation` | 解析 `[N]` 为 `citation-ref` | 不变 | + +**颜色策略**:当前 `CITATION_COLORS[(citationIndex - 1) % 6]` 按 citation 序号。若需「同一文章同色」,需在 `CitationDict` 中传递 `paper_id`,前端用 `paper_id` 取模。 + +```ts +// 建议:按 paper_id 取色,同一文献同色 +const colorIndex = citation.paper_id != null + ? citation.paper_id % CITATION_COLORS.length + : (citation.index - 1) % CITATION_COLORS.length; +``` + +### 3.7 引用完整性优化 + +> **代码审计发现的关键问题**(详见 [07-code-audit-and-fixes.md](./07-code-audit-and-fixes.md)) + +| 问题 | 根因 | 修复方案 | +|------|------|----------| +| 引用 excerpt 内容错乱 | `_get_adjacent_chunks` 拼接 BUG:前后文被重复贴在主 chunk 两侧 | **P0**:分离 prev/next chunk,按 `[前]\n[主]\n[后]` 正确拼接 | +| 引用不完整 | `top_k=5` 硬编码,且无 Reranker | 提高 `top_k` 至 15-20,引入 BGE Reranker 后取 top_n=5-8 | +| 低质量引用干扰回答 | 无 relevance score 过滤 | `rank_node` 增加 `MIN_RELEVANCE = 0.3` 过滤 | +| 多 KB 重复引用 | 同一论文在多 KB 索引,结果无去重 | 按 `paper_id + chunk_index` 去重 | +| 双重 LLM 调用浪费 | `rag_service.query()` 内部调 LLM + `generate_node` 再调 LLM | 新增 `retrieve_only()` 仅检索不生成 | +| 错误关联 | clean 阶段无校验 | rank 阶段引入 relevance 阈值过滤 | +| 段落与引用错位 | prompt 未按段落对应 | prompt 中要求 LLM 按段落—引用对应输出 | + +--- + +## 4. 智能补全 + +### 4.1 用户故事 + +| ID | 角色 | 需求 | 验收标准 | +|----|------|------|----------| +| U10 | 科研人员 | 输入时实时预测后续内容 | 输入框下方或行内展示补全建议 | +| U11 | 科研人员 | 补全考虑当前对话历史和知识库 | 补全内容与上下文相关 | + +### 4.2 交互流程 + +```mermaid +flowchart TD + A[用户输入中] --> B{debounce 300ms} + B --> C[收集: 当前输入 + 最近3轮历史 + 选中KB] + C --> D[POST /api/v1/chat/complete] + D --> E[轻量 LLM 或 n-gram/embedding 预测] + E --> F[返回补全片段] + F --> G[前端展示灰色建议] + G --> H{用户 Tab 接受?} + H -->|是| I[替换为补全内容] + H -->|否| J[继续输入,建议消失] +``` + +### 4.3 技术方案 + +| 方案 | 优点 | 缺点 | 建议 | +|------|------|------|------| +| 流式 LLM 补全 | 上下文感知强 | 延迟高、成本高 | 仅作为可选增强 | +| 本地 n-gram / 简单统计 | 延迟低 | 无上下文 | 不适合科研场景 | +| 小模型 + 缓存 | 折中 | 需部署小模型 | | +| 服务端 LLM 单次调用 | 实现简单 | 每次输入都调用成本高 | 需 debounce + 缓存 | + +**推荐**:Phase 1 采用 **debounce + 单次 LLM 调用**,限制为:仅当输入 ≥ 10 字符且停顿 400ms 时触发,返回 1 个补全片段(最多 50 字符)。后续可评估 WebSocket 长连接或本地小模型。 + +### 4.4 API 设计 + +**新增**:`POST /api/v1/chat/complete` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `prefix` | string | 是 | 用户当前输入 | +| `conversation_id` | int | 否 | 当前会话 ID | +| `knowledge_base_ids` | int[] | 否 | 选中的知识库 | +| `recent_messages` | array | 否 | 最近 3 轮 {role, content},可由前端传入或后端从 conversation 加载 | + +**响应**: + +```json +{ + "completion": "后续预测的文本片段", + "confidence": 0.85 +} +``` + +非流式,单次返回。若无需补全可返回 `{ "completion": "" }`。 + +### 4.5 前端组件设计 + +| 组件 | 职责 | +|------|------| +| `ChatInput` | 扩展:监听 `onChange`,debounce 后调用 `complete` API,将返回的 `completion` 以灰色行内或下方展示 | +| `CompletionSuggestion` | 新增:展示补全建议,支持 Tab 接受、Esc 忽略 | + +**交互**: + +- 补全建议以灰色、斜体或下划线样式显示在光标后 +- Tab:接受补全,插入到输入框 +- Esc:清除建议 +- 继续输入:取消当前请求,重新 debounce + +**反馈机制**: + +- 请求中:输入框右侧显示 loading 小图标(可选,避免闪烁) +- 无补全时:不展示任何 UI + + + +--- + +## 5. 斜杠命令 + +### 5.1 用户故事 + +| ID | 角色 | 需求 | 验收标准 | +|----|------|------|----------| +| U12 | 科研人员 | 输入 `/` 调出快捷指令菜单 | 弹出菜单,可搜索、选择 | +| U13 | 科研人员 | 可选择模式、功能、加载知识库 | 选择后自动切换并填充 | +| U14 | 科研人员 | 支持快捷指令如 `/qa`、`/cite 知识库名` | 输入即执行 | + +### 5.2 交互流程 + +```mermaid +flowchart TD + A[用户输入 /] --> B[调出 SlashCommandMenu] + B --> C[展示: 模式 / 功能 / 知识库] + C --> D{用户选择或继续输入} + D -->|选择模式| E[切换 tool_mode] + D -->|选择功能| F[切换 tool_mode + 可选 KB] + D -->|选择知识库| G[添加 KB 到选中列表] + D -->|输入 /qa /cite 等| H[解析并执行] + E --> I[关闭菜单,聚焦输入框] + F --> I + G --> I + H --> I +``` + +### 5.3 命令结构 + +| 类别 | 命令示例 | 行为 | +|------|----------|------| +| 模式 | `/qa` | 切换为 qa 模式 | +| 模式 | `/cite` | 切换为 citation_lookup | +| 模式 | `/outline` | 切换为 review_outline | +| 模式 | `/gap` | 切换为 gap_analysis | +| 知识库 | `/kb 知识库名` 或 选择列表项 | 添加/切换知识库 | +| 组合 | `/cite 我的文献库` | 切换模式 + 加载指定 KB | + +### 5.4 API 设计 + +**可选**:`GET /api/v1/chat/slash-commands` 返回可用命令列表(若命令静态可前端写死)。 + +**推荐**:命令列表前端维护,无需新 API。知识库列表已有 `GET /api/v1/projects`。 + +### 5.5 前端组件设计 + +| 组件 | 职责 | +|------|------| +| `SlashCommandMenu` | 弹出菜单,展示模式/功能/KB 列表,支持键盘上下选择、Enter 确认 | +| `ChatInput` | 监听 `onKeyDown`,当输入 `/` 时打开 `SlashCommandMenu`,将光标位置传给菜单用于定位 | +| `ToolModeSelector` | 已有,可与 SlashCommandMenu 共享模式列表 | + +**菜单结构**: + +``` +/ 快捷指令 +├── 模式 +│ ├── /qa — 问答 +│ ├── /cite — 引用查询 +│ ├── /outline — 综述提纲 +│ └── /gap — 缺口分析 +└── 知识库 + ├── 项目A + ├── 项目B + └── ... +``` + +**反馈机制**: + +- 选择后:toast 提示「已切换到引用查询模式」或「已加载知识库:项目A」 +- 若选择「加载知识库」:顶部 KB 选择器同步更新,Badge 展示新增的 KB + +--- + +## 6. 反馈机制汇总 + +| 阶段 | 后端事件 | 前端展示 | +|------|----------|----------| +| 意图识别 | `data-thinking(step=intent)` | ThinkingChain 显示「识别意图」 | +| 意图建议 | `data-intent-suggestion` | IntentSuggestionBanner | +| 检索 | `data-thinking(step=retrieve)` | 「搜索知识库」 | +| 排序 | `data-thinking(step=rank)` | 「分析引用」 | +| 清洗 | `data-thinking(step=clean)` | 「优化引用文本」 | +| 生成 | `data-thinking(step=generate)` | 「生成回答」 | +| 引用 | `data-citation` | InlineCitationTag + CitationCardList | +| 启发式 | `data-suggested-followups` | SuggestedFollowupButtons | +| 完成 | `data-thinking(step=complete)` | 收起 ThinkingChain | +| 智能补全 | 无流式 | 输入框内灰色建议 + 可选 loading | +| 斜杠命令 | 无 | Toast 确认 | + +--- + +## 7. 实施优先级 + +| 阶段 | 功能 | 依赖 | +|------|------|------| +| P1 | 意图感知(understand 扩展 + intent_route + suggest) | 无 | +| P1 | 启发式后续(predict_followups) | P1 意图 | +| P2 | 引用查询增强(颜色按 paper_id、段落级优化) | 无 | +| P2 | 斜杠命令(SlashCommandMenu) | 无 | +| P3 | 智能补全(complete API + ChatInput 集成) | 无 | + +--- + +## 8. 附录 + +### 8.1 ChatState 扩展字段(意图感知) + +```python +# state.py 新增 +intent_result: dict | None # {"intent": "function"|"kb"|"general", "suggested_function"?: str, "suggested_kb_ids"?: list[int]} +``` + +### 8.2 意图识别 Prompt 模板(草案) + +``` +你是一个科研助手。根据用户输入、当前功能模式、可用知识库列表,判断用户意图。 + +可用功能模式:qa(问答)、citation_lookup(引用查询)、review_outline(综述提纲)、gap_analysis(缺口分析)。 +可用知识库:{kb_list} + +用户输入:{message} +当前模式:{tool_mode} +当前选中知识库:{kb_ids} + +请返回 JSON:{"intent": "function"|"kb"|"general", "suggested_function"?: "citation_lookup"|..., "suggested_kb_ids"?: [1,2], "reason": "简短说明"} +``` + +### 8.3 参考文件 + +- 现有 Chat Pipeline:`backend/app/pipelines/chat/graph.py`、`nodes.py` +- 前端 Chat:`frontend/src/hooks/use-chat-stream.ts`、`MessageBubbleV2.tsx` +- 引用组件:`InlineCitationTag.tsx`、`CitationCardList.tsx`、`CitationCard.tsx` diff --git a/docs/prd/v3/02-knowledge-base.md b/docs/prd/v3/02-knowledge-base.md new file mode 100644 index 0000000..ba120aa --- /dev/null +++ b/docs/prd/v3/02-knowledge-base.md @@ -0,0 +1,918 @@ +# Omelette V3 PRD — 知识库管理模块 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 1. 模块概述 + +知识库管理模块是 Omelette 的核心数据入口,负责科研文献的创建、导入、去重、索引与订阅更新。本 PRD 细化以下四个子模块: + +1. **创建新知识库**:手动创建、AI 辅助创建、Zotero 导入创建 +2. **文献上传与管理**:PDF 上传、网络爬取、去重、列表、reindex、批量操作 +3. **Zotero 联动**:API 集成、导入、数据库导入、双向同步评估 +4. **订阅管理**:多源订阅、增量更新、定时任务、新文献通知 + +### 1.1 与现有实现的关系 + +| 现有能力 | V3 增强点 | +|----------|-----------| +| Project 模型(知识库) | 新增 AI 建库、Zotero 导入入口 | +| Upload Pipeline(extract→dedup→import→ocr→index) | 分级去重、AI 自动过滤、并行 OCR、进度反馈 | +| Search Pipeline | 网络爬取流程整合 | +| DedupService(DOI + 标题 + LLM) | 分级判定机制、HITL 冲突解决 | +| Subscription(RSS/API) | 多源统一、增量策略、定时任务、通知 | +| RAGService(LlamaIndex + ChromaDB) | reindex 机制、批量操作 | + +--- + +## 2. 创建新知识库 + +### 2.1 功能一:手动输入名称和简介 + +#### 用户故事 + +> 作为科研人员,我希望通过填写名称和简介快速创建一个空知识库,以便后续逐步添加文献。 + +#### 交互流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant F as 前端 + participant A as API + + U->>F: 点击「新建知识库」 + F->>F: 展示表单(名称、简介、领域) + U->>F: 填写并提交 + F->>A: POST /api/v1/projects + A->>A: 创建 Project 记录 + A->>F: 返回 ProjectRead + F->>U: 跳转至知识库详情页 +``` + +#### 数据模型变更 + +**无变更**。沿用现有 `Project` 模型: + +- `name`, `description`, `domain` 已支持 +- `settings` 可扩展存储后续配置 + +#### API 设计 + +沿用现有: + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects` | 创建知识库,Body: `ProjectCreate` | + +#### 前端反馈 + +- 提交时:Loading 状态 + 禁用按钮 +- 成功:Toast 提示「知识库已创建」,跳转详情 +- 失败:Toast 展示错误信息 + +--- + +### 2.2 功能二:AI 按钮 — 根据用户提示词自动新建 + +#### 用户故事 + +> 作为科研人员,我希望输入一段研究主题描述,由 AI 自动生成知识库名称、描述和推荐关键词,以便快速搭建结构化知识库。 + +#### 交互流程 + +```mermaid +flowchart TD + A[用户输入提示词] --> B[点击 AI 建库] + B --> C[调用 LLM 生成] + C --> D{生成成功?} + D -->|是| E[展示预览:名称、描述、推荐关键词] + D -->|否| F[展示错误,支持重试] + E --> G[用户确认/编辑] + G --> H[创建 Project + 可选创建 Keyword] + H --> I[跳转知识库详情] +``` + +#### 数据模型变更 + +**可选扩展**(留空点): + +- `Project.settings` 中可存储 `ai_prompt`、`ai_generated_at` 等元数据,便于后续追溯 +- 暂不新增表,优先复用现有字段 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/ai-create/preview` | 根据提示词生成预览,不落库 | + +**Request:** + +```json +{ + "prompt": "我想研究大语言模型在医疗诊断中的应用,重点关注多模态输入和可解释性" +} +``` + +**Response:** + +```json +{ + "code": 0, + "data": { + "name": "LLM 医疗诊断与可解释性", + "description": "聚焦大语言模型在医疗诊断场景的应用,涵盖多模态输入(文本、影像)与可解释性研究", + "domain": "AI in Healthcare", + "suggested_keywords": [ + {"term": "large language model", "level": "primary"}, + {"term": "medical diagnosis", "level": "primary"}, + {"term": "multimodal", "level": "secondary"}, + {"term": "explainability", "level": "secondary"} + ] + } +} +``` + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects` | 创建知识库(与手动创建共用,Body 可包含 `suggested_keywords` 用于批量创建 Keyword) | + +#### LangGraph 节点 + +**无需独立 Pipeline**。AI 建库为单次 LLM 调用,可在 `ProjectService` 或新建 `ProjectAIService` 中实现: + +```python +# 伪代码 +async def generate_project_preview(prompt: str) -> dict: + result = await llm.chat_json( + messages=[...], + task_type="project_ai_create", + ) + return result # name, description, domain, suggested_keywords +``` + +#### 前端反馈 + +- 点击 AI 建库:展示 Loading,禁用表单 +- 生成中:进度文案「正在生成名称与推荐关键词…」 +- 成功:展示预览卡片,支持编辑后确认 +- 失败:Toast 错误 + 重试按钮 + +#### 技术可行性 + +- **可行**:现有 `LLMClient.chat_json` 已支持结构化输出 +- **留空点**:`suggested_keywords` 的层级(primary/secondary)与现有 Keyword 三级体系的映射关系待定 + +--- + +### 2.3 功能三:从 Zotero 导入创建 + +#### 用户故事 + +> 作为 Zotero 用户,我希望选择 Zotero 中的某个 Collection,一键导入为 Omelette 知识库,以便在保留原有分类的同时获得 AI 能力。 + +#### 交互流程 + +详见 **第四节「Zotero 联动」** 中的「文章导入」部分。创建流程为: + +1. 用户选择 Zotero 数据源(API / 本地 .sqlite) +2. 选择 Collection 或全库 +3. 系统拉取条目列表,展示预览 +4. 用户确认后创建 Project,并导入文献元数据 + +--- + +## 3. 文献上传与管理 + +### 3.1 PDF 上传流程 + +#### 用户故事 + +> 作为用户,我希望上传 PDF 后,系统自动提取元数据、去重、OCR 并建立索引,我只需在冲突时参与确认,其余步骤自动完成。 + +#### 交互流程 + +```mermaid +flowchart TD + subgraph 上传阶段 + A[用户选择 PDF 文件] --> B[上传至服务端] + B --> C[提取元数据 doi/标题等] + end + + subgraph 去重阶段 + C --> D[自动去重过滤] + D --> E{存在冲突?} + E -->|是| F[用户确认 / AI 自动过滤] + E -->|否| G[直接进入导入] + F --> G + end + + subgraph 处理阶段 + G --> H[并行 OCR] + H --> I[自动 Index] + I --> J[完成] + end +``` + +#### 现有 vs 目标流程对比 + +| 环节 | 现有实现 | V3 目标 | +|------|----------|---------| +| 元数据提取 | `pdf_metadata.extract_metadata` | 保持,支持 pdfplumber + 可选增强 | +| 去重 | DOI + 标题相似度 0.85,同步检测 | 分级判定(见 3.3),支持 AI 自动过滤 | +| 用户确认 | 上传接口返回 conflicts,前端需二次调用 | 统一为 Pipeline HITL,支持 resume | +| OCR | 串行,`asyncio.to_thread` | 并行(多 PDF 同时 OCR,控制并发数) | +| Index | 后台 `process_papers_background` | 保持,增加 SSE 进度推送 | + +#### 数据模型变更 + +**无新增表**。沿用 `Paper`、`PaperChunk`、`PaperStatus`。 + +可选扩展: + +- `Task` 表:若需显式任务队列,可关联 `project_id`、`paper_ids`、`stage`、`progress` +- 现有 `process_papers_background` 为 fire-and-forget,进度通过 **留空点**:Task/SSE 机制待设计 + +#### API 设计 + +**现有接口保留并增强:** + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/{id}/papers/upload` | 上传 PDF,返回 `papers` + `conflicts` | +| POST | `/api/v1/projects/{id}/papers/process` | 触发 OCR+Index,支持 `paper_ids` 或全量 | + +**新增(可选):** + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/{id}/pipeline/upload/run` | 启动 Upload Pipeline(含 HITL),返回 `thread_id` | +| GET | `/api/v1/projects/{id}/pipeline/upload/status/{thread_id}` | 轮询或 SSE 获取进度 | + +#### LangGraph 节点 + +沿用 `create_upload_pipeline`,节点顺序: + +``` +extract_metadata → dedup → [hitl_dedup | apply_resolution] → import_papers → ocr → index +``` + +**增强点:** + +1. **ocr_node**:改为批量并行,例如 `asyncio.gather` 限制并发为 3 +2. **进度反馈**:每个 node 更新 `state["progress"]`、`state["stage"]`,通过 SSE 推送给前端 + +#### 前端反馈 + +| 阶段 | 反馈形式 | +|------|----------| +| 上传中 | 进度条(按文件数) | +| 元数据提取 | 「正在解析 PDF 元数据…」 | +| 去重 | 「检测到 N 篇可能重复,请确认」或「无冲突,继续处理」 | +| HITL | 冲突列表 + 操作按钮(保留旧/保留新/跳过) | +| OCR | 「正在 OCR:3/10」进度 | +| Index | 「正在建立索引:5/10」 | +| 完成 | Toast「已导入 N 篇文献,索引完成」 | + +#### 留空点 + +- [ ] AI 自动过滤冲突:根据用户偏好(如「优先保留有 PDF 的」)自动决策,需定义策略枚举 +- [ ] 并行 OCR 的并发数配置(默认 3,可配置) +- [ ] 大文件上传分片(当前单文件 50MB 限制) + +--- + +### 3.2 网络爬取流程 + +#### 用户故事 + +> 作为用户,我希望通过关键词在多个学术源(Semantic Scholar、OpenAlex、arXiv 等)检索,将结果去重后导入知识库,并自动下载 PDF、OCR、索引。 + +#### 交互流程 + +```mermaid +flowchart TD + A[用户输入关键词/选择订阅] --> B[Search Pipeline] + B --> C[多源联合检索] + C --> D[去重] + D --> E{存在冲突?} + E -->|是| F[HITL 确认] + E -->|否| G[导入元数据] + F --> G + G --> H[Crawl 下载 PDF] + H --> I[OCR] + I --> J[Index] + J --> K[完成] +``` + +#### 现有实现 + +- `create_search_pipeline`:search → dedup → hitl → apply_resolution → import → crawl → ocr → index +- `SearchService`:多 Provider(Semantic Scholar、OpenAlex、arXiv、Crossref) + +#### 数据模型变更 + +**无变更**。`Paper.source` 已区分 `semantic_scholar`、`openalex`、`arxiv` 等。 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/{id}/pipeline/search/run` | 启动 Search Pipeline,Body: `query`, `sources`, `max_results` | +| GET | `/api/v1/projects/{id}/pipeline/search/status/{thread_id}` | 进度查询 | + +#### LangGraph 节点 + +沿用 `create_search_pipeline`,与 3.1 类似增加进度推送。 + +#### 前端反馈 + +- 检索中:「正在检索 Semantic Scholar、OpenAlex…」 +- 去重/HITL:同 PDF 上传 +- Crawl:「正在下载 PDF:5/20」 +- 后续同 OCR、Index + +--- + +### 3.3 分级去重判定机制设计 + +#### 设计目标 + +将去重分为三个等级,不同等级采用不同策略,减少误杀、控制 LLM 调用成本。 + +```mermaid +flowchart TD + A[新文献] --> B{Level 1: DOI 完全匹配?} + B -->|是| C[确认为重复,直接过滤] + B -->|否| D{Level 2: 标题相似度 ≥ 0.90?} + D -->|是| E[确认为重复,直接过滤] + D -->|否| F{Level 3: 标题相似度 0.80~0.90?} + F -->|是| G[LLM 校验] + F -->|否| H[通过,导入] + G --> I{LLM 判定为重复?} + I -->|是| C + I -->|否| H +``` + +#### 等级定义 + +| 等级 | 条件 | 动作 | 实现位置 | +|------|------|------|----------| +| L1 | DOI 非空且与库内某篇相同 | 直接视为重复,不导入 | `dedup_node`、`DedupService.doi_hard_dedup` | +| L2 | 标题相似度 ≥ 0.90(无 DOI 或 DOI 不同) | 直接视为重复 | `dedup_node`,阈值可配置 | +| L3 | 标题相似度 0.80~0.90 | 调用 LLM 校验 | `DedupService.llm_verify_duplicate` | +| 通过 | 相似度 < 0.80 | 导入 | - | + +#### 数据模型变更 + +**无**。冲突结构沿用 `DedupConflictPair`,可扩展 `reason`: + +- `doi_duplicate` +- `title_similarity_high`(≥0.90) +- `title_similarity_medium`(0.80~0.90,待 LLM) + +#### 配置项(留空点) + +- `DEDUP_TITLE_HARD_THRESHOLD`:默认 0.90 +- `DEDUP_TITLE_LLM_THRESHOLD`:默认 0.80 +- 是否启用 L3 LLM 校验(可关闭以节省成本) + +#### 代码审计发现的问题 + +> 详见 [07-code-audit-and-fixes.md](./07-code-audit-and-fixes.md) + +| 问题 | 严重性 | 说明 | +|------|--------|------| +| **去重阈值分散** | P1 | Pipeline `dedup_node` 用 0.85,`DedupService` 用 0.90/0.80,上传 API 用 0.85。应统一到 `config.py` | +| **Pipeline 未复用 DedupService** | P1 | `dedup_node` 重复实现去重逻辑,不支持 L3 LLM 校验。应改为调用 `DedupService` | +| **apply_resolution BUG** | P0 | `action in ("keep_new", "skip")` 导致 `skip` 时仍导入新文献。应改为仅 `keep_new` 才添加 | +| **OCR 分块策略不一致** | P1 | Pipeline `ocr_node` 按页分块(一页一 chunk),`paper_processor` 用语义分块(1024字/100重叠)。应统一为语义分块 | +| **index_node N+1 查询** | P1 | 每篇 Paper 单独查 PaperChunk,应改为批量查询后按 paper_id 分组 | +| **index_node 缺 chunk_type** | P1 | 未传递 `chunk_type` 和 `section` 到 ChromaDB,导致引用无法按章节定位 | +| **Pipeline 取消无效** | P1 | 仅改 `task["status"]`,未将 `cancelled` 写入 Pipeline state,节点仍继续执行 | + +--- + +### 3.4 文献列表 + +#### 用户故事 + +> 作为用户,我希望在知识库内浏览文献列表,支持翻页、展开摘要、点击进入 PDF 查看器。 + +#### 交互流程 + +- 列表页:表格/卡片展示,支持分页、筛选(状态、年份)、搜索(标题/摘要) +- 行操作:展开摘要、查看 PDF、编辑、删除 +- 点击标题或「查看」:进入 PDF 查看器(需支持 `pdf_path` 或 `pdf_url`) + +#### 数据模型变更 + +**无**。`Paper` 已包含 `abstract`、`pdf_path`、`pdf_url`、`status`。 + +#### API 设计 + +沿用现有: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/projects/{id}/papers` | 分页列表,支持 `page`, `page_size`, `status`, `year`, `q`, `sort_by`, `order` | +| GET | `/api/v1/projects/{id}/papers/{paper_id}` | 单篇详情 | + +**PDF 访问:** + +- 方案 A:静态文件服务,`/api/v1/papers/{id}/pdf` 返回文件流 +- 方案 B:前端直接请求 `pdf_path` 对应 URL(需确保同源或 CORS) +- **留空点**:PDF 查看器具体实现(iframe / PDF.js / 第三方) + +#### 前端反馈 + +- 列表加载:Skeleton +- 展开摘要:内联展开,无额外请求 +- PDF 加载:Loading 状态,失败时提示 + +--- + +### 3.5 Reindex 按钮和机制 + +#### 用户故事 + +> 作为用户,当嵌入模型升级或索引异常时,我希望一键重建知识库的向量索引,而不必重新上传或 OCR。 + +#### 交互流程 + +```mermaid +sequenceDiagram + participant U as 用户 + participant F as 前端 + participant A as API + participant R as RAGService + + U->>F: 点击「重建索引」 + F->>F: 二次确认弹窗 + U->>F: 确认 + F->>A: POST /projects/{id}/rag/reindex + A->>R: 清空 ChromaDB collection + A->>A: 从 PaperChunk 重新读取 + A->>R: index_chunks 批量写入 + R->>A: 完成 + A->>F: 返回 stats + F->>U: Toast「索引已重建」 +``` + +#### 数据模型变更 + +**无**。仅操作 ChromaDB 与 `PaperChunk`,不修改 `Paper`。 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/{id}/rag/reindex` | 清空并重建索引,返回 `{indexed: N}` | + +> 注:现有 `POST /rag/index` 为增量添加,不先清空。Reindex 需先调用 `DELETE /rag/index` 再 `POST /rag/index`,或封装为独立 `POST /rag/reindex` 端点。 + +**实现要点:** + +1. 删除 `project_{id}` collection(调用 `RAGService.delete_index`) +2. 查询 `Paper.status == INDEXED` 的 Paper 及其 PaperChunk +3. 调用 `RAGService.index_chunks` 批量写入 +4. 使用 `asyncio.to_thread` 包装,避免阻塞 + +#### 前端反馈 + +- 点击后:Loading「正在重建索引…」 +- 大库:使用现有 `POST /rag/index/stream` 获取 SSE 进度(`stage`、`percent`) +- 完成:Toast「索引已重建,共 N 个片段」 + +#### 留空点 + +- [ ] 增量 reindex:仅对变更的 Paper 更新,避免全量重建 +- [ ] 嵌入模型切换后的兼容性(不同模型向量维度不同,需清空重建) + +--- + +### 3.6 批量操作 + +#### 用户故事 + +> 作为用户,我希望对多篇文献执行批量删除、批量重新 OCR、批量导出等操作。 + +#### 交互流程 + +- 列表支持多选(复选框) +- 选中后展示批量操作栏:删除、重新处理(OCR+Index)、导出元数据 +- 删除:二次确认,软删除或硬删除待定 +- 重新处理:将 `status` 回退至 `PDF_DOWNLOADED` 或 `OCR_COMPLETE`,重新跑 Pipeline + +#### 数据模型变更 + +**无**。若引入软删除,可增加 `Paper.deleted_at`,当前可先硬删除。 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| DELETE | `/api/v1/projects/{id}/papers/bulk` | Body: `{paper_ids: [1,2,3]}`,批量删除 | +| POST | `/api/v1/projects/{id}/papers/bulk/process` | Body: `{paper_ids: [1,2,3]}`,批量重新 OCR+Index | +| GET | `/api/v1/projects/{id}/papers/export` | Query: `paper_ids`,导出 CSV/BibTeX(留空点) | + +#### 前端反馈 + +- 批量删除:确认弹窗「将删除 N 篇文献,且无法恢复」 +- 批量处理:同 3.1 的 OCR/Index 进度 +- 导出:下载文件 + +--- + +## 4. Zotero 联动 + +### 4.1 Zotero API 集成方案 + +#### 技术背景 + +- Zotero Web API v3:`https://api.zotero.org` +- 认证:API Key(`Authorization: Bearer ` 或 `Zotero-API-Key`) +- 用户库:`/users/{user_id}/...` +- 群组库:`/groups/{group_id}/...` + +#### 资源端点 + +| 资源 | 端点示例 | +|------|----------| +| 顶层 Collection | `/users/{uid}/collections/top` | +| 某 Collection 子集 | `/collections/{key}/collections` | +| Collection 内条目 | `/collections/{key}/items` | +| 单条 Item | `/items/{itemKey}` | +| Item 附件(含 PDF) | `/items/{itemKey}/children`,过滤 `itemType=attachment` | + +#### 数据模型变更 + +**新增表:`zotero_connection`(可选,用于存储 API 配置)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | PK | +| user_id | int | 关联用户(若有多用户) | +| api_key | str | 加密存储 | +| library_type | str | `user` / `group` | +| library_id | str | user_id 或 group_id | +| last_synced_at | datetime | 上次同步时间 | +| created_at | datetime | - | + +**留空点**:当前 Omelette 无多用户,可简化为全局配置或 `Project.settings["zotero_api_key"]`。 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/integrations/zotero/connect` | 保存 API Key、library_type、library_id | +| GET | `/api/v1/integrations/zotero/collections` | 拉取顶层 Collections 列表 | +| GET | `/api/v1/integrations/zotero/collections/{key}/items` | 拉取某 Collection 内 Items | +| GET | `/api/v1/integrations/zotero/items/{key}` | 单条 Item 详情(含附件) | + +#### 实现要点 + +- 使用 `httpx.AsyncClient`,Header:`Zotero-API-Key: {key}` +- Zotero Item 的 `data` 含 `title`, `creators`, `date`, `DOI` 等,需映射为 `StandardizedPaper` / `Paper` +- 附件中 `contentType=application/pdf` 的 `links.attachment.href` 可下载 PDF + +--- + +### 4.2 文章导入(单篇 / 批量 / 整个 Collection) + +#### 用户故事 + +> 作为用户,我希望从 Zotero 选择单篇、多篇或整个 Collection,导入到 Omelette 知识库,并自动下载 PDF、OCR、索引。 + +#### 交互流程 + +```mermaid +flowchart TD + A[选择 Zotero 数据源] --> B[拉取 Collections 列表] + B --> C[用户选择 Collection 或全库] + C --> D[拉取 Items 列表] + D --> E[展示预览:标题、作者、DOI] + E --> F[用户选择目标知识库] + F --> G[去重检测] + G --> H{有冲突?} + H -->|是| I[HITL 确认] + H -->|否| J[导入元数据] + I --> J + J --> K[下载 PDF 附件] + K --> L[OCR + Index] + L --> M[完成] +``` + +#### 数据模型变更 + +**无**。导入后生成 `Paper`,`source="zotero"`,`source_id` 存 Zotero itemKey。 + +可选:`Paper.extra_metadata["zotero_key"]` 用于双向同步时匹配。 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/{id}/import/zotero` | Body: `{collection_key?: str, item_keys?: list[str]}`,启动导入 Pipeline | + +#### LangGraph 节点 + +**新建 `ZoteroImportPipeline`:** + +``` +fetch_zotero_items → dedup → [hitl_dedup | apply_resolution] → import_papers → crawl_zotero_pdfs → ocr → index +``` + +- `fetch_zotero_items`:根据 `collection_key` 或 `item_keys` 从 Zotero API 拉取,转换为 `StandardizedPaper` 列表 +- `crawl_zotero_pdfs`:对每条 Item 的附件中 PDF 调用 Zotero 附件 URL 下载,写入 `pdf_path` + +#### 前端反馈 + +- 拉取 Collections:Loading「正在连接 Zotero…」 +- 拉取 Items:Progress「已获取 50 条…」 +- 去重/HITL:同 3.1 +- 下载 PDF:Progress「正在下载:5/20」 +- OCR/Index:同 3.1 + +--- + +### 4.3 数据库(.sqlite)导入 + +#### 用户故事 + +> 作为用户,当无法使用 Zotero API(如无网络、私有库)时,我希望上传 Zotero 的 `zotero.sqlite` 文件,解析后导入文献。 + +#### 技术背景 + +- Zotero 本地数据库:`~/Zotero/zotero.sqlite` +- 主要表:`items`(条目)、`itemData`(字段值)、`itemAttachments`(附件)、`collections` 等 +- 需解析 SQLite,映射为 `Paper` 结构 + +#### 交互流程 + +1. 用户上传 `zotero.sqlite` 文件 +2. 后端解析 SQLite,提取 `items` 中 type 为 `journalArticle`、`conferencePaper` 等 +3. 关联 `itemData` 获取 title、DOI、authors 等 +4. 展示预览列表,用户选择目标知识库 +5. 导入元数据;PDF 需从本地路径解析(`itemAttachments` 存相对路径) +6. **留空点**:用户本地上传的 sqlite 中,PDF 路径为上传者机器路径,无法直接使用。可选方案:仅导入元数据,或要求用户同时上传 PDF 包 + +#### 数据模型变更 + +**无**。 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/import/zotero-sqlite/preview` | 上传 sqlite,返回解析后的条目列表(不落库) | +| POST | `/api/v1/projects/{id}/import/zotero-sqlite` | Body: `{item_ids: [...]}`,导入选中条目(仅元数据) | + +#### 留空点 + +- [ ] PDF 路径解析:Zotero 存储路径格式因平台而异,需兼容 Windows/macOS/Linux +- [ ] 大 sqlite 文件上传限制与超时 +- [ ] 是否支持「上传 sqlite + 打包 PDF 目录」的混合导入 + +--- + +### 4.4 双向同步可能性评估 + +#### 目标 + +Omelette 中的修改(如添加笔记、标签)同步回 Zotero,或 Zotero 的修改同步到 Omelette。 + +#### 技术评估 + +| 方向 | 可行性 | 说明 | +|------|--------|------| +| Zotero → Omelette | ✅ 可行 | 通过 API 轮询或 Webhook(若 Zotero 支持)拉取变更 | +| Omelette → Zotero | ⚠️ 部分可行 | Zotero API 支持 Write(创建/更新 Item),需维护 itemKey 映射 | +| 实时双向 | ❌ 复杂 | 需解决冲突策略、版本号、离线编辑等 | + +#### 建议 + +- **V3 范围**:仅实现 **Zotero → Omelette** 单向导入与定期「增量拉取」 +- **后续迭代**:评估 Omelette → Zotero 的写回(如笔记、标签),需设计 `Paper.extra_metadata["zotero_key"]` 的维护 +- **留空点**:Zotero 的 `version` 机制用于增量同步,需在后续 PRD 中细化 + +--- + +## 5. 订阅管理 + +### 5.1 多源订阅(RSS、API) + +#### 用户故事 + +> 作为用户,我希望为知识库添加多种订阅源(如 arXiv 某分类 RSS、Semantic Scholar API 检索),系统按计划自动检查并导入新文献。 + +#### 数据模型 + +沿用 `Subscription` 模型,扩展 `sources` 与 `query`: + +- `sources`:`["rss", "semantic_scholar", "openalex"]` 或 RSS URL +- `query`:检索关键词或 RSS Feed URL +- `subscription_type`:`rss` | `api`(可选新增字段,或从 `sources` 推断) + +#### 统一抽象 + +```python +# 订阅源类型 +class SubscriptionSourceType: + RSS = "rss" # 单个 RSS URL + API_SEARCH = "api" # 多源 API 检索 +``` + +单个 Subscription 可对应: +- 一个 RSS Feed URL,或 +- 一组 API 源 + 检索词 + +#### API 设计 + +沿用现有 CRUD,增强 `SubscriptionCreate`: + +```json +{ + "name": "arXiv 光学", + "subscription_type": "rss", + "feed_url": "https://export.arxiv.org/rss/physics.optics", + "frequency": "daily", + "max_results": 50 +} +``` + +```json +{ + "name": "LLM 医疗 多源检索", + "subscription_type": "api", + "query": "large language model medical diagnosis", + "sources": ["semantic_scholar", "openalex", "arxiv"], + "frequency": "weekly", + "max_results": 30 +} +``` + +#### 前端反馈 + +- 添加订阅:表单校验,保存后 Toast +- 订阅列表:展示类型、上次运行时间、找到数量 + +--- + +### 5.2 增量更新策略 + +#### 策略设计 + +| 源类型 | 增量依据 | 实现 | +|--------|----------|------| +| RSS | `published` / `updated` 时间戳 | `SubscriptionService.check_rss_feed(since=...)` 已支持 | +| API | 出版年份 / 检索结果去重 | `check_api_updates` 按 `since_days` 过滤,与库内 DOI 去重 | + +#### 去重 + +- 拉取到新条目后,先与 `Project` 内 `Paper` 做 DOI/标题去重 +- 仅导入不重复的,避免重复文献 + +#### 留空点 + +- [ ] API 源(如 Semantic Scholar)是否支持按日期过滤,需查阅各 API 文档 +- [ ] 跨订阅源去重:同一文献可能被多个订阅拉取,需在导入前统一去重 + +--- + +### 5.3 定时任务机制 + +#### 用户故事 + +> 作为用户,我希望订阅按设定频率(每日/每周)自动运行,无需手动触发。 + +#### 实现方案 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| APScheduler | 轻量、进程内 | 单机、重启丢失 | +| Celery Beat | 分布式、持久化 | 引入 Redis/RabbitMQ | +| 系统 Crontab | 简单 | 需额外配置 | +| 后台线程 + asyncio | 无新依赖 | 需自行实现调度逻辑 | + +#### 建议(V3) + +- 使用 **APScheduler** 或 **简单 asyncio 循环**:在 FastAPI 启动时注册定时任务 +- 任务:遍历 `Subscription` 中 `is_active=True` 且 `frequency` 到期者,调用 `SubscriptionService.check_*`,将新文献导入对应 Project +- 调度逻辑放在 `app/scheduler.py` 或 `app/tasks/subscription.py` + +#### 数据模型变更 + +- `Subscription.last_run_at`:已有,用于判断是否到期 +- 可选:`Subscription.next_run_at`,由调度器计算 + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/projects/{id}/subscriptions/{sub_id}/trigger` | 手动触发(已有) | +| GET | `/api/v1/subscriptions/next-run` | 查询下次预计运行时间(留空点) | + +#### 留空点 + +- [ ] 定时任务持久化:服务重启后恢复调度 +- [ ] 多实例部署时的任务互斥(避免重复执行) + +--- + +### 5.4 新文献通知 + +#### 用户故事 + +> 作为用户,当订阅发现新文献并导入后,我希望收到通知(应用内或邮件)。 + +#### 实现方案 + +| 渠道 | 实现难度 | 说明 | +|------|----------|------| +| 应用内 | 低 | 前端轮询或 WebSocket,展示「N 篇新文献已导入」 | +| 邮件 | 中 | 需 SMTP 配置,发送摘要邮件 | +| 桌面通知 | 低 | 前端 Notification API | + +#### 建议(V3) + +- **优先**:应用内通知 — 在订阅触发完成后,写入 `Notification` 表(需新增)或复用现有消息机制 +- **后续**:邮件、桌面通知 + +#### 数据模型变更(留空点) + +**新增 `notification` 表:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | PK | +| project_id | int | 关联知识库 | +| subscription_id | int | 关联订阅 | +| type | str | `new_papers` | +| payload | JSON | `{paper_count, paper_ids}` | +| read_at | datetime | 已读时间 | +| created_at | datetime | - | + +#### API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/notifications` | 未读通知列表 | +| POST | `/api/v1/notifications/{id}/read` | 标记已读 | + +--- + +## 6. 反馈机制总览 + +> 原则:每步操作都需要有前端可视化进度。 + +| 操作类型 | 反馈方式 | 实现要点 | +|----------|----------|----------| +| 创建知识库 | Toast + 跳转 | 同步接口 | +| AI 建库 | Loading + 预览卡片 | 单次 LLM 调用 | +| PDF 上传 | 进度条 + 阶段文案 | 分阶段,HITL 时中断 | +| 网络检索 | 同上 | Search Pipeline | +| Zotero 导入 | 同上 | ZoteroImport Pipeline | +| Reindex | Loading + 可选 SSE | 长时间任务 | +| 批量操作 | 确认弹窗 + 进度 | 复用 Pipeline | +| 订阅触发 | Toast + 结果摘要 | 异步任务 | +| 定时订阅 | 无直接反馈 | 依赖通知 | + +--- + +## 7. 留空点汇总 + +| 编号 | 模块 | 留空点 | +|------|------|--------| +| 1 | 创建知识库 | AI 建库的 `suggested_keywords` 与 Keyword 三级体系映射 | +| 2 | 文献上传 | AI 自动过滤冲突的策略枚举 | +| 3 | 文献上传 | 并行 OCR 并发数配置 | +| 4 | 文献上传 | 大文件分片上传 | +| 5 | 文献列表 | PDF 查看器实现方案 | +| 6 | Reindex | 增量 reindex、嵌入模型切换兼容性 | +| 7 | Zotero | 多用户下的 `zotero_connection` 存储 | +| 8 | Zotero | sqlite 导入的 PDF 路径解析与打包方案 | +| 9 | Zotero | 双向同步的冲突策略与版本管理 | +| 10 | 订阅 | API 源按日期过滤能力 | +| 11 | 订阅 | 跨订阅源去重 | +| 12 | 订阅 | 定时任务持久化与多实例互斥 | +| 13 | 通知 | `notification` 表与推送机制 | + +--- + +## 8. 附录:Pipeline 状态与进度字段 + +为支持前端进度展示,建议 `PipelineState` 统一包含: + +```python +# state.py 扩展 +progress: int # 0-100 +stage: str # "extract" | "dedup" | "hitl" | "import" | "crawl" | "ocr" | "index" +stage_detail: str # "正在 OCR:3/10" +result_summary: dict # {"imported": 5, "indexed": 5, "conflicts": 2} +``` + +SSE 推送格式可复用 Chat 的 Data Stream Protocol,或自定义 `stage`、`progress` 事件。 + +--- + +*本文档为知识库管理模块详细 PRD,与 [00-overview.md](./00-overview.md) 及后续架构文档配套使用。* diff --git a/docs/prd/v3/03-settings-and-integrations.md b/docs/prd/v3/03-settings-and-integrations.md new file mode 100644 index 0000000..d4463e9 --- /dev/null +++ b/docs/prd/v3/03-settings-and-integrations.md @@ -0,0 +1,302 @@ +# Omelette V3 PRD — 设置与集成模块 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 1. 模块概述 + +设置与集成模块负责:**多模型 LLM 管理**、**Zotero 集成**、**系统配置**。本 PRD 从产品侧定义功能、交互与验收标准,技术实现参考 `docs/plans/2026-03-11-feat-multi-model-llm-settings-plan.md`。 + +### 1.1 与现有架构的关系 + +- **后端**:`UserSettingsService`、`UserSettings` 表、`LLMClient` + LangChain 工厂 +- **前端**:`SettingsPage` 已有 Provider/模型选择、API Key、连接测试 +- **配置优先级**:DB 覆盖 .env;请求时 `model` 参数可覆盖默认 + +### 1.2 模块边界 + +| 子模块 | 状态 | 说明 | +|--------|------|------| +| 多模型 LLM 管理 | V2 已有,V3 增强 | Provider 切换、模型选择、连接测试、任务级模型 | +| Zotero 集成 | V3 新增 | 文库导入、可选双向同步 | +| 系统配置 | V2 已有 | 数据目录、Embedding、代理等只读/受限编辑 | + +--- + +## 2. 多模型 LLM 管理 + +### 2.1 产品目标 + +科研场景下,用户需按任务选择不同模型: + +- **成本敏感**:关键词扩展、去重用经济型;综述、深度问答用高能力模型 +- **合规与隐私**:机构要求使用国产云或本地 Ollama +- **能力对比**:研究者希望对比不同模型在引用生成、理解上的表现 +- **可用性**:单一 Provider 故障时快速切换备用 + +### 2.2 功能清单 + +| 功能 | 描述 | 验收标准 | +|------|------|----------| +| Provider 选择 | 支持 OpenAI、Anthropic、阿里云、火山引擎、Ollama、Mock | 下拉选择,保存后持久化 | +| 模型选择 | 每个 Provider 对应模型列表,可切换 | 模型列表来自 `GET /api/v1/settings/models` | +| API Key 配置 | 各 Provider 的 Key、Base URL(如需要) | 密码框、脱敏展示、保存时完整写入 | +| 连接测试 | 一键验证当前配置是否可用 | 显示成功/失败及错误信息 | +| 高级参数 | temperature、max_tokens | 可编辑,有合理默认值 | +| 全局模型切换 | 在 Playground/聊天输入框旁显示当前模型,支持切换 | 切换后新请求使用新模型 | +| 任务级模型(可选) | 某些任务可指定模型(如写作用 Claude) | 请求体可带 `model` 覆盖 | + +### 2.3 交互设计 + +#### 2.3.1 设置页布局 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 设置 │ +├─────────────────────────────────────────────────────────┤ +│ LLM 配置 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Provider: [阿里云百炼 ▼] 模型: [qwen3.5-plus ▼] │ │ +│ │ API Key: [••••••••••••••••••••] [显示] [测试连接] │ │ +│ │ Base URL: [https://dashscope...] (可选) │ │ +│ │ 高级: temperature [0.7] max_tokens [4096] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ [保存] │ +├─────────────────────────────────────────────────────────┤ +│ 系统配置(只读/受限) │ +│ 数据目录、Embedding 模型、代理等 │ +└─────────────────────────────────────────────────────────┘ +``` + +#### 2.3.2 全局模型切换器 + +- **位置**:Playground 顶部或聊天输入框上方 +- **形态**:下拉或徽章,显示当前 `Provider / 模型`,点击可切换 +- **行为**:切换后写入 UserSettings 或会话状态,后续请求携带新模型 + +#### 2.3.3 连接测试 + +- **触发**:点击「测试连接」按钮 +- **请求**:`POST /api/v1/settings/test-connection` +- **反馈**:Loading → 成功(绿色 + 简短响应示例)或 失败(红色 + 错误信息) +- **限流**:同一 IP 每分钟最多 5 次(后端实现) + +### 2.4 配置合并规则 + +| 优先级(从低到高) | 来源 | +|-------------------|------| +| 1 | 代码默认值 | +| 2 | .env 文件 | +| 3 | DB UserSettings | +| 4 | 请求时显式传入的 `provider`、`model` | + +**规则**:DB 中非空值覆盖 .env;API Key 若为脱敏值(含 `***`),保存时跳过,不覆盖真实 Key。 + +### 2.5 安全与隐私 + +| 项 | 说明 | +|----|------| +| API Key 存储 | 单用户本地部署可明文存 DB;多用户场景需加密 | +| 前端脱敏 | 返回格式 `sk-xxxx***xxxx`,仅前 4 + 后 4 位 | +| 连接测试 | 仅发送无害 prompt(如 "Hi"),不记录 | + +--- + +## 3. Zotero 集成 + +### 3.1 产品目标 + +- **复用 Zotero 文库**:用户已有大量文献在 Zotero,希望导入到 Omelette 做 RAG、对话、写作辅助 +- **不替代 Zotero**:Omelette 专注智能分析,文献管理仍以 Zotero 为主 +- **可选双向同步**:在 Omelette 中新增的标注、笔记可写回 Zotero(Phase 2) + +### 3.2 集成方式 + +Zotero 提供 **Web API** 与 **本地 SQLite** 两种访问方式: + +| 方式 | 优点 | 缺点 | +|------|------|------| +| Web API | 官方支持、跨平台、支持群组 | 需 API Key、有速率限制、无法直接读附件 | +| 本地 SQLite | 无网络、可读附件路径 | 需 Zotero 安装路径、数据库格式可能变更 | + +**建议**:优先支持 **Web API**,后续可增加本地 SQLite 作为补充(用于读取附件路径)。 + +### 3.3 功能清单 + +| 功能 | 描述 | 验收标准 | +|------|------|----------| +| Zotero 连接配置 | 输入 API Key、User ID 或 Group ID | 保存到 UserSettings,支持测试连接 | +| 文库/集合选择 | 选择要导入的 Zotero 集合(Collection) | 树形选择,支持多选 | +| 一键导入 | 将选中集合的文献元数据导入 Omelette 项目 | 创建 Paper 记录,状态为 metadata_only | +| 附件同步(可选) | 若 Zotero 有 PDF 附件,可同步路径或下载 | Phase 2;需处理 Zotero 存储结构 | +| 双向同步(可选) | Omelette 中的笔记、标签写回 Zotero | Phase 2;需 Zotero Items API 写权限 | + +### 3.4 用户故事 + +| ID | 角色 | 需求 | 验收标准 | +|----|------|------|----------| +| Z1 | 科研人员 | 将 Zotero 中某项目的文献导入 Omelette | 选择集合 → 导入 → 项目中出现对应 Paper | +| Z2 | 科研人员 | 导入时自动去重(与现有 Paper 比对 DOI/标题) | 重复文献不重复创建,可提示 | +| Z3 | 科研人员 | 导入后自动触发爬取、OCR、索引 | 可选「导入后自动建库」 | +| Z4 | 科研人员 | 配置错误时能明确提示 | 测试连接返回具体错误信息 | + +### 3.5 交互设计 + +#### 3.5.1 设置页 — Zotero 配置 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Zotero 集成 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ API Key: [••••••••••••••••••••] [显示] [测试连接] │ │ +│ │ User/Group ID: [12345678] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ [保存] │ +└─────────────────────────────────────────────────────────┘ +``` + +- **API Key**:从 [zotero.org/settings/keys](https://www.zotero.org/settings/keys) 创建,需 `library:read` 权限 +- **User/Group ID**:个人文库为 User ID;群组文库为 Group ID,可从 Zotero 群组 URL 获取 + +#### 3.5.2 知识库/项目页 — 从 Zotero 导入 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 从 Zotero 导入 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 选择集合: │ │ +│ │ ☑ My Library │ │ +│ │ ☑ 项目A/子集合1 │ │ +│ │ ☐ 项目A/子集合2 │ │ +│ │ ☑ 项目B │ │ +│ │ │ │ +│ │ ☐ 导入后自动建库(爬取 PDF、OCR、索引) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ [导入] │ +└─────────────────────────────────────────────────────────┘ +``` + +- **导入**:异步任务,返回 task_id,可查进度 +- **去重**:按 DOI 优先,无 DOI 按标题相似度 + +### 3.6 数据流 + +``` +Zotero API (collections/items) + → 获取 items 元数据(title, authors, DOI, year, ...) + → 去重(与项目 Paper 比对) + → 创建 Paper(metadata_only) + → [可选] 触发 crawl → ocr → index +``` + +### 3.7 技术要点 + +| 项 | 说明 | +|----|------| +| Zotero API | `GET /users/{userId}/collections`、`GET /users/{userId}/items`,支持 `collection` 过滤 | +| 速率限制 | 每用户 100 请求/分钟(Zotero 限制),需客户端限流 | +| 附件 | Web API 可获取 attachment 的 URL,但部分需认证;本地 SQLite 可读 `storage` 路径 | +| 错误处理 | 401 未授权、404 无权限、429 超限,需明确提示 | + +### 3.8 预留空间 + +- **Better BibTeX citekey**:若 Zotero 安装 BBT,可通过 API 获取 extra 字段中的 citekey,便于 LaTeX 用户 +- **双向同步**:Omelette 的 Paper.notes、tags 写回 Zotero item notes、tags +- **增量同步**:定时检测 Zotero 集合变更,增量更新 Omelette + +--- + +## 4. 系统配置 + +### 4.1 只读/受限项 + +| 配置项 | 说明 | 是否可编辑 | +|--------|------|:----------:| +| data_dir | 数据根目录 | 仅 .env | +| pdf_dir, ocr_output_dir, chroma_db_dir | 子目录 | 仅 .env | +| embedding_model | 嵌入模型 | 仅 .env | +| reranker_model | 重排序模型 | 仅 .env | +| cuda_visible_devices | GPU 设备 | 仅 .env | +| semantic_scholar_api_key | S2 API Key | 可编辑(Settings) | +| unpaywall_email | Unpaywall 邮箱 | 可编辑(Settings) | +| http_proxy | 代理 | 仅 .env | + +### 4.2 展示方式 + +- 设置页「系统配置」区域以只读形式展示 +- 敏感项(API Key)脱敏 +- 提供「从 .env 重新加载」说明(修改 .env 后需重启服务) + +--- + +## 5. API 设计 + +### 5.1 已有接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/settings` | 获取合并后配置(脱敏) | +| PUT | `/api/v1/settings` | 更新配置 | +| GET | `/api/v1/settings/models` | 获取可用 Provider 及模型列表 | +| POST | `/api/v1/settings/test-connection` | 测试 LLM 连接 | + +### 5.2 Zotero 相关接口(新增) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/settings/test-zotero` | 测试 Zotero 连接 | +| GET | `/api/v1/zotero/collections` | 获取 Zotero 集合树(需先配置 Key) | +| POST | `/api/v1/projects/{id}/papers/import-zotero` | 从 Zotero 导入文献到项目 | + +**请求示例**: + +```json +POST /api/v1/projects/1/papers/import-zotero +{ + "collection_keys": ["ABC123", "DEF456"], + "auto_index": true +} +``` + +**响应**:返回 `task_id`,可通过 `GET /api/v1/tasks/{task_id}` 查询进度。 + +--- + +## 6. 验收标准汇总 + +### 6.1 多模型管理 + +- [ ] 设置页可编辑 Provider、模型、各 Provider 的 API Key、Base URL +- [ ] 保存后刷新页面,配置仍生效 +- [ ] 连接测试按钮可触发测试并显示成功/失败 +- [ ] 高级参数(temperature、max_tokens)可编辑 +- [ ] Playground 有模型选择器,可切换当前模型 +- [ ] 模型切换后,新请求使用新模型 + +### 6.2 Zotero 集成 + +- [ ] 设置页可配置 Zotero API Key、User/Group ID +- [ ] 测试 Zotero 连接可验证配置 +- [ ] 项目页可发起「从 Zotero 导入」,选择集合后导入 +- [ ] 导入时按 DOI/标题去重 +- [ ] 可选「导入后自动建库」触发爬取、OCR、索引 + +### 6.3 端到端 + +- [ ] 仅配置 .env 时,系统行为与当前一致 +- [ ] 通过前端配置后,无需重启服务即可生效 +- [ ] 连接测试失败时,用户能明确看到错误信息 + +--- + +## 7. 实施顺序建议 + +| 阶段 | 内容 | 预估 | +|------|------|------| +| Phase 1 | 多模型管理前端完善(模型选择器、连接测试优化) | 1 周 | +| Phase 2 | Zotero 连接配置 + 测试接口 | 0.5 周 | +| Phase 3 | Zotero 集合获取 + 导入接口 + 前端导入 UI | 1 周 | +| Phase 4 | 导入后自动建库、去重优化 | 0.5 周 | + +--- + +*本文档为设置与集成模块的产品设计,技术实现见 `docs/plans/` 及后端 API 文档。* diff --git a/docs/prd/v3/04-architecture.md b/docs/prd/v3/04-architecture.md new file mode 100644 index 0000000..afa97d6 --- /dev/null +++ b/docs/prd/v3/04-architecture.md @@ -0,0 +1,715 @@ +# Omelette V3 — 整体架构设计与技术方案 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 文档说明 + +本文档由 Omelette 子 Agent 产出,负责细化「整体架构设计与技术方案」。基于现有技术栈与架构问题,提供渐进式、可落地的设计建议。 + +### 设计原则 + +1. **从简到繁**:优先解决 P0 问题(Pipeline 持久化、API 去重),再逐步引入 Reranker、混合检索、模型分级 +2. **模块解耦**:编排层与服务层边界清晰,各 service 可独立测试、独立替换 +3. **多人协作**:按域划分(chat、rag、pipelines、settings),减少 merge 冲突 +4. **留空标注**:明确标注「后续优化」点,避免过度设计 + +--- + +## 一、整体架构设计 + +### 1.1 系统分层架构图 + +```mermaid +flowchart TB + subgraph 表现层["表现层 (Presentation)"] + Web["Web 前端 (React)"] + MCP["MCP 客户端 (AI IDE)"] + end + + subgraph 网关层["网关层 (Gateway)"] + API["REST API (FastAPI)"] + SSE["SSE 流式"] + Auth["认证中间件"] + end + + subgraph 编排层["编排层 (Orchestration)"] + ChatGraph["Chat LangGraph"] + SearchGraph["Search Pipeline"] + UploadGraph["Upload Pipeline"] + end + + subgraph 服务层["服务层 (Services)"] + RAG["RAG Service"] + Search["Search Service"] + Dedup["Dedup Service"] + Crawler["Crawler Service"] + OCR["OCR Service"] + Sub["Subscription Service"] + LLM["LLM Client"] + Embed["Embedding Service"] + end + + subgraph 数据层["数据层 (Data)"] + SQLite["SQLite (元数据)"] + Chroma["ChromaDB (向量)"] + FS["文件系统 (PDF)"] + end + + Web --> API + MCP --> API + API --> Auth + Auth --> ChatGraph + Auth --> SearchGraph + Auth --> UploadGraph + API --> SSE + + ChatGraph --> RAG + ChatGraph --> LLM + SearchGraph --> Search + SearchGraph --> Dedup + SearchGraph --> Crawler + SearchGraph --> OCR + UploadGraph --> Dedup + UploadGraph --> OCR + + RAG --> Embed + RAG --> LLM + RAG --> Chroma + Search --> SQLite + Dedup --> SQLite + Crawler --> FS + OCR --> FS + Sub --> Search + Sub --> SQLite +``` + +### 1.2 模块间通信与依赖关系 + +```mermaid +flowchart LR + subgraph 核心域["核心域"] + Chat["对话引擎"] + KB["知识库管理"] + Writing["写作助手"] + end + + subgraph 编排域["编排域"] + LangGraph["LangGraph Pipelines"] + end + + subgraph 基础域["基础域"] + RAG["RAG"] + Search["Search"] + Dedup["Dedup"] + Crawler["Crawler"] + OCR["OCR"] + Sub["Subscription"] + LLM["LLM"] + Embed["Embedding"] + MCP["MCP Server"] + end + + Chat --> LangGraph + KB --> LangGraph + Writing --> LangGraph + + LangGraph --> RAG + LangGraph --> Search + LangGraph --> Dedup + LangGraph --> Crawler + LangGraph --> OCR + LangGraph --> LLM + + RAG --> Embed + RAG --> LLM + Sub --> Search + MCP -.-> RAG + MCP -.-> Search +``` + +**依赖原则**: +- 编排层依赖服务层,服务层不依赖编排层 +- 基础域内部:RAG 依赖 Embed/LLM,Sub 依赖 Search +- MCP 通过 HTTP 调用后端 API,与前端共享同一套服务 + +### 1.3 数据流图:用户输入到最终输出 + +#### 1.3.1 Chat 对话流 + +```mermaid +sequenceDiagram + participant U as 用户 + participant F as 前端 + participant API as Chat API + participant G as LangGraph + participant RAG as RAG Service + participant LLM as LLM Client + participant DB as SQLite + participant Chroma as ChromaDB + + U->>F: 输入消息 + 选择知识库 + F->>API: POST /chat/stream (SSE) + API->>G: astream(initial_state) + G->>DB: 加载对话历史 + G->>RAG: query(kb_ids, question) + RAG->>Chroma: 向量检索 + RAG->>LLM: 生成答案 (可选) + G->>G: rank + clean + G->>LLM: chat_stream(messages) + LLM-->>G: token stream + G-->>API: SSE events (text-delta, data-citation...) + API-->>F: SSE stream + F-->>U: 流式展示 + G->>DB: persist 对话与消息 +``` + +#### 1.3.2 Search Pipeline 流 + +```mermaid +sequenceDiagram + participant U as 用户 + participant F as 前端 + participant API as Pipelines API + participant G as SearchPipeline + participant Search as Search Service + participant Dedup as Dedup Service + participant Crawler as Crawler + participant OCR as OCR Service + participant RAG as RAG Service + + U->>F: 关键词搜索 + F->>API: POST /pipelines/search + API->>G: ainvoke(params) + G->>Search: 多源检索 + Search-->>G: papers + G->>Dedup: 去重 + alt 有冲突 + G->>G: interrupt(conflicts) + G-->>API: 返回 next=hitl_dedup + API-->>F: 展示冲突 UI + U->>F: 解决冲突 + F->>API: POST /pipelines/{id}/resume + API->>G: Command(resume=resolutions) + end + G->>Crawler: 下载 PDF + G->>OCR: 识别文本 + G->>RAG: index_chunks + G-->>API: 完成 +``` + +--- + +## 二、后端架构优化 + +### 2.1 Pipeline 状态持久化方案 + +**现状**:`MemorySaver` 单例,进程重启后状态丢失,HITL 中断无法跨重启恢复。 + +| 方案 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| **SQLite Checkpointer** | 无额外依赖、与现有 DB 统一、支持多进程只读 | 写入并发需注意、大状态序列化开销 | **推荐**:单机/小团队部署 | +| **Redis** | 高性能、支持分布式、TTL 过期 | 需额外运维、数据持久化配置复杂 | 多实例、高并发 | +| **PostgreSQL** | 强一致性、成熟 | 需迁移 DB、相对重 | 已有 PG 的团队 | + +**推荐方案:SQLite Checkpointer** + +```mermaid +flowchart LR + subgraph 应用["FastAPI 进程"] + Pipeline["SearchPipeline"] + Upload["UploadPipeline"] + end + + subgraph 持久化["持久化层"] + SqliteCP["SqliteSaver"] + end + + subgraph 存储["存储"] + CheckpointDB["checkpoints.db"] + end + + Pipeline --> SqliteCP + Upload --> SqliteCP + SqliteCP --> CheckpointDB +``` + +**实现要点**: +- 使用 `langgraph-checkpoint-sqlite` 的 `SqliteSaver` +- 配置 `checkpoint_dir` 指向 `{data_dir}/langgraph_checkpoints` +- 每个 pipeline 实例传入同一 checkpointer(可共享) +- **留空**:多实例部署时需评估 SQLite 锁竞争,可后续切 Redis + +```python +# 伪代码 +from langgraph.checkpoint.sqlite import SqliteSaver + +def get_checkpointer(): + path = Path(settings.data_dir) / "langgraph_checkpoints" + path.mkdir(parents=True, exist_ok=True) + return SqliteSaver.from_conn_string(f"sqlite:///{path}/checkpoints.db") + +search_pipeline = create_search_pipeline(checkpointer=get_checkpointer()) +upload_pipeline = create_upload_pipeline(checkpointer=get_checkpointer()) +``` + +### 2.2 LLM 模型分级策略 + +**目标**:轻量模型负责意图识别/分流,重量模型负责生成/深度推理,降低成本与延迟。 + +```mermaid +flowchart TB + subgraph 分级["模型分级"] + Routing["Routing Model\n(意图/分流)"] + Generation["Generation Model\n(主生成)"] + Thinking["Thinking Model\n(深度推理)"] + end + + subgraph 场景["使用场景"] + Intent["意图识别"] + ToolMode["工具模式选择"] + Chat["对话生成"] + RAG["RAG 答案生成"] + Gap["Gap 分析"] + end + + Intent --> Routing + ToolMode --> Routing + Chat --> Generation + RAG --> Generation + Gap --> Thinking +``` + +| 级别 | 典型模型 | 用途 | 延迟要求 | +|------|----------|------|----------| +| **Routing** | gpt-5-mini, gemini-3.1-flash, claude-haiku-4-5, Doubao-Seed-2.0-mini, Qwen3.5-Flash | 意图识别、工具选择、简单分类 | <500ms | +| **Generation** | gpt-5.4, gemini-3.1-pro, claude-sonnet-4-6, doubao-seed-2-0-pro, Qwen3.5-397B, moonshotai/Kimi-K2.5, zai-org/GLM-5, MiniMaxAI/MiniMax-M2.5, DeepSeek-V3.2, Qwen3.5-Plus, Qwen3-Max | 对话、RAG 答案、写作 | 可接受 2–5s | +| **Thinking** | gpt-5.4, gemini-3.1-pro, claude-opus-4-6, doubao-seed-2-0-pro, deepseek-r1, Qwen3-Max | 复杂推理、Gap 分析 | 可接受 10s+ | + +这些配置既可以使用官方baseurl,也可以使用自定义baseurl。可以自定baseurl及其遵循openai还是anthropic的api规范。至少要支持上述国内外主流模型. 特别是对国内模型提供商, 如火山引擎, 阿里云百炼, 硅基流动,智谱, 月之暗面, MiniMax, DeepSeek, OpenRouter, 需要支持其api规范. 规划如何做一个好的路由系统,也方便其他人做后续开发。 + +**配置结构(留空实现)**: +```yaml +# 后续在 Settings 中实现 +llm_tiers: + routing: { provider: "openai", model: "gpt-4o-mini" } + generation: { provider: "anthropic", model: "claude-sonnet-4" } + thinking: { provider: "openai", model: "o3-mini" } +``` + +### 2.3 记忆系统架构:mem0 vs 自建 + +| 维度 | mem0 | 自建(SQLite + 向量) | +|------|------|------------------------| +| **短期记忆** | Session Memory | 现有 Conversation + Message 已覆盖 | +| **长期记忆** | User/Org Memory,两阶段提取+更新 | 需自建:从对话中抽取事实 → 向量存储 | +| **集成成本** | 引入 mem0 Python SDK,需配置存储 | 复用 ChromaDB + 新表 `user_memories` | +| **可控性** | 黑盒,提取逻辑不可定制 | 完全可控,可针对科研场景优化 | +| **性能** | 26% 准确率提升(LOCOMO) | 取决于实现质量 | + +**推荐**:**Phase 1 不引入 mem0**,优先完成对话历史 + 知识库上下文。**Phase 2+ 评估**: +- 若需「用户偏好记忆」「跨会话事实」:可试点 mem0 或自建轻量版(可在对话中出现小按钮表明该对话是否基于用户偏好记忆来实现. 若用户开启, 则对话记忆进入mem0, 否则不使用跨对话的用户偏好记忆.) +- 自建方案:新表 `user_memories(user_id, memory_type, content, embedding, created_at)`,用现有 Embedding 服务 + +```mermaid +flowchart LR + subgraph 短期["短期记忆"] + Conv["Conversation"] + Msg["Message"] + end + + subgraph 长期["长期记忆 (留空)"] + Facts["事实抽取"] + Store["向量存储"] + end + + Conv --> Msg + Msg -.-> Facts + Facts -.-> Store +``` + +### 2.4 统一 LLM 配置管理 + +**现状问题**:Chat 从 `UserSettingsService.get_merged_llm_config()` 取配置,RAG/Writing/Keyword 等从 `LLMClient(provider=None)` 即 env 直读,两套来源不一致。 + +**方案**:所有 LLM 调用统一经 `LLMConfigResolver`: + +```mermaid +flowchart TB + subgraph 来源["配置来源"] + Env[".env 默认"] + DB["UserSettings 表"] + end + + subgraph 解析["LLMConfigResolver"] + Merge["合并逻辑"] + Tier["分级覆盖"] + end + + subgraph 消费["消费者"] + Chat["Chat API"] + RAG["RAG Service"] + Writing["Writing Service"] + Keyword["Keyword Service"] + end + + Env --> Merge + DB --> Merge + Merge --> Tier + Tier --> Chat + Tier --> RAG + Tier --> Writing + Tier --> Keyword +``` + +**实现**: +- 新增 `app/services/llm_config_resolver.py`:`async def resolve_llm_config(task_type: str) -> LLMConfig` +- `task_type` 映射到 tier:`intent`→routing,`chat`/`rag_answer`→generation,`gap_analysis`→thinking +- Chat API、RAG、Writing 等统一调用 `resolve_llm_config`,不再直接读 env + +### 2.5 认证系统方案 + +目前只做单用户即可,目标是一个科研工作者或者部署到实验室服务器上供一个团队使用,无需考虑多用户情况。 + +### 2.6 后台任务调度(订阅定时更新等) + +**现状**:Subscription 仅有 `check_rss_feed`、`check_api_updates` 方法,无定时触发。 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| **APScheduler** | 纯 Python、无额外进程 | 多实例需防重复执行 | +| **Celery + Redis** | 成熟、分布式 | 引入重、需 Redis | +| **独立 cron + API** | 简单、可复用现有 API | 需外部 cron 配置 | + +**推荐**:**APScheduler 单实例**,后续可替换为 Celery。 + +```mermaid +flowchart TB + subgraph 调度["APScheduler"] + Daily["每日 6:00"] + Hourly["每小时"] + end + + subgraph 任务["任务"] + SubUpdate["订阅增量更新"] + Health["健康检查"] + end + + Daily --> SubUpdate + Hourly --> Health +``` + +**实现要点**: +- 在 `lifespan` 中启动 `AsyncIOScheduler` +- 新增 `app/jobs/subscription_job.py`:遍历 `Subscription` 表,调用 `SubscriptionService.check_rss_feed` / `check_api_updates` +- 结果写入 `Paper` + 触发 pipeline 或仅记录待处理队列 +- **留空**:多实例时需分布式锁(如 Redis)防重复 + +--- + +## 三、RAG 增强方案 + +> **重要**:代码审计发现了多个影响 RAG 质量的关键 BUG,详见 [07-code-audit-and-fixes.md](./07-code-audit-and-fixes.md)。以下方案基于修复后的代码路径设计。 + +### 3.0 前置修复(Phase 0 必须完成) + +| 修复项 | 说明 | 影响 | +|--------|------|------| +| **相邻 chunk 拼接逻辑** | `_get_adjacent_chunks` 返回 prev+next 混合体,`query()` 又重复贴在主 chunk 两侧 | RAG 上下文质量严重受损 | +| **OCR 分块策略统一** | Pipeline `ocr_node` 按页分块,`paper_processor` 用语义分块(1024字/100重叠),粒度差异大 | 同一论文不同入口索引效果不同 | +| **索引元数据补全** | `index_node` 未传递 `chunk_type` 和 `section`,ChromaDB 中信息丢失 | 引用无法按章节定位 | +| **retrieve_node top_k 硬编码** | 固定 `top_k=5`,不可配置,`use_reranker` 也未传递 | 检索召回不足 | +| **RAG query 内部 LLM 冗余** | `rag_service.query()` 内部调用 `_generate_answer()`,但 Chat Pipeline 的 `generate_node` 会再调一次 | 双重 LLM 调用浪费 | + +修复后的 RAG 检索路径: + +```mermaid +flowchart TB + subgraph 索引["统一索引路径"] + OCR["OCR 处理"] --> Chunk["统一语义分块\n1024字/100重叠"] + Chunk --> PC["PaperChunk\n含 chunk_type + section"] + PC --> Index["RAGService.index_chunks\n完整 metadata"] + Index --> Chroma["ChromaDB\npaper_id + paper_title +\nchunk_type + section +\npage_number + chunk_index"] + end + + subgraph 检索["修复后的检索路径"] + Query["用户提问"] --> Retrieve["retrieve_node\ntop_k=可配置"] + Retrieve --> RAG["RAGService.retrieve_only\n仅检索,不生成"] + RAG --> Vector["向量检索 top_k=N"] + Vector --> Adjacent["相邻 chunk 扩展\n正确:前-主-后"] + Adjacent --> Reranker["Reranker(可选)"] + Reranker --> Filter["相关性过滤\nscore >= 0.3"] + Filter --> CrossKBDedup["跨 KB 去重\n按 paper_id + chunk_index"] + CrossKBDedup --> Rank["rank_node\n批量查 Paper 元数据"] + Rank --> Clean["clean_node\nLLM 清洗 excerpt"] + Clean --> Generate["generate_node\n流式生成"] + end +``` + +### 3.1 混合检索(BM25 + 向量) + +**现状**:仅向量检索(ChromaDB cosine),对精确词、缩写、数字召回不足。 + +```mermaid +flowchart TB + Q["Query"] + Q --> Vector["向量检索\n(top_k=20)"] + Q --> BM25["BM25 检索\n(top_k=20)"] + Vector --> Merge["结果合并"] + BM25 --> Merge + Merge --> Dedup["去重 + 加权"] + Dedup --> Rerank["Reranker"] + Rerank --> TopK["Top-K 输出"] +``` + +| 方案 | 优点 | 缺点 | +|------|------|------| +| **LlamaIndex BM25Retriever + VectorIndexRetriever** | 与现有 LlamaIndex 集成好 | 需维护两份索引 | +| **ChromaDB + 自建 BM25** | Chroma 已有,BM25 用 `rank_bm25` | 需同步 chunk 文本到 BM25 索引 | +| **Elasticsearch/Meilisearch** | 全文+向量一体 | 引入新组件 | + +**推荐**:LlamaIndex `EnsembleRetriever`,向量用现有 Chroma,BM25 用 `BM25Retriever`(基于 `rank_bm25`,索引存 SQLite 或内存)。 + +**留空**:BM25 索引的持久化策略(内存重启丢失 vs 持久化)。 + +### 3.2 Reranker 引入 + +**现状**:`rag_service.query` 有 `use_reranker` 参数但未实现,直接按相似度排序。 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| **BGE Reranker (本地)** | 无 API 成本、低延迟 | 需 GPU、模型加载 | +| **Cohere Rerank API** | 效果好、省心 | 需 API Key、网络 | +| **Cross-Encoder (sentence-transformers)** | 开源、可本地 | 需额外依赖 | + +**推荐**:优先 **BGE Reranker**(`config.reranker_model` 已预留),与 embedding 同源。Cohere 作为可选 fallback。 + +```python +# 伪代码 +from llama_index.postprocessor import SentenceTransformerRerank + +reranker = SentenceTransformerRerank( + model=settings.reranker_model, + top_n=5, +) +retriever = index.as_retriever(similarity_top_k=20) +nodes = retriever.retrieve(query) +nodes = reranker.postprocess_nodes(nodes, query) +``` + +### 3.3 引用质量优化 + +**已发现问题及修复**: + +| 问题 | 根因 | 修复 | +|------|------|------| +| 引用 excerpt 内容错乱 | 相邻 chunk 拼接 BUG(前后文重复翻倍) | 分离 prev/next,按正确顺序拼接 | +| 多 KB 重复引用同一段落 | 检索结果未跨 KB 去重 | `rank_node` 中按 `paper_id+chunk_index` 去重 | +| 低质量引用干扰上下文 | 无 relevance score 过滤 | `rank_node` 增加 `MIN_RELEVANCE = 0.3` 阈值 | +| 引用无章节信息 | `section` 元数据丢失 | 索引路径补全 section | +| excerpt 截断句子 | 粗暴按 800 字符截断 | 在最近句号/换行处智能截断 | + +**增强方向**: + +- **段落级引用**:已有 `chunk_index`、`page_number`,补全 `section` 后可增加「高亮句」定位 +- **引用格式**:统一 `[1]`、`[2]` 与 `CitationCard` 的映射,前端已支持 +- **幻觉抑制**:在 system prompt 中强化「仅基于 context 回答,无则明确说明」 +- **新增 `retrieve_only` 方法**:Chat Pipeline 仅需检索结果,不需要 RAG 内部再调 LLM 生成答案,避免双重 LLM 调用 + +### 3.4 知识图谱(后续考虑) + +**留空**:知识图谱可提升「概念关系」「作者-机构」等查询,但实现成本高。建议在 RAG 混合检索 + Reranker 稳定后,再评估 Neo4j/NetworkX 等方案。 + +--- + +## 四、前端架构 + +### 4.1 状态管理优化 + +**现状**:React Query + 局部 useState,无全局 store。 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| **保持 React Query + Context** | 轻量、符合现有习惯 | 跨页面共享需设计 | +| **Zustand** | 简单、无 boilerplate | 引入新依赖 | +| **Jotai** | 原子化、细粒度更新 | 学习成本 | + +**推荐**:**保持 React Query 为主**,对「当前知识库选择」「Tool Mode」等跨组件状态用 **Context + useReducer** 或 **Zustand** 轻量 store。避免 Redux 等重方案。 + +```mermaid +flowchart TB + subgraph 服务端状态["服务端状态"] + RQ["React Query"] + end + + subgraph 客户端状态["客户端状态"] + Ctx["Context\n(知识库/ToolMode)"] + Local["useState\n(表单/UI)"] + end + + RQ --> Ctx + Ctx --> Local +``` + +### 4.2 实时通信:SSE vs WebSocket + +| 场景 | 推荐 | 理由 | +|------|------|------| +| Chat 流式 | **SSE** | 单向、实现简单、与 Vercel AI SDK 协议兼容 | +| 智能补全 | **SSE 或 HTTP 轮询** | 请求-响应模式,无需长连接 | +| Pipeline 进度 | **SSE** | 已有 `/pipelines/{id}/status`,可改为 SSE 推送 | +| 多端同步 | WebSocket | 双向、低延迟,当前非刚需 | + +**结论**:继续以 **SSE** 为主,WebSocket 留作后续「实时协作」等能力。 + +### 4.3 路由与代码分割 + +**现状**:`lazy()` 已用于部分页面,路由结构清晰。 + +**优化建议**: +- 按路由拆分 chunk:`KnowledgeBasesPage`、`ProjectDetail` 子路由(Papers、Discovery、Writing)独立 chunk +- 预加载:`` 悬停时 `preload` 对应 chunk +- **留空**:若引入 PWA,可考虑 `workbox` 缓存策略 + +--- + +## 五、协议与接口规范 + +### 5.1 REST API 规范 + +**统一响应**:`ApiResponse[T]` 已存在,保持 `{ data, error?, message? }` 结构。 + +**路由清理**:`projects` 与 `knowledge-bases` 当前重复挂载同一 router: + +```python +# 现状 +api_router.include_router(projects.router, prefix="/projects") +api_router.include_router(projects.router, prefix="/knowledge-bases", tags=["knowledge-bases"]) +``` + +**建议**:**统一为 `/projects`**,`/knowledge-bases` 作为 alias 重定向或 301,避免重复注册与维护两套路径。 + +| 资源 | 推荐路径 | 说明 | +|------|----------|------| +| 项目/知识库 | `/api/v1/projects` | 统一入口 | +| 论文 | `/api/v1/projects/{id}/papers` | 嵌套 | +| 对话 | `/api/v1/conversations` | 顶层 | +| Chat | `/api/v1/chat/stream` | 流式 | + +### 5.2 SSE Data Stream Protocol 扩展 + +**现状**:已支持 `text-delta`、`data-citation`、`data-thinking`、`data-conversation` 等。 + +**工具调用反馈事件(留空)**:若未来 Chat 支持 tool calling,可扩展: + +```json +{ "type": "tool-call", "id": "tc-1", "data": { "name": "search_papers", "args": {...} } } +{ "type": "tool-result", "id": "tc-1", "data": { "result": {...} } } +``` + +与 Vercel AI SDK 5.0 的 `tool-invocation` 等对齐,具体格式待实现时确定。 + +### 5.3 MCP 扩展 + +**现状**:MCP Server 已挂载 `/mcp`,提供 tools/resources。 + +**扩展建议**: +- 暴露「知识库检索」「论文搜索」等为 MCP tools +- 新增 `prompts` 资源,供 AI IDE 快速插入常用 prompt +- **留空**:MCP 与后端认证打通(当前可能无鉴权) + +--- + +## 六、设置与多模型管理 + +参考 OpenClaw 等项目的分级与路由思路,设计多 LLM Provider 支持。 + +### 6.1 Provider CRUD + +**现状**:`AVAILABLE_PROVIDERS` 硬编码,无用户自定义 Provider。 + +**设计**: + +```mermaid +erDiagram + llm_provider { + int id PK + string name + string provider_type + string api_key_encrypted + string base_url + string default_model + json extra_config + datetime created_at + } + + llm_model_config { + int id PK + int provider_id FK + string model_id + string tier + boolean is_default + } +``` + +| 操作 | API | 说明 | +|------|-----|------| +| 列表 | `GET /api/v1/settings/providers` | 内置 + 用户自定义 | +| 创建 | `POST /api/v1/settings/providers` | 自定义 OpenAI 兼容端点 | +| 更新 | `PATCH /api/v1/settings/providers/{id}` | 更新 key/url/model | +| 删除 | `DELETE /api/v1/settings/providers/{id}` | 软删或硬删 | + +### 6.2 模型分级(routing / generation / embedding) + +| 层级 | 用途 | 可配置项 | +|------|------|----------| +| **routing** | 意图识别、分流 | provider_id, model_id | +| **generation** | 对话、RAG、写作 | provider_id, model_id | +| **embedding** | 向量化 | provider_id, model_id(或沿用现有 embedding_service) | + +**配置结构**: +```json +{ + "routing": { "provider_id": 1, "model_id": "gpt-4o-mini" }, + "generation": { "provider_id": 2, "model_id": "claude-sonnet-4" }, + "embedding": { "provider_id": 0, "model_id": "BAAI/bge-m3" } +} +``` + +### 6.3 用户自定义配置 + +- 支持「自定义 Provider」:任意 OpenAI 兼容 API,填写 base_url + api_key +- 支持「模型 fallback」:主模型不可用时自动切换(参考 OpenClaw) +- **留空**:按 token 计费、用量统计 + +--- + +## 七、实施优先级与留空点汇总 + +### 7.1 优先级矩阵 + +| 项目 | 优先级 | 预估 | 依赖 | +|------|--------|------|------| +| Pipeline SQLite Checkpointer | P0 | 1d | 无 | +| API 路由去重 (projects/knowledge-bases) | P0 | 0.5d | 无 | +| 统一 LLM 配置管理 | P1 | 2d | 无 | +| 订阅定时任务 (APScheduler) | P1 | 2d | 无 | +| RAG Reranker | P1 | 1d | 无 | +| RAG 混合检索 | P2 | 3d | Reranker | +| 模型分级 (routing/generation) | P2 | 2d | 统一 LLM 配置 | +| Provider CRUD | P2 | 3d | 模型分级 | +| 认证 JWT | P2 | 3d | 无 | +| 记忆系统 (mem0/自建) | P3 | 待评估 | 无 | + +### 7.2 留空点清单 + +- [ ] 多实例部署时 Pipeline checkpointer 的 Redis 迁移 +- [ ] 记忆系统:mem0 vs 自建 +- [ ] 知识图谱 +- [ ] BM25 索引持久化 +- [ ] Chat 工具调用 SSE 事件格式 +- [ ] MCP 认证打通 +- [ ] 按 token 计费与用量统计 + +--- + +*本文档为 V3 架构设计初稿,后续可根据实施反馈迭代。* diff --git a/docs/prd/v3/05-innovation.md b/docs/prd/v3/05-innovation.md new file mode 100644 index 0000000..ffebfc9 --- /dev/null +++ b/docs/prd/v3/05-innovation.md @@ -0,0 +1,321 @@ +# Omelette V3 PRD — 竞品调研与创新功能设计 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 1. 竞品调研 + +### 1.1 Google NotebookLM + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **知识管理** | 上传 PDF、网页、文档,构建「Notebook」知识库 | 多源混合,支持 Google Drive、网页、PDF | +| **多源问答** | 基于上传内容与 AI 对话,支持引用溯源 | 回答可追溯到具体来源段落 | +| **Audio Overview** | 将文档转为播客式对话,双 AI 主持 | 可下载离线收听,支持 50+ 语言;可自定义 AI 关注点与专业程度 | +| **协作** | NotebookLM Plus(2024.12)支持团队协作 | 企业级数据保护 | +| **局限** | 依赖 Google 生态,数据在云端 | 无本地部署,隐私敏感场景受限 | + +**可借鉴点**:Audio Overview 的「播客式知识消化」形态;引用溯源的可视化展示。 + +--- + +### 1.2 Zotero + 插件生态 + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **文献管理** | 元数据管理、分类、标签、附件 | 行业标杆,支持 6000+ 种文献类型 | +| **Better BibTeX** | 自动生成 citekey、LaTeX/Markdown 导出 | 与 zotxt、Obsidian、RStudio 等打通 | +| **Zotero Connector** | 浏览器一键抓取网页文献 | 覆盖主流学术网站 | +| **插件生态** | Scite(引用立场)、ZotFile(PDF 管理)、Better Notes(知识管理) | 社区活跃,可扩展性强 | +| **局限** | AI 能力弱,无 RAG、无智能问答 | 需配合其他工具完成智能分析 | + +**可借鉴点**:与 Zotero 双向同步,复用其文献管理能力;Better BibTeX 的 citekey 与导出格式设计。 + +--- + +### 1.3 Elicit + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **语义检索** | 1.38 亿+ 论文、54.5 万临床试验 | 语义搜索,非纯关键词 | +| **系统综述** | 自动筛选、数据提取,节省约 80% 时间 | 支持 sentence-level 引用 | +| **Research Agents** | 竞品格局、研究版图、临床试验分析、主题探索 | Agent 化工作流,可迭代细化 | +| **数据提取** | 从数百篇论文中提取表格数据 | 规模化证据合成 | +| **局限** | 云端 SaaS,无本地部署 | 深度定制与隐私控制有限 | + +**可借鉴点**:Research Agent 工作流;系统综述的自动化筛选与数据提取思路。 + +--- + +### 1.4 Consensus + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **学术搜索** | 2 亿+ 同行评审论文,混合语义+关键词 | 多步排序,兼顾相关性与质量 | +| **Consensus Meter** | 展示科学共识程度 | 直观呈现领域共识 | +| **Ask Paper** | 与全文 PDF 对话,追问方法、图表 | 针对单篇论文的深度问答 | +| **引用导出** | 支持 CSV、RIS,可导入 Zotero、EndNote | 与文献管理工具打通 | +| **多语言** | 31 种语言的 AI 功能 | 国际化友好 | + +**可借鉴点**:Consensus Meter 的共识可视化;Ask Paper 式的单篇论文深度对话。 + +--- + +### 1.5 Semantic Scholar + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **学术图谱** | 2.25 亿+ 论文、1 亿+ 作者、28 亿+ 引用边 | 结构化知识图谱 | +| **API 能力** | 论文检索、引用/被引、作者、SPECTER2 向量 | 开发者友好,可构建自有应用 | +| **推荐** | 基于论文的相似推荐 | 支持发现相关文献 | +| **局限** | 无对话式界面,偏数据层 | 需自行构建上层应用 | + +**可借鉴点**:利用 S2 API 构建引用网络、相似推荐;SPECTER2 向量可用于混合检索。 + +--- + +### 1.6 Connected Papers + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **文献图谱** | 以种子论文为中心,力导向图展示相关文献 | 节点大小=引用量,颜色=年份 | +| **Prior/Derivative** | 区分「前序工作」与「后续工作」 | 帮助理解研究脉络 | +| **过滤** | 按期刊、年份、领域筛选 | 精准定位 | +| **导出** | 支持 Zotero 等 | 与文献管理集成 | +| **局限** | 免费版每月 2–5 张图;偏向高引论文 | 新论文、小众领域覆盖有限 | + +**可借鉴点**:文献关系图谱的可视化形态;Prior/Derivative 的时序视角。 + +--- + +### 1.7 Research Rabbit + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **可视化发现** | 2.7 亿+ 论文,通过引用、主题、合作者连接 | 交互式图谱,替代传统关键词列表 | +| **智能推荐** | Similar Work、Earlier Work、Later Work | 基于阅读行为的学习推荐 | +| **组织** | Collections 管理,与 Zotero、EndNote 同步 | 打通文献管理 | +| **局限** | 无内置引用管理;图谱偏向发现,非深度分析 | 需配合 Zotero 等 | + +**可借鉴点**:Similar/Earlier/Later 的三维发现逻辑;Collections 与外部工具同步思路。 + +--- + +### 1.8 Cursor / Windsurf(AI IDE) + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **Chat (Cmd+L)** | 多轮对话、@ 引用代码库 | 上下文感知,Apply 一键应用 | +| **Composer / Cascade** | 多文件编辑、Agent 化任务 | 可拆解任务、执行命令 | +| **Command Palette (Cmd+K)** | 局部代码修改 | 聚焦单点编辑 | +| **@Codebase / @Git** | RAG 式项目理解、Git 历史 | 长上下文、代码演进 | +| **MCP** | 自定义工具扩展 | 与 Omelette MCP 可互通 | + +**可借鉴点**:@ 引用、斜杠命令、启发式按钮;Agent 化任务拆解与执行。 + +--- + +### 1.9 Open WebUI / LibreChat(开源 LLM Playground) + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **多模型** | OpenAI、Anthropic、Ollama、OpenRouter 等 | 模型切换、预设管理 | +| **文档检索** | Open WebUI 支持文档上传与检索 | 类似 RAG 能力 | +| **MCP / 插件** | LibreChat 支持 MCP、Functions | 可扩展工具链 | +| **离线** | Open WebUI 可完全离线 | 隐私友好 | +| **局限** | 通用对话,非科研垂直 | 无文献管理、无学术检索 | + +**可借鉴点**:多模型切换 UI;文档检索与 RAG 的集成方式。 + +--- + +### 1.10 Perplexity + +| 维度 | 功能描述 | 设计亮点 | +|------|----------|----------| +| **引用展示** | 每个事实声明旁标注 [1][2],链接到来源 | 平均约 22 条内联引用/回答 | +| **来源面板** | 回答旁常驻来源列表 | 可点击验证,无需离开页面 | +| **RAG 流程** | 检索 → 重排 → 质量过滤 → 生成 → 标注引用 | 引用密度约为 ChatGPT 的 3 倍 | +| **API** | 2024.11 起支持 SSE 流式引用 | 可集成到自有产品 | + +**可借鉴点**:段落级内联引用 [1][2] 的展示方式;来源面板常驻设计。 + +--- + +## 2. Omelette 与竞品功能对照表 + +| 功能 | Omelette | NotebookLM | Zotero | Elicit | Consensus | S2 | Connected Papers | Research Rabbit | Cursor | Perplexity | +|------|:--------:|:-----------:|:------:|:------:|:----------:|:--:|:-----------------:|:----------------:|:------:|:----------:| +| 多源文献检索 | ✅ | ❌ | 插件 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| 本地 RAG 知识库 | ✅ | ❌(云端) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅(代码) | ❌ | +| 对话式问答 | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | +| 段落级引用 | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| 文献关系图谱 | ❌ | ❌ | 插件 | ❌ | ❌ | API | ✅ | ✅ | ❌ | ❌ | +| 写作辅助(提纲/空白) | ✅ | 部分 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 关键词体系+检索公式 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 去重+OCR+爬取 | ✅ | ❌ | 插件 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 订阅/增量更新 | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 多模型切换 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| 本地部署 | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| Zotero 集成 | 规划中 | ❌ | — | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | +| MCP / 扩展 | ✅ | ❌ | 插件 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| Audio Overview | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | + +--- + +## 3. 创新功能设计 + +### 3.1 文献关系图谱可视化 + +| 项目 | 说明 | +|------|------| +| **功能描述** | 以种子论文为中心,展示引用/被引/相似文献的关系图,支持 Prior/Derivative 视角 | +| **数据来源** | Semantic Scholar API(引用边)、OpenAlex、本地 Paper 表 | +| **可行性** | 高 — S2 API 已有引用数据;前端可用 D3/React Flow 等 | +| **优先级** | P1;与 Connected Papers 差异化:结合本地知识库,可仅展示「已读/已索引」文献 | +| **与现有架构** | 复用 Paper 元数据、Semantic Scholar 检索;可扩展 `citation_graph` 服务 | +| **预留空间** | 可后续增加:按主题聚类、时间轴、作者网络 | + +--- + +### 3.2 研究趋势分析 + +| 项目 | 说明 | +|------|------| +| **功能描述** | 基于知识库内关键词/主题的出现频率,生成年度趋势图;识别新兴/衰退主题 | +| **数据来源** | Paper 表 year + abstract/title 的 LLM 或规则抽取 | +| **可行性** | 中 — 需主题抽取与聚合逻辑;可先做简单关键词频次 | +| **优先级** | P2;适合开题、综述场景 | +| **与现有架构** | 复用 Keyword、Paper;可新增 `trend_analysis` 服务 | +| **预留空间** | 可引入外部数据(如 arXiv 分类统计)做对比 | + +--- + +### 3.3 协同标注与笔记 + +| 项目 | 说明 | +|------|------| +| **功能描述** | 多人对同一文献/段落做高亮、批注、标签;支持冲突合并与讨论 | +| **可行性** | 低 — 需多用户、权限、实时同步;当前单用户架构 | +| **优先级** | P3;可作为 Phase 2 的团队协作方向 | +| **与现有架构** | 需扩展 User、Permission、PaperAnnotation 模型 | +| **预留空间** | 可先做「单用户笔记」与导出,为后续协同打基础 | + +--- + +### 3.4 多模态论文理解(图表、公式) + +| 项目 | 说明 | +|------|------| +| **功能描述** | 对图表、公式进行 OCR/识别,支持「这张图在说什么?」、「公式含义」等问答 | +| **可行性** | 中 — 需多模态模型(GPT-4V、Gemini 等)或专用图表解析 | +| **优先级** | P2 | 与 Consensus Ask Paper 类似,可增强单篇深度理解 | +| **与现有架构** | 复用 PaperChunk 的 figure_caption;可扩展 chunk_type 支持 image | +| **预留空间** | 可先支持「图表 caption」检索,再逐步引入多模态生成 | + +--- + +### 3.5 自动生成 Literature Review + +| 项目 | 说明 | +|------|------| +| **功能描述** | 基于知识库自动生成综述提纲、段落草稿,带引用与结构 | +| **可行性** | 高 — 已有 outline、gap 模式;可组合为完整综述流程 | +| **优先级** | P1 | 与 Elicit 系统综述类似,但基于本地知识库 | +| **与现有架构** | 复用 Writing Assistant、RAG、工具模式 | +| **预留空间** | 可增加「按主题/时间线」组织;支持 GB/T 7714、APA 引用格式 | + +--- + +### 3.6 研究问题拆解与文献匹配 + +| 项目 | 说明 | +|------|------| +| **功能描述** | 用户输入研究问题 → LLM 拆解为子问题 → 为每个子问题检索匹配文献 | +| **可行性** | 高 — 意图识别 + 多轮检索 + 结果聚合 | +| **优先级** | P1 | 与 Elicit Research Agent 思路一致 | +| **与现有架构** | 复用意图识别、检索、RAG;可新增 LangGraph 子图 | +| **预留空间** | 可支持「子问题→关键词」的自动扩展与检索公式生成 | + +--- + +### 3.7 PDF 阅读器内 AI 助手 + +| 项目 | 说明 | +|------|------| +| **功能描述** | 在 PDF 阅读器侧边栏或悬浮窗提供「选中即问」:解释、翻译、找引用 | +| **可行性** | 高 — 前端已有 PDF 预览;可增加侧边栏 + 选中文本传后端 | +| **优先级** | P1 | 对标 Consensus Ask Paper,但集成在阅读器内 | +| **与现有架构** | 复用 RAG、chat API;需前端 PDF 组件支持选区 | +| **预留空间** | 可支持「跨 PDF 对比」、图表区域选择 | + +--- + +### 3.8 引用网络分析 + +| 项目 | 说明 | +|------|------| +| **功能描述** | 分析知识库内文献的引用/被引关系,识别核心论文、桥梁论文、孤立簇 | +| **可行性** | 中 — 需 S2 引用数据;可做简单中心度、聚类分析 | +| **优先级** | P2 | 与文献图谱互补,偏分析 | +| **与现有架构** | 复用 Paper、S2 API;可扩展 `citation_analysis` 服务 | +| **预留空间** | 可引入 Scite 式「支持/反对」引用立场(若 API 可用) | + +--- + +## 4. 创新功能优先级矩阵 + +| 功能 | 可行性 | 用户价值 | 与现有能力契合度 | 建议优先级 | +|------|:------:|:--------:|:----------------:|:----------:| +| 文献关系图谱 | 高 | 高 | 高 | **P1** | +| 自动 Literature Review | 高 | 高 | 高 | **P1** | +| 研究问题拆解与文献匹配 | 高 | 高 | 高 | **P1** | +| PDF 阅读器内 AI 助手 | 高 | 高 | 高 | **P1** | +| 多模态论文理解 | 中 | 高 | 中 | 高 | +| 研究趋势分析 | 中 | 中 | 高 | 中 | +| 引用网络分析 | 中 | 中 | 高 | 中 | +| 协同标注与笔记 | 低 | 中 | 低 | 低 | + +--- + +## 5. 建议的差异化策略 + +### 5.1 核心定位 + +> **AI 原生的科研文献智能 IDE**:比 NotebookLM 更垂直、比 Zotero 更智能、比通用 AI IDE 更懂科研。 + +### 5.2 差异化策略 + +| 策略 | 说明 | 实施要点 | +|------|------|----------| +| **本地优先 + 全链路** | 检索→去重→爬取→OCR→索引→对话→写作,一气呵成 | 强调数据在本地、无云端依赖;与 Elicit/Consensus 等 SaaS 区分 | +| **关键词体系驱动** | 三级关键词 + LLM 扩展 + 多源检索公式 | 竞品多为「自然语言检索」,Omelette 提供结构化检索能力 | +| **RAG 深度集成** | 引用模式、提纲、空白分析、Literature Review 均基于 RAG | 对标 Perplexity 的引用密度,但面向学术场景 | +| **Zotero 联动** | 导入 Zotero 文库、双向同步(规划中) | 复用 Zotero 生态,不重复造轮子 | +| **MCP 开放** | 与 Cursor/IDE 互通,文献能力可被 AI 编程助手调用 | 构建「文献 Copilot + 代码 Copilot」组合 | +| **多模型分级** | 按任务选择经济型/高能力模型 | 成本敏感、合规场景可灵活切换 | + +### 5.3 避免过度设计 + +- **不追求**:通用 AI 对话、社交功能、移动端原生 App +- **不重复**:Zotero 的完整文献管理(通过集成而非替代) +- **不堆砌**:优先实现 P1 创新功能,再逐步扩展 + +--- + +## 6. 附录:竞品信息速查 + +| 产品 | 官网/API | 主要用途 | +|------|----------|----------| +| NotebookLM | notebooklm.google.com | 知识管理 + 多源问答 + Audio Overview | +| Zotero | zotero.org | 文献管理 | +| Elicit | elicit.com | AI 研究助手、系统综述 | +| Consensus | consensus.app | 学术搜索 + 共识 | +| Semantic Scholar | semanticscholar.org, api.semanticscholar.org | 学术图谱、API | +| Connected Papers | connectedpapers.com | 文献关系图谱 | +| Research Rabbit | researchrabbit.ai | 文献推荐、可视化发现 | +| Cursor | cursor.com | AI IDE | +| Perplexity | perplexity.ai | AI 搜索、引用展示 | + +--- + +*本文档为创新功能设计,具体实施见 06-implementation-roadmap.md。* diff --git a/docs/prd/v3/06-implementation-roadmap.md b/docs/prd/v3/06-implementation-roadmap.md new file mode 100644 index 0000000..45788bb --- /dev/null +++ b/docs/prd/v3/06-implementation-roadmap.md @@ -0,0 +1,290 @@ +# Omelette V3 — 实施路线图与开发规划 + +> 版本:V3.0 Draft | 日期:2026-03-15 | 状态:规划中 + +## 1. 实施原则 + +1. **从简到繁**:先修复架构问题(P0),再增强核心功能(P1),最后添加创新功能(P2+) +2. **可独立交付**:每个 Phase 产出可运行、可测试的增量版本 +3. **模块解耦**:不同 Phase 的工作可分配给不同开发者,减少冲突 +4. **渐进增强**:基础功能先行,高级功能预留接口 + +--- + +## 2. Phase 概览 + +```mermaid +gantt + title Omelette V3 实施路线图 + dateFormat YYYY-MM-DD + axisFormat %m/%d + + section Phase 0 — BUG修复+架构修复 + KaTeX CSS 修复 :p0x, 2026-03-17, 0.5d + PaddleOCR lang 自动检测 :p0y, 2026-03-17, 0.5d + Pipeline SQLite Checkpointer :p0a, 2026-03-17, 1d + API 路由去重 :p0b, 2026-03-17, 1d + 统一 LLM 配置管理 :p0c, 2026-03-18, 2d + + section Phase 1 — 对话引擎 + 意图识别 + intent_route :p1a, 2026-03-20, 3d + 启发式后续按钮 :p1b, after p1a, 1d + 引用查询增强 :p1c, 2026-03-20, 2d + 斜杠命令 :p1d, 2026-03-22, 2d + RAG Reranker 引入 :p1e, 2026-03-24, 2d + + section Phase 2 — 知识库增强 + MinerU 集成 + 分块增强 :p2m, 2026-03-26, 3d + 公式/表格/caption chunk :p2n, after p2m, 2d + AI 建库 :p2a, 2026-03-26, 2d + 分级去重机制 :p2b, 2026-03-26, 2d + Reindex 机制 :p2c, 2026-03-28, 1d + 批量操作 :p2d, 2026-03-28, 1d + Zotero API 集成 :p2e, 2026-03-29, 3d + Zotero 导入 Pipeline :p2f, after p2e, 2d + + section Phase 3 — 设置与调度 + 多模型分级 :p3a, 2026-04-03, 3d + 订阅定时任务 :p3b, 2026-04-03, 2d + RAG 混合检索 :p3c, 2026-04-06, 3d + + section Phase 4 — 创新功能 + 智能补全 :p4a, 2026-04-09, 3d + 文献关系图谱 :p4b, 2026-04-09, 4d + 自动 Literature Review :p4c, 2026-04-13, 3d + PDF 阅读器 AI 助手 :p4d, 2026-04-13, 3d + + section Phase 5 — 打磨发布 + 集成测试 :p5a, 2026-04-16, 3d + 文档完善 :p5b, 2026-04-16, 2d + 性能优化 :p5c, 2026-04-18, 2d + V3 发布 :milestone, 2026-04-20, 0d +``` + +--- + +## 3. 各 Phase 详细任务 + +### Phase 0 — BUG 修复 + 架构修复 + 前端修复(5 天) + +> 目标:修复代码审计发现的 P0 BUG,解决架构问题,修复前端渲染缺陷,为后续开发打好基础。 +> 详细修复清单见 [07-code-audit-and-fixes.md](./07-code-audit-and-fixes.md) +> 技术决策依据见 [08-technical-deep-dive.md](./08-technical-deep-dive.md) + +| 任务 | 负责模块 | 工作量 | 交付物 | +|------|----------|--------|--------| +| **KaTeX CSS 修复** | frontend | 0.5h | `main.tsx` 导入 `katex/dist/katex.min.css`,公式正确渲染 | +| **PaddleOCR 语言自动检测** | backend/services/ocr | 0.5h | PaddleOCR `lang` 参数改为可配置(中英文自动检测或用户指定) | +| **BUG: 相邻 chunk 拼接逻辑修复** | backend/services/rag | 0.5d | `_get_adjacent_chunks` 返回分离的 prev/next,`query()` 按 `[前]\n[主]\n[后]` 拼接 | +| **BUG: apply_resolution skip 逻辑** | backend/pipelines | 0.5h | `skip` 不再添加 `new_paper` | +| **OCR 分块策略统一** | backend/pipelines | 1d | Pipeline `ocr_node` 改用 `ocr.chunk_text()` 语义分块 | +| **索引元数据补全** | backend/pipelines, services | 0.5d | `index_node` 传递 `chunk_type` + `section`,`RAGService` 索引 `section` | +| **去重阈值统一 + 复用 DedupService** | backend/pipelines, services, config | 0.5d | 阈值集中到 `config.py`,Pipeline 调用 `DedupService` | +| **index_node N+1 修复** | backend/pipelines | 0.5h | 批量查询 PaperChunk | +| **retrieve_node 参数可配置** | backend/pipelines/chat | 0.5h | `top_k`/`use_reranker` 从 state 读取 | +| **新增 `RAGService.retrieve_only()`** | backend/services/rag | 0.5d | Chat Pipeline 仅检索不生成,避免双重 LLM 调用 | +| Pipeline SQLite Checkpointer | backend/pipelines | 1d | `SqliteSaver` 替换 `MemorySaver` | +| API 路由去重 | backend/api | 0.5d | 移除 `/knowledge-bases` 重复注册 | +| 统一 LLM 配置管理 | backend/services | 1d | `LLMConfigResolver` | + +**验收标准**: +- [ ] 聊天对话框中数学公式(`$...$` / `$$...$$`)正确渲染,含分数、积分等复杂公式 +- [ ] PaddleOCR 可识别中文扫描件 +- [ ] 相邻 chunk 上下文顺序正确:`[前]\n[主]\n[后]`,无重复 +- [ ] skip 操作不导入新文献 +- [ ] 所有入口的 OCR 使用统一的语义分块策略(1024字/100重叠) +- [ ] ChromaDB 中索引包含 `section` 和正确的 `chunk_type` +- [ ] 去重阈值统一:L1=DOI精确,L2=0.90标题,L3=0.80~0.90 LLM +- [ ] Chat Pipeline retrieve 的 `top_k` 可配置 +- [ ] 重启后端后 Pipeline 可恢复 +- [ ] Chat/RAG/Writing 的 LLM 配置来源一致 + +--- + +### Phase 1 — 对话引擎重构(2 周) + +> 目标:对话引擎具备意图感知、启发式交互、引用增强、斜杠命令能力。 + +| 任务 | 负责模块 | 工作量 | 依赖 | +|------|----------|--------|------| +| 意图识别 + `intent_route` 节点 | backend/pipelines/chat | 3d | Phase 0 LLM 配置 | +| `suggest` 节点 + SSE 事件 | backend/pipelines/chat | 含上 | — | +| `predict_followups` 节点 | backend/pipelines/chat | 1d | — | +| 前端 `IntentSuggestionBanner` | frontend/components | 1d | 后端意图 API | +| 前端 `SuggestedFollowupButtons` | frontend/components | 1d | 后端 followup API | +| 引用查询增强:按 `paper_id` 取色 | frontend/components | 1d | — | +| 引用查询:强化 prompt 不修改原文 | backend/pipelines/chat | 0.5d | — | +| 斜杠命令 `SlashCommandMenu` | frontend/components | 2d | — | +| RAG Reranker | backend/services/rag | 2d | — | + +**验收标准**: +- [ ] 未选 KB 时提问,系统可建议知识库或功能 +- [ ] 对话完成后展示 2-4 个启发式按钮 +- [ ] 引用查询模式下同一文献同色 +- [ ] 输入 `/` 可调出命令菜单 +- [ ] RAG 检索结果经过 Reranker 排序 + +--- + +### Phase 2 — 知识库增强 + PDF 解析升级(2.5 周) + +> 目标:知识库管理具备 AI 建库、分级去重、Zotero 联动能力;PDF 解析引擎升级为 MinerU。 +> 技术选型依据见 [08-technical-deep-dive.md](./08-technical-deep-dive.md) + +| 任务 | 负责模块 | 工作量 | 依赖 | +|------|----------|--------|------| +| **MinerU 独立服务部署**:conda 环境 + `mineru-api` 启动 | infra | 0.5d | GPU ≥6GB VRAM | +| **MinerU 客户端集成**:OCRService 改造,HTTP 调用 MinerU API | backend/services/ocr | 2d | MinerU 服务 | +| **分块策略增强**:解析 MinerU Markdown 输出,识别公式/表格/图片并分类分块 | backend/services/ocr | 2d | MinerU 客户端 | +| **PaperChunk 模型扩展**:新增 `has_formula`、`figure_path` | backend/models | 0.5d | — | +| **配置更新**:`.env` 新增 `MINERU_API_URL`,`pyproject.toml` 添加 `httpx` | backend | 0.5h | — | +| AI 建库预览 API | backend/api, services | 2d | — | +| AI 建库前端 | frontend/components | 1d | 后端 API | +| 分级去重机制(L1/L2/L3) | backend/services/dedup | 2d | — | +| Reindex API + 前端按钮 | backend/api, frontend | 1d | — | +| 批量删除/处理 API | backend/api | 1d | — | +| Zotero API 集成(连接、测试) | backend/services | 2d | — | +| Zotero 集合获取 API | backend/api | 1d | Zotero 连接 | +| ZoteroImportPipeline | backend/pipelines | 2d | Zotero API | +| Zotero 导入前端 UI | frontend/components | 2d | 后端 Pipeline | + +**验收标准**: +- [ ] MinerU 独立服务启动并可访问(`http://localhost:8010/docs`) +- [ ] OCRService 通过 HTTP 调用 MinerU API,正确解析含公式的中英文学术 PDF +- [ ] MinerU 正确识别并提取 PDF 中的表格(含图像表格、跨页表格) +- [ ] 分块结果包含 `text`、`table`、`figure_caption` 三种 chunk_type +- [ ] 含公式的 text chunk 标记 `has_formula=True` +- [ ] 输入主题描述可 AI 生成知识库名称和推荐关键词 +- [ ] 去重分三级(DOI精确/标题0.90/LLM校验) +- [ ] 可一键重建索引 +- [ ] 可批量删除/重新处理文献 +- [ ] 可连接 Zotero 并导入 Collection + +--- + +### Phase 3 — 设置与调度(1.5 周) + +> 目标:多模型分级、订阅定时任务、RAG 混合检索。 + +| 任务 | 负责模块 | 工作量 | 依赖 | +|------|----------|--------|------| +| 模型分级配置 UI | frontend/pages/settings | 2d | — | +| 模型分级后端(routing/generation/thinking) | backend/services | 1d | Phase 0 LLM 配置 | +| 订阅定时任务(APScheduler) | backend/jobs | 2d | — | +| 通知系统(应用内) | backend/api, frontend | 2d | — | +| RAG 混合检索(BM25 + 向量) | backend/services/rag | 3d | Phase 1 Reranker | + +**验收标准**: +- [ ] 不同任务可使用不同级别的模型 +- [ ] 订阅可按设定频率自动运行 +- [ ] 新文献入库时有应用内通知 +- [ ] RAG 支持混合检索 + +--- + +### Phase 4 — 创新功能(2 周) + +> 目标:差异化竞争力功能上线。 + +| 任务 | 负责模块 | 工作量 | 依赖 | +|------|----------|--------|------| +| 智能补全 API + 前端 | backend/api, frontend | 3d | — | +| 文献关系图谱(S2 引用数据 + D3 可视化) | backend/services, frontend | 4d | — | +| 自动 Literature Review(组合 outline + RAG) | backend/pipelines | 3d | Phase 1 Reranker | +| PDF 阅读器内 AI 助手(侧边栏 + 选区问答) | frontend/components | 3d | Phase 1 引用增强 | + +**验收标准**: +- [ ] 输入时展示灰色补全建议,Tab 接受 +- [ ] 可查看文献引用关系图谱 +- [ ] 可基于知识库自动生成综述草稿 +- [ ] PDF 阅读器内可选中文本向 AI 提问 + +--- + +### Phase 5 — 打磨发布(1 周) + +> 目标:全面测试、文档、性能优化。 + +| 任务 | 负责模块 | 工作量 | +|------|----------|--------| +| 端到端集成测试 | tests | 3d | +| API 文档更新 | docs/api | 1d | +| 用户指南更新 | docs/guide | 1d | +| 性能优化(SSE 节流、批量查询) | backend, frontend | 2d | +| 安全审查(认证、路径、注入) | backend | 1d | + +--- + +## 4. 模块间依赖图 + +```mermaid +flowchart TD + P0["Phase 0: 架构修复"] --> P1["Phase 1: 对话引擎"] + P0 --> P2["Phase 2: 知识库"] + P0 --> P3["Phase 3: 设置与调度"] + + P1 --> P4a["Phase 4: 智能补全"] + P1 --> P4b["Phase 4: Literature Review"] + P1 --> P4d["Phase 4: PDF AI 助手"] + + P2 --> P4c["Phase 4: 文献图谱"] + + P3 --> P4b + + P4a --> P5["Phase 5: 打磨发布"] + P4b --> P5 + P4c --> P5 + P4d --> P5 +``` + +**关键路径**:Phase 0 → Phase 1 → Phase 4 → Phase 5 + +**可并行**: +- Phase 1 的引用增强/斜杠命令 与 Phase 2 的 AI 建库/Zotero 可并行 +- Phase 3 的订阅定时任务与 Phase 2 可并行 +- Phase 4 的文献图谱与智能补全可并行 + +--- + +## 5. 团队分工建议 + +| 角色 | 负责模块 | 主要 Phase | +|------|----------|-----------| +| 后端开发 A | Chat Pipeline、意图识别、LLM 配置 | Phase 0, 1 | +| 后端开发 B | 知识库管理、Zotero、订阅 | Phase 2, 3 | +| 前端开发 A | 对话 UI、引用增强、斜杠命令 | Phase 1, 4 | +| 前端开发 B | 知识库 UI、设置页、文献图谱 | Phase 2, 3, 4 | +| 全栈 / AI | RAG 增强、Reranker、混合检索 | Phase 1, 3 | + +--- + +## 6. 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| LangGraph checkpoint SQLite 锁竞争 | 多并发时 Pipeline 卡顿 | 限制并发数,后续可切 Redis | +| Zotero API 速率限制 | 大量导入时超限 | 客户端限流 + 指数退避 | +| Reranker 模型 GPU 占用 | 与 Embedding 竞争 GPU | 分时调度,或 CPU fallback | +| 智能补全延迟 | 用户体验差 | debounce + 缓存 + 可选关闭 | +| 文献图谱数据量大 | 前端渲染卡顿 | 分页加载 + 虚拟化 + WebWorker | + +--- + +## 7. 留空点 — 后续迭代方向 + +| 方向 | 说明 | 预估 Phase | +|------|------|-----------| +| 记忆系统 mem0 集成 | 基于 ChromaDB 独立 collection,短/长期记忆管理 | V3.1 | +| VLM 图表描述 | 提取论文图表 → 调用 GPT-4V/Gemini 生成描述 → 文本嵌入检索 | V3.2 | +| 数据库迁移 PostgreSQL | 若需多用户高并发,迁移至 PostgreSQL + pgvector(mem0 也支持) | V3.2 | +| 知识图谱 | 概念关系、作者网络 | V3.2 | +| 认证系统(JWT + OAuth) | 多用户支持 | V3.1 | +| 协同标注 | 多人笔记与讨论 | V4 | +| 移动端适配 | 响应式/PWA | V3.2 | +| Zotero 双向同步 | 回写笔记和标签 | V3.2 | +| ColPali 多模态 RAG | 直接对论文图片做向量检索(技术成熟时) | V4 | +| Audio Overview | 播客式知识消化 | V4 | + +--- + +*本文档为实施路线图,具体任务分解与验收标准见各模块 PRD 文档。* diff --git a/docs/prd/v3/07-code-audit-and-fixes.md b/docs/prd/v3/07-code-audit-and-fixes.md new file mode 100644 index 0000000..a34d149 --- /dev/null +++ b/docs/prd/v3/07-code-audit-and-fixes.md @@ -0,0 +1,408 @@ +# Omelette V3 — 代码审计与修复清单 + +> 版本:V3.0 | 日期:2026-03-15 | 状态:审计完成 + +本文档记录了对现有代码的深度审查结果,标注了所有需要修复的 BUG、不一致和优化空间,按严重程度分级。 + +--- + +## 一、严重 BUG(P0 — 功能错误) + +### BUG-1:相邻 chunk 上下文拼接逻辑错误 + +**位置**:`backend/app/services/rag_service.py` 第 167-236 行 + +**现状**: + +```python +# _get_adjacent_chunks 返回的是 prev + next 混合拼接 +target_ids = [ + f"paper_{paper_id}_chunk_{chunk_index + offset}" + for offset in range(-window, window + 1) if offset != 0 +] +# 当 window=1 时,target_ids = [chunk_index-1, chunk_index+1] +docs = result.get("documents") or [] +return "\n".join(d for d in docs if d) # 返回 "prev_text\nnext_text" + +# 然后在 query() 中: +full_context = f"{adjacent_text}\n{text}\n{adjacent_text}".strip() +# 实际输出:[prev+next]\n[主chunk]\n[prev+next] +``` + +**问题**: +1. 前后 chunk 文本混在一起,无法区分顺序 +2. `adjacent_text` 被重复放在主 chunk 两侧,内容翻倍 +3. 实际顺序错乱:期望 `[前]\n[主]\n[后]`,实际 `[前+后]\n[主]\n[前+后]` + +**修复方案**: + +```python +def _get_adjacent_chunks(self, collection, paper_id, chunk_index, window=1): + prev_ids = [f"paper_{paper_id}_chunk_{chunk_index + offset}" + for offset in range(-window, 0)] + next_ids = [f"paper_{paper_id}_chunk_{chunk_index + offset}" + for offset in range(1, window + 1)] + + prev_text = self._fetch_chunk_texts(collection, prev_ids) + next_text = self._fetch_chunk_texts(collection, next_ids) + return prev_text, next_text + +# 在 query() 中: +prev_text, next_text = await asyncio.to_thread( + self._get_adjacent_chunks, collection, paper_id, chunk_idx +) +parts = [p for p in [prev_text, text, next_text] if p] +full_context = "\n".join(parts) +``` + +**影响**:RAG 检索上下文质量严重受损,所有引用的 excerpt 内容错乱。 + +--- + +### BUG-2:Pipeline apply_resolution 逻辑错误 + +**位置**:`backend/app/pipelines/nodes.py` 第 148-159 行 + +**现状**: + +```python +for res in resolved: + action = res.get("action", "skip") + new_paper = res.get("new_paper", {}) + if action in ("keep_new", "skip") and new_paper: + clean_papers.append(new_paper) +``` + +**问题**:`skip` 表示「跳过此冲突,不导入」,但代码中 `skip` 和 `keep_new` 一样会将 `new_paper` 加入 `clean_papers`。 + +**修复方案**: + +```python +if action == "keep_new" and new_paper: + clean_papers.append(new_paper) +elif action == "merge" and new_paper: + # 合并逻辑(留空) + clean_papers.append(new_paper) +# skip 和 keep_old 不添加 new_paper +``` + +**影响**:用户选择「跳过」时,重复文献仍会被导入。 + +--- + +## 二、中等问题(P1 — 数据质量/一致性) + +### ISSUE-3:OCR 分块策略不一致 + +**位置**: + +| 入口 | 分块策略 | 文件 | +|------|----------|------| +| `paper_processor` | 语义分块:1024字符、100重叠、段落切分 | `paper_processor.py:66-77` | +| Pipeline `ocr_node` | 按页分块:一页 = 一个 chunk | `pipelines/nodes.py:268-280` | +| `pipeline_service._ocr` | 按页分块 | `pipeline_service.py:86-101` | + +**问题**:通过不同入口处理的同一篇论文,生成的 chunk 粒度完全不同。按页分块可能导致: +- chunk 过长(一页可能数千字符),超出 embedding 模型最优输入长度 +- 段落被截断在页面边界 +- RAG 检索精度下降 + +**修复方案**:Pipeline `ocr_node` 统一使用 `ocr.chunk_text()` 进行语义分块。 + +```python +# ocr_node 修改 +result = await asyncio.to_thread(ocr.process_pdf, paper.pdf_path) +chunks = ocr.chunk_text(result.get("pages", [])) +for chunk_data in chunks: + db.add(PaperChunk( + paper_id=paper.id, + content=chunk_data["content"], + chunk_type=chunk_data.get("chunk_type", "text"), + page_number=chunk_data.get("page_number", 0), + chunk_index=chunk_data.get("chunk_index", 0), + token_count=chunk_data.get("token_count"), + )) +``` + +--- + +### ISSUE-4:索引路径元数据不完整 + +**位置**:`pipelines/nodes.py` `index_node` 第 324-333 行 + +**现状**: + +```python +chunk_dicts = [ + { + "paper_id": paper.id, + "paper_title": paper.title, + "content": c.content, + "page_number": c.page_number, + "chunk_index": c.chunk_index, + # 缺失:chunk_type, section + } + for c in chunks +] +``` + +**问题**: +- `chunk_type` 未传递 → ChromaDB 中全部退化为 `"text"` +- `section` 未传递 → 无法按章节定位引用 + +**修复方案**: + +```python +chunk_dicts = [ + { + "paper_id": paper.id, + "paper_title": paper.title, + "content": c.content, + "page_number": c.page_number, + "chunk_index": c.chunk_index, + "chunk_type": c.chunk_type or "text", + "section": c.section or "", + } + for c in chunks +] +``` + +同时 `RAGService.index_chunks` 也需要将 `section` 加入 metadata: + +```python +metadata={ + "paper_id": chunk["paper_id"], + "paper_title": chunk.get("paper_title", ""), + "chunk_type": chunk.get("chunk_type", "text"), + "page_number": chunk.get("page_number", 0), + "chunk_index": chunk.get("chunk_index", 0), + "section": chunk.get("section", ""), # 新增 +}, +``` + +--- + +### ISSUE-5:去重阈值分散且不一致 + +**位置**: + +| 位置 | 阈值 | 说明 | +|------|------|------| +| `pipelines/nodes.py` `dedup_node` | 0.85 | Pipeline 去重 | +| `api/v1/upload.py` | 0.85 | 上传 API 去重 | +| `services/dedup_service.py` Stage 2 | 0.90 | DedupService 硬去重 | +| `services/dedup_service.py` Stage 3 | 0.80 | DedupService LLM 候选 | + +**问题**:同一套数据通过不同入口去重,阈值不同,结果不一致。Pipeline 的 `dedup_node` 完全不调用 `DedupService`,重复实现了去重逻辑。 + +**修复方案**: +1. 将阈值集中到 `app/config.py` +2. Pipeline `dedup_node` 改为调用 `DedupService` +3. PRD 中的分级去重与代码统一 + +```python +# config.py +DEDUP_TITLE_HARD_THRESHOLD: float = 0.90 +DEDUP_TITLE_LLM_THRESHOLD: float = 0.80 +``` + +--- + +### ISSUE-6:index_node 存在 N+1 查询 + +**位置**:`pipelines/nodes.py` 第 316-319 行 + +```python +for paper in papers: + chunks = (await db.execute( + select(PaperChunk).where(PaperChunk.paper_id == paper.id) + )).scalars().all() +``` + +**问题**:每篇 Paper 一次查询,N 篇 = N 次 SQL。 + +**修复方案**: + +```python +paper_ids = [p.id for p in papers] +all_chunks = (await db.execute( + select(PaperChunk).where(PaperChunk.paper_id.in_(paper_ids)) +)).scalars().all() +chunks_by_paper = defaultdict(list) +for c in all_chunks: + chunks_by_paper[c.paper_id].append(c) + +for paper in papers: + chunks = chunks_by_paper.get(paper.id, []) + ... +``` + +--- + +### ISSUE-7:Chat Pipeline retrieve_node 参数硬编码 + +**位置**:`pipelines/chat/nodes.py` 第 160 行 + +```python +tasks = [rag.query(project_id=kb_id, question=state["message"], + top_k=5, include_sources=True) for kb_id in kb_ids] +``` + +**问题**: +- `top_k=5` 硬编码,多知识库时每个 KB 只取 5 条可能不够 +- `use_reranker` 始终不传,即使后端已实现也不会启用 +- 无法根据 `tool_mode` 调整检索策略 + +**修复方案**: + +```python +rag_top_k = state.get("rag_top_k", 10) +use_reranker = state.get("use_reranker", False) + +tasks = [rag.query( + project_id=kb_id, + question=state["message"], + top_k=rag_top_k, + use_reranker=use_reranker, + include_sources=True, +) for kb_id in kb_ids] +``` + +--- + +### ISSUE-8:Pipeline 取消机制无效 + +**位置**:`api/v1/pipelines.py` + +**现状**:取消仅设置 `task["status"] = "cancelled"`,但未将 `cancelled=True` 写入 Pipeline 的 `state`。节点中的 `if state.get("cancelled"): break` 永远读到 `False`。 + +**修复方案**:需要通过 LangGraph 的 `Command` 机制注入 `cancelled` 到 state,或在节点执行前轮询外部取消标记。 + +--- + +## 三、优化空间(P2 — 性能/体验) + +### OPT-1:RAG 查询结果无相关性过滤 + +**现状**:`retrieve_node` 返回所有检索结果,无论 relevance_score 高低。低相关性结果会稀释 context 质量。 + +**优化**:在 `rank_node` 中过滤低于阈值的引用: + +```python +MIN_RELEVANCE = 0.3 +citations = [c for c in citations if c["relevance_score"] >= MIN_RELEVANCE] +``` + +--- + +### OPT-2:RAG query 中 LLM 生成可能冗余 + +**现状**:`rag_service.query()` 内部会调用 `_generate_answer()` 生成答案,但 Chat Pipeline 中 `generate_node` 又会调用 LLM 生成。`retrieve_node` 调用 `rag.query()` 时,内部的 LLM 生成是浪费的。 + +**优化**:`retrieve_node` 调用 RAG 时仅做检索,不生成答案。方案: +- 新增 `RAGService.retrieve_only()` 方法,或 +- 在 `query()` 中增加 `skip_generation=True` 参数 + +--- + +### OPT-3:多知识库检索结果未做跨 KB 去重 + +**现状**:`retrieve_node` 并行查询多个 KB 后直接 `extend`,同一篇论文可能在多个 KB 中被索引,导致重复引用。 + +**优化**:按 `paper_id + chunk_index` 去重,保留 score 最高的。 + +--- + +### OPT-4:excerpt 截断粗暴 + +**现状**:`rag_service.py:248` + +```python +"excerpt": full_context[:800] + "..." if len(full_context) > 800 else full_context, +``` + +直接按字符截断可能截断句子。 + +**优化**:在最近的句号/换行处截断。 + +--- + +## 四、RAG 完整路径审计总结 + +### 当前路径(有问题的) + +```mermaid +flowchart TB + subgraph 索引路径 + A[PDF 上传] --> B{入口?} + B -->|paper_processor| C[语义分块\n1024字/100重叠] + B -->|Pipeline ocr_node| D[按页分块\n❌ 粒度过粗] + C --> E[PaperChunk\n含 chunk_type, section] + D --> F[PaperChunk\n❌ 缺 chunk_type, section] + E --> G[RAGService.index_chunks] + F --> H[index_node\n❌ 缺 chunk_type] + G --> I[ChromaDB\n含完整 metadata] + H --> J[ChromaDB\n❌ 缺 section] + end + + subgraph 检索路径 + K[用户提问] --> L[retrieve_node\n❌ top_k=5 硬编码] + L --> M[RAGService.query\ntop_k=5] + M --> N[ChromaDB 向量检索] + N --> O[相邻 chunk 扩展\n❌ 拼接逻辑错误] + O --> P[sources 列表] + P --> Q[rank_node\n批量查 Paper 表] + Q --> R[citation 列表\n❌ 无相关性过滤] + R --> S[clean_node\nLLM 清洗 excerpt] + S --> T[generate_node\n流式生成] + end +``` + +### 修复后路径(目标) + +```mermaid +flowchart TB + subgraph 索引路径 + A[PDF 上传] --> B{入口} + B -->|任何入口| C[统一语义分块\n1024字/100重叠] + C --> D[PaperChunk\n含 chunk_type, section] + D --> E[index_chunks\n含完整 metadata] + E --> F[ChromaDB\n完整 metadata] + end + + subgraph 检索路径 + G[用户提问] --> H[retrieve_node\ntop_k=可配置] + H --> I[RAGService.retrieve_only\n仅检索不生成] + I --> J[ChromaDB 向量检索] + J --> K[相邻 chunk 扩展\n正确顺序 前-主-后] + K --> L["Reranker (可选)"] + L --> M[sources 列表] + M --> N[rank_node\n批量查 Paper\n+ 相关性过滤] + N --> O[跨 KB 去重] + O --> P[clean_node] + P --> Q[generate_node] + end +``` + +--- + +## 五、修复优先级矩阵 + +| ID | 问题 | 优先级 | 影响 | 工作量 | +|----|------|--------|------|--------| +| BUG-1 | 相邻 chunk 拼接逻辑 | **P0** | RAG 上下文质量 | 0.5d | +| BUG-2 | apply_resolution skip 逻辑 | **P0** | 去重功能 | 0.5h | +| ISSUE-3 | OCR 分块策略统一 | **P1** | RAG 检索精度 | 1d | +| ISSUE-4 | 索引元数据补全 | **P1** | 引用定位 | 0.5d | +| ISSUE-5 | 去重阈值统一 | **P1** | 去重一致性 | 0.5d | +| ISSUE-6 | index_node N+1 | **P1** | 索引性能 | 0.5h | +| ISSUE-7 | retrieve top_k 可配置 | **P1** | 检索质量 | 0.5h | +| ISSUE-8 | Pipeline 取消机制 | **P1** | 用户体验 | 1d | +| OPT-1 | 相关性过滤 | **P2** | 引用精度 | 0.5h | +| OPT-2 | retrieve_only | **P2** | 性能/成本 | 0.5d | +| OPT-3 | 跨 KB 去重 | **P2** | 引用去重 | 0.5h | +| OPT-4 | excerpt 智能截断 | **P2** | 引用可读性 | 0.5h | + +--- + +*本文档作为 V3 开发前的代码健康度基线,所有 P0/P1 问题应在 Phase 0 或 Phase 1 前完成修复。* diff --git a/docs/prd/v3/08-technical-deep-dive.md b/docs/prd/v3/08-technical-deep-dive.md new file mode 100644 index 0000000..8129fb3 --- /dev/null +++ b/docs/prd/v3/08-technical-deep-dive.md @@ -0,0 +1,845 @@ +# Omelette V3 — 技术深度研究与决策 + +> 版本:V3.0 | 日期:2026-03-15 | 状态:研究完成 + +--- + +## 一、数据库选型与 mem0 兼容性 + +### 1.1 现状 + +| 层 | 技术 | 存储内容 | +|----|------|----------| +| 元数据 | SQLite + SQLAlchemy async | Paper, Conversation, Message, Keyword, Subscription 等 | +| 向量存储 | ChromaDB | PaperChunk 嵌入向量 | +| 文件 | 本地文件系统 | PDF, OCR JSON | + +### 1.2 mem0 对数据库的要求 + +通过调研 mem0 官方文档(https://docs.mem0.ai/components/vectordbs/overview): + +| 维度 | 结论 | +|------|------| +| **向量库** | mem0 支持 **17+ 种向量数据库**,包括 ChromaDB、Qdrant、pgvector、Milvus、Pinecone、FAISS 等 | +| **默认** | 未配置时默认使用 Qdrant | +| **ChromaDB** | **支持**,Python SDK 完整支持 | +| **元数据库** | mem0 自身管理,不要求特定关系型数据库 | +| **SQLite** | mem0 不直接使用 SQLite,不冲突 | + +### 1.3 决策:维持 SQLite + ChromaDB + +```mermaid +flowchart LR + subgraph 当前["当前架构(维持)"] + SQLite["SQLite\n元数据"] + ChromaDB["ChromaDB\n向量存储"] + FS["文件系统\nPDF/OCR"] + end + + subgraph mem0集成["mem0 集成(Phase 2+)"] + Mem0["mem0 SDK"] + Mem0 --> ChromaDB + end + + subgraph 未来可选["未来可选迁移"] + PG["PostgreSQL + pgvector"] + end + + SQLite -.->|未来若需多用户| PG + ChromaDB -.->|未来若需大规模| Qdrant["Qdrant"] +``` + +**理由**: + +1. **mem0 支持 ChromaDB**:无需迁移向量库即可接入 mem0 +2. **SQLite 够用**:单用户/小团队场景,SQLite 性能足够,无运维成本 +3. **渐进式**:若未来需多用户或高并发,可迁移到 PostgreSQL + pgvector(一次搞定关系+向量) +4. **不过度设计**:当前阶段引入 PostgreSQL 或 Qdrant 增加运维复杂度,收益不大 + +### 1.4 mem0 集成方案(Phase 2+) + +```python +from mem0 import Memory + +m = Memory.from_config({ + "vector_store": { + "provider": "chroma", + "config": { + "collection_name": "omelette_memories", + "path": str(settings.chroma_db_dir), + } + } +}) + +# 添加记忆 +m.add("用户倾向于关注 LLM 在医疗领域的应用", user_id="default") + +# 检索相关记忆 +memories = m.search("医疗 AI", user_id="default") +``` + +**与现有架构的集成点**: +- mem0 使用独立的 ChromaDB collection(`omelette_memories`),不与 RAG 的 `project_N` collection 冲突 +- 在 Chat Pipeline 的 `understand` 节点中注入 mem0 检索结果到 system prompt +- 每次对话结束后,在 `persist` 节点中可选地将关键信息写入 mem0 + +--- + +## 二、PDF 解析引擎选型 + +### 2.1 现状分析 + +当前的 OCR 三级策略: + +| 级别 | 引擎 | 问题 | +|------|------|------| +| 1 | pdfplumber | 仅能提取原生文本型 PDF;表格提取尚可,但无公式/图片识别 | +| 2 | marker-pdf | 未声明在 `pyproject.toml` 依赖中,需手动安装 | +| 3 | PaddleOCR | **硬编码 `lang="en"`**,中文扫描件识别效果差 | + +**关键缺陷**: +- 无公式识别(`formula` chunk_type 定义了但从未产出) +- 无图片/图表描述 +- 中文支持不完整 +- marker-pdf 的 Markdown 表格未被识别为 `table` chunk + +### 2.2 MinerU 深度调研 + +#### 2.2.1 基本信息 + +| 维度 | 详情 | +|------|------| +| **项目** | [opendatalab/MinerU](https://github.com/opendatalab/MinerU) | +| **Stars** | 56K+(截至 2026-03) | +| **许可证** | **AGPL-3.0**(开源,copyleft) | +| **最新版本** | **mineru 2.7.6**(2026-02-06) | +| **包名** | v1.x: `magic-pdf` → v2.x: **`mineru`**(2025-06 起更名) | +| **Python** | 3.10 - 3.12(与本项目 3.12 兼容) | +| **GPU 需求** | 最低 **6GB VRAM**,推荐 8GB;支持 CPU 模式 | +| **CUDA** | 11.8 / 12.4 / 12.6 | + +#### 2.2.2 开源性与许可证分析 + +MinerU 采用 **AGPL-3.0** 许可证: + +| 场景 | 影响 | +|------|------| +| **学术/个人研究项目** | ✅ 完全可用,无限制 | +| **内部部署(不对外提供服务)** | ✅ 可用 | +| **对外提供网络服务** | ⚠️ AGPL 要求公开源码(但 Omelette 本身也开源,无冲突) | +| **修改 MinerU 代码** | ⚠️ 修改部分需开源(建议不修改,通过 API 调用) | + +**结论**:Omelette 为开源科研工具,AGPL-3.0 无实际约束。推荐以 **独立服务** 方式集成(HTTP API),避免代码耦合。 + +#### 2.2.3 三种后端模式 + +MinerU v2.7+ 提供三种后端: + +| 后端 | 原理 | 适用场景 | 精度 | 速度 | +|------|------|----------|------|------| +| **pipeline** | 传统规则 + 小模型 | 文本 PDF、低 GPU 场景 | 中 | 快 | +| **vlm** | MinerU2.5 VLM(1.2B 参数) | 复杂论文、扫描件 | **高** | 中 | +| **hybrid**(推荐) | vlm + pipeline 融合 | **通用最佳** | **最高** | 中 | + +v2.5 的 VLM 模型在 OmniDocBench 上**超过 Gemini 2.5 Pro、GPT-4o、Qwen2.5-VL-72B**,公式和表格识别优秀。 + +**推荐使用 `hybrid` 后端**: +- 文本 PDF 直接提取文本,减少 VLM 幻觉 +- 扫描 PDF 用 VLM 识别,支持 109 种语言 +- 行内公式可独立控制开关 + +#### 2.2.4 核心能力详情 + +| 能力 | 实现 | 输出格式 | +|------|------|----------| +| **公式识别** | 行内 + 行间,v2.5 优化中英混合公式 | `$...$`、`$$...$$` LaTeX | +| **表格识别** | 旋转表格、无边框表格、**跨页表格合并**(v2.7.2+) | HTML 或 Markdown | +| **图片提取** | 自动提取,存储到 images 目录 | `![caption](path)` | +| **布局分析** | 标题、段落、列表、图注、脚注、页眉页脚过滤 | 结构化 JSON + Markdown | +| **OCR 识别** | 自动检测是否需要 OCR,支持 **109 种语言** | 按阅读顺序排列 | +| **结构化输出** | `model.json`(布局坐标)、`content_list.json`(按阅读序) | JSON + Markdown | + +#### 2.2.5 集成方式对比 + +| 方式 | 优点 | 缺点 | 推荐度 | +|------|------|------|--------| +| **A. `mineru-api` 独立服务** | GPU 隔离、独立升级、许可证解耦、自带 FastAPI | 多一个服务运维 | ⭐⭐⭐⭐⭐ | +| B. Python API 嵌入 | 无网络开销 | 依赖耦合、GPU 竞争 | ⭐⭐⭐ | +| C. CLI 子进程调用 | 简单 | 无法异步、性能差 | ⭐⭐ | + +### 2.3 推荐方案:MinerU 独立服务 + pdfplumber 兜底 + +```mermaid +flowchart TD + PDF[PDF 文件] --> Detect{快速文本检测} + + Detect -->|纯文本 PDF + 无公式| PB[pdfplumber\n快速提取] + Detect -->|学术论文 / 含公式表格 / 扫描件| API["HTTP → MinerU API\n(独立服务 :8010)"] + + PB --> Check{文本质量检查} + Check -->|充足 + 无公式需求| Result[结果] + Check -->|不足| API + + API --> Parse[MinerU 解析输出] + Parse --> MD[Markdown\n含 LaTeX 公式] + Parse --> Tables[表格\nHTML/Markdown] + Parse --> Figures[图片\n含 caption] + Parse --> Structure[结构化 JSON\n标题/段落/图注] + + MD --> Chunk[语义分块] + Tables --> Chunk + Figures --> Chunk + Structure --> Chunk + Chunk --> Result +``` + +**为什么选 MinerU**: + +1. **公式识别最强**:VLM 模型超越 GPT-4o,自动输出 LaTeX,前端 KaTeX 直接渲染 +2. **表格识别强**:支持旋转表格、无边框表格、跨页表格合并 +3. **109 种语言**:中英文自动检测,不需分语言处理 +4. **图片提取含描述**:可用于多模态 RAG +5. **GPU 友好**:v2.x 优化后最低 6GB VRAM +6. **活跃维护**:56K+ stars,每周更新,已适配 10 种国产算力平台 + +**独立服务的优势**: +- `mineru-api --host 0.0.0.0 --port 8010` 一行启动 +- 自带 Swagger 文档(`/docs`) +- GPU 资源与 Omelette 后端隔离 +- 可独立升级 MinerU 版本 +- AGPL 许可证通过 HTTP 调用最大化解耦 + +**保留 pdfplumber 的原因**: +- 纯文本 PDF 提取速度快(<1s vs MinerU 数秒),无需 GPU +- CPU-only 环境的兜底方案 +- MinerU 服务不可用时的降级路径 + +### 2.4 中英文处理策略 + +**不再需要分语言处理**:MinerU v2.7 `hybrid` 后端支持 109 种语言自动检测。 + +| 现状 | 修改后 | +|------|--------| +| PaddleOCR `lang="en"` 硬编码 | MinerU 自动语言检测 | +| 中文扫描件效果差 | MinerU VLM 中文识别优秀 | +| 需手动区分引擎 | 统一引擎,自动处理 | +| 三级 fallback 链路复杂 | 二级:pdfplumber → MinerU API | + +### 2.5 实现方案 + +#### 部署 MinerU 服务 + +```bash +# 独立 conda 环境(避免与 Omelette 依赖冲突) +conda create -n mineru python=3.12 -y +conda activate mineru +pip install mineru + +# 下载模型(首次) +mineru-models-download + +# 启动 API 服务(后台运行) +mineru-api --host 0.0.0.0 --port 8010 +# 访问 http://localhost:8010/docs 查看 API 文档 +``` + +#### Omelette OCRService 改造 + +```python +import httpx + +class OCRService: + def __init__(self, mineru_url: str = "http://localhost:8010"): + self.mineru_url = mineru_url + self._mineru_available = None + + async def process_pdf(self, pdf_path: str, force_deep: bool = False) -> dict: + # 1. 快速尝试 pdfplumber + if not force_deep: + native_result = self._extract_with_pdfplumber(pdf_path) + if self._quality_check(native_result) and not self._likely_has_formulas(native_result): + return native_result + + # 2. MinerU API 深度解析 + if await self._is_mineru_available(): + try: + return await self._extract_with_mineru_api(pdf_path) + except Exception as e: + logger.warning(f"MinerU API failed: {e}") + + # 3. 兜底 + return native_result or {"pages": [], "method": "failed"} + + async def _extract_with_mineru_api(self, pdf_path: str) -> dict: + async with httpx.AsyncClient(timeout=300) as client: + with open(pdf_path, "rb") as f: + resp = await client.post( + f"{self.mineru_url}/file/upload", + files={"file": f}, + data={"backend": "hybrid"}, + ) + result = resp.json() + return self._parse_mineru_response(result) +``` + +#### 配置新增 + +```env +# PDF 解析 +PDF_PARSER=auto # auto | mineru | pdfplumber +MINERU_API_URL=http://localhost:8010 # MinerU 独立服务地址 +MINERU_BACKEND=hybrid # hybrid | vlm | pipeline +``` + +#### docker-compose 集成(可选) + +```yaml +services: + omelette-backend: + # ... 现有配置 + + mineru: + image: mineru:latest + command: mineru-api --host 0.0.0.0 --port 8010 + ports: + - "8010:8010" + deploy: + resources: + reservations: + devices: + - capabilities: [gpu] + count: 1 + volumes: + - ./data/mineru-models:/root/.cache/mineru +``` + +--- + +## 三、图片、表格、公式的 RAG 策略 + +### 3.1 总体策略 + +```mermaid +flowchart TB + subgraph 解析["PDF 解析(MinerU)"] + Text["文本段落"] + Formula["公式 → LaTeX"] + Table["表格 → HTML/Markdown"] + Figure["图片 → 提取 + 描述"] + end + + subgraph 分块["智能分块"] + TextChunk["text chunk\n语义分块 1024/100"] + FormulaChunk["formula chunk\nLaTeX + 上下文"] + TableChunk["table chunk\nMarkdown/HTML"] + FigureChunk["figure_caption chunk\nVLM 描述 + caption"] + end + + subgraph 索引["向量索引"] + TextEmbed["文本嵌入"] + FormulaEmbed["公式嵌入\nLaTeX 文本化"] + TableEmbed["表格嵌入\n线性化文本"] + FigureEmbed["图片嵌入\n描述文本"] + end + + Text --> TextChunk --> TextEmbed + Formula --> FormulaChunk --> FormulaEmbed + Table --> TableChunk --> TableEmbed + Figure --> FigureChunk --> FigureEmbed +``` + +### 3.2 公式处理 + +#### 提取 + +MinerU 自动将公式转为 LaTeX 格式,输出在 Markdown 中: +- 行内公式:`$E = mc^2$` +- 行间公式:`$$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$` + +#### 分块策略 + +```python +# 公式不单独成 chunk,而是随上下文一起分块 +# 但在 chunk metadata 中标记包含公式 +chunk = { + "content": "根据 Maxwell 方程组 $$\\nabla \\cdot \\mathbf{E} = \\frac{\\rho}{\\epsilon_0}$$,电场散度...", + "chunk_type": "text", # 含公式的文本 + "has_formula": True, + "page_number": 5, +} +``` + +**关键决策**:公式不单独做 chunk,而是保留在其上下文段落中。原因: +1. 孤立的公式(如 `$E=mc^2$`)无法被有效检索 +2. 公式的含义依赖上下文 +3. LaTeX 文本本身可以被 embedding 模型理解(bge-m3 支持多语言含数学) + +#### RAG 检索 + +- 用户问「Maxwell 方程」→ embedding 匹配包含相关公式的段落 → 返回含 LaTeX 的 excerpt +- 前端 KaTeX 自动渲染 `$...$` 和 `$$...$$` + +#### 前端渲染 + +已有 `rehype-katex` + `remark-math` 插件,**但缺少 KaTeX CSS**: + +``` +现状:import rehypeKatex from "rehype-katex" ← 插件已引入 +问题:katex/dist/katex.min.css 未导入 ← 公式样式缺失 +``` + +**修复**:在 `frontend/src/main.tsx` 或 `index.css` 中添加: + +```tsx +import 'katex/dist/katex.min.css'; +``` + +### 3.3 表格处理 + +#### 提取 + +| 来源 | 格式 | 当前处理 | +|------|------|----------| +| pdfplumber `extract_tables()` | Python list[list] | ✅ 转为 `table` chunk | +| marker-pdf Markdown 表格 | GFM Markdown | ❌ 被当作普通文本 | +| MinerU 表格 | HTML 或 LaTeX | 需适配 | + +#### 分块策略 + +```python +# 表格单独成 chunk,保留表格标题作为上下文 +chunk = { + "content": "表3:不同模型在 MMLU 上的准确率\n| Model | Acc |\n|---|---|\n| GPT-4 | 86.4 |...", + "chunk_type": "table", + "page_number": 8, + "section": "4.2 实验结果", +} +``` + +**线性化策略**(用于 embedding): +- 小表格(< 500 字符):直接用 Markdown 文本嵌入 +- 大表格:提取表头 + 前 N 行,附加表格标题 +- embedding 模型对结构化文本的理解有限,线性化是必要的 + +#### RAG 检索 + +- 用户问「GPT-4 在 MMLU 上的表现」→ 检索到表格 chunk → 返回 Markdown 表格 +- 前端 `remark-gfm` 自动渲染 Markdown 表格 ✅ + +### 3.4 图片/图表处理 + +#### 提取 + +MinerU 可提取图片并输出到独立文件,但不自动生成描述。 + +#### RAG 策略(渐进式) + +| 阶段 | 方案 | 可行性 | +|------|------|--------| +| **Phase 1** | 提取图片 caption(MinerU 可识别)→ 以 `figure_caption` chunk 索引 | 高,无需 VLM | +| **Phase 2** | 提取图片 → 调用 VLM(GPT-4V/Gemini)生成描述 → 描述文本索引 | 中,需 VLM API | +| **Phase 3** | ColPali 等多模态嵌入,直接对图片做向量检索 | 低,技术尚不成熟 | + +**Phase 1 实现**: + +```python +# MinerU 输出中包含图片 caption +chunk = { + "content": "Figure 3: Comparison of attention mechanisms. " + "The self-attention mechanism shows quadratic complexity...", + "chunk_type": "figure_caption", + "page_number": 6, + "figure_path": "figures/fig3.png", # 可选:存储图片路径 +} +``` + +**Phase 2 实现**(留空): + +```python +# 对提取的图片调用 VLM 生成描述 +async def describe_figure(image_path: str) -> str: + # 使用 generation 级别模型(支持多模态) + response = await llm.chat([ + {"role": "user", "content": [ + {"type": "text", "text": "请详细描述这张科学论文中的图表内容"}, + {"type": "image_url", "image_url": {"url": f"file://{image_path}"}}, + ]} + ]) + return response.content +``` + +### 3.5 前端渲染能力汇总 + +| 内容类型 | 渲染方式 | 当前支持 | 需要修复 | +|----------|----------|:--------:|:--------:| +| **Markdown 文本** | `react-markdown` | ✅ | — | +| **GFM 表格** | `remark-gfm` | ✅ | — | +| **代码高亮** | `rehype-highlight` | ✅ | — | +| **数学公式** | `remark-math` + `rehype-katex` | ⚠️ 插件有,CSS 缺失 | 需导入 `katex.min.css` | +| **引用标注** | `remark-citation` | ✅ | — | +| **图片** | Markdown `![alt](url)` | ✅ 但未使用 | 需设计图片展示 | +| **Mermaid 图** | 需额外插件 | ❌ | 留空 | + +--- + +## 四、RAG 方案深度评估 + +### 4.1 现有方案是否需要大改? + +**结论:不需要大改,增量优化即可。** + +| 组件 | 现状 | 评估 | 行动 | +|------|------|------|------| +| LlamaIndex | RAG 框架 | 成熟稳定,生态好 | 维持 | +| ChromaDB | 向量存储 | 轻量够用,mem0 兼容 | 维持 | +| bge-m3 | 嵌入模型 | 多语言、高质量 | 维持 | +| Reranker | 未实现 | **必须引入** | BGE Reranker | +| 混合检索 | 未实现 | 建议引入 | BM25 + 向量 | +| 分块策略 | 不一致 | **必须统一** | MinerU 输出 + 语义分块 | + +### 4.2 Deep Research 类项目的 RAG 架构参考 + +调研了 GPT-Researcher、STORM 等项目: + +| 项目 | RAG 特点 | 可借鉴 | +|------|----------|--------| +| **GPT-Researcher** | 多 Agent 树状探索,每层检索→总结→再检索 | 研究问题拆解的迭代式检索 | +| **STORM** | 预写作阶段多视角提问+检索 | 综述写作的多轮 RAG | +| **LangChain RAG** | 标准 retrieve→rerank→generate | Reranker + 混合检索 | + +**借鉴方向**: +- **迭代式检索**:对复杂问题,先检索→生成子问题→再检索,提高召回率 +- **多视角检索**:综述写作时,从不同角度提问检索同一主题 +- 但这些都是**编排层**的优化,不需要更换底层 RAG 组件 + +### 4.3 优化路径(优先级排序) + +```mermaid +flowchart LR + subgraph P0["P0: 修复 BUG"] + A1["相邻 chunk 拼接修复"] + A2["分块策略统一"] + A3["元数据补全"] + end + + subgraph P1["P1: 核心增强"] + B1["BGE Reranker"] + B2["retrieve_only 方法"] + B3["相关性过滤 + 跨KB去重"] + B4["KaTeX CSS 修复"] + end + + subgraph P2["P2: 进阶增强"] + C1["BM25 混合检索"] + C2["MinerU 集成"] + C3["公式/表格 chunk 优化"] + end + + subgraph P3["P3: 创新"] + D1["图片 caption RAG"] + D2["VLM 图表描述"] + D3["迭代式检索"] + end + + P0 --> P1 --> P2 --> P3 +``` + +--- + +## 五、MinerU 集成详细方案 + +### 5.1 部署方案:独立服务 + +推荐以独立服务方式部署,与 Omelette 后端解耦。 + +#### 环境准备 + +```bash +# 1. 创建独立 conda 环境(避免与 Omelette 依赖冲突) +conda create -n mineru python=3.12 -y +conda activate mineru + +# 2. 安装 mineru(v2.x 包名已从 magic-pdf 改为 mineru) +pip install mineru + +# 3. 首次下载模型(约 2GB,支持离线部署) +mineru-models-download +# 或者使用 modelscope 源(国内加速): +# export MINERU_MODEL_SOURCE=modelscope && mineru-models-download + +# 4. 启动 API 服务 +mineru-api --host 0.0.0.0 --port 8010 +# 自带 Swagger 文档:http://localhost:8010/docs +``` + +#### 配置文件(`~/mineru.json`) + +```json +{ + "latex-delimiter-config": { + "left": "$", + "right": "$", + "display_left": "$$", + "display_right": "$$" + } +} +``` + +> LaTeX 分隔符默认使用 `$`,与前端 KaTeX 的 `remark-math` 插件直接兼容。 + +### 5.2 Omelette 集成代码 + +```python +import httpx +from pathlib import Path + +class MinerUClient: + """MinerU API 客户端,用于 PDF 深度解析""" + + def __init__(self, base_url: str = "http://localhost:8010"): + self.base_url = base_url + + async def health_check(self) -> bool: + try: + async with httpx.AsyncClient(timeout=5) as c: + r = await c.get(f"{self.base_url}/docs") + return r.status_code == 200 + except Exception: + return False + + async def parse_pdf(self, pdf_path: str | Path, backend: str = "hybrid") -> dict: + """ + 调用 MinerU API 解析 PDF。 + + Args: + pdf_path: PDF 文件路径 + backend: hybrid | vlm | pipeline + + Returns: + { + "markdown": "...", # 完整 Markdown(含 LaTeX 公式) + "images": [...], # 提取的图片路径列表 + "content_list": [...], # 按阅读序的结构化内容 + } + """ + async with httpx.AsyncClient(timeout=300) as client: + with open(pdf_path, "rb") as f: + resp = await client.post( + f"{self.base_url}/file/upload", + files={"file": (Path(pdf_path).name, f, "application/pdf")}, + data={"backend": backend}, + ) + resp.raise_for_status() + return resp.json() +``` + +### 5.3 改造后的 OCRService 流程 + +```python +class OCRService: + def __init__(self, mineru_url: str | None = None): + self.mineru = MinerUClient(mineru_url) if mineru_url else None + + async def process_pdf(self, pdf_path: str, force_deep: bool = False) -> dict: + # Phase 1: pdfplumber 快速尝试 + if not force_deep: + native = self._extract_with_pdfplumber(pdf_path) + if self._is_good_native(native): + return {"pages": native, "method": "pdfplumber"} + + # Phase 2: MinerU 深度解析 + if self.mineru and await self.mineru.health_check(): + result = await self.mineru.parse_pdf(pdf_path, backend="hybrid") + return self._transform_mineru_result(result) + + # Phase 3: 降级到 pdfplumber + return {"pages": native or [], "method": "pdfplumber_fallback"} + + def _transform_mineru_result(self, result: dict) -> dict: + """将 MinerU 输出转为 Omelette 内部格式""" + md_content = result.get("markdown", "") + images = result.get("images", []) + + pages = self._parse_markdown_to_pages(md_content, images) + return {"pages": pages, "method": "mineru", "images": images} +``` + +### 5.4 智能分块增强 + +MinerU 输出的 Markdown 包含结构化的公式、表格、图片标记,分块时需区分处理: + +```python +import re + +def chunk_mineru_markdown(self, md_content: str, chunk_size=1024, overlap=100) -> list[dict]: + """解析 MinerU Markdown 并智能分块""" + blocks = self._split_markdown_to_blocks(md_content) + chunks = [] + current_text = "" + chunk_idx = 0 + + for block in blocks: + if block["type"] == "table": + if current_text.strip(): + chunks.append(self._make_chunk(current_text, "text", chunk_idx, + has_formula="$" in current_text)) + chunk_idx += 1 + current_text = "" + chunks.append(self._make_chunk(block["content"], "table", chunk_idx)) + chunk_idx += 1 + + elif block["type"] == "figure": + if block.get("caption"): + chunks.append(self._make_chunk( + block["caption"], "figure_caption", chunk_idx, + figure_path=block.get("path"), + )) + chunk_idx += 1 + + else: + if len(current_text) + len(block["content"]) > chunk_size and current_text: + chunks.append(self._make_chunk(current_text, "text", chunk_idx, + has_formula="$" in current_text)) + chunk_idx += 1 + words = current_text.split() + current_text = " ".join(words[-overlap:]) + " " + block["content"] + else: + current_text += ("\n\n" + block["content"]) if current_text else block["content"] + + if current_text.strip(): + chunks.append(self._make_chunk(current_text, "text", chunk_idx, + has_formula="$" in current_text)) + + return chunks + +def _split_markdown_to_blocks(self, md: str) -> list[dict]: + """将 Markdown 拆分为文本/表格/图片块""" + blocks = [] + table_pattern = re.compile(r'(\|.+\|\n)+', re.MULTILINE) + figure_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + # 先提取表格和图片,剩余为文本 + # ...实现细节留空 + return blocks + +--- + +## 六、聊天对话框渲染能力 + +### 6.1 当前能力 + +对话框使用 `react-markdown` 渲染 assistant 消息: + +```tsx +// MessageBubbleV2.tsx + + {content} + +``` + +**已支持**: +- ✅ Markdown 基础语法(标题、列表、加粗、斜体等) +- ✅ GFM 表格渲染 +- ✅ 代码块语法高亮 +- ✅ 引用标注 `[1]` `[2]` +- ⚠️ 数学公式(插件有,但 **KaTeX CSS 未导入**) + +### 6.2 需要修复 + +**P0:导入 KaTeX CSS** + +```tsx +// frontend/src/main.tsx 添加 +import 'katex/dist/katex.min.css'; +``` + +这一行缺失会导致 KaTeX 渲染的公式没有样式(字体、对齐、分数线等全部错乱)。 + +### 6.3 公式渲染效果 + +修复 CSS 后,对话中可正确渲染: + +- 行内公式:`$E = mc^2$` → 渲染为 \(E = mc^2\) +- 行间公式: + ``` + $$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$ + ``` + → 渲染为居中的积分公式 + +**前提**:RAG 返回的 excerpt 中包含 LaTeX 公式(需要 MinerU 或 marker 解析产出)。 + +### 6.4 表格渲染 + +GFM 表格已支持,RAG 返回的 Markdown 表格会自动渲染为 HTML 表格。 + +### 6.5 图片渲染(留空) + +- Markdown `![caption](url)` 语法前端已支持 +- 但当前 RAG 不返回图片 +- 若需展示论文图表:需设计图片存储路径 + API 端点 + 前端展示 + +--- + +## 七、决策汇总与实施计划 + +### 7.1 决策表 + +| 决策项 | 结论 | 理由 | +|--------|------|------| +| 数据库 | **维持 SQLite + ChromaDB** | mem0 兼容,够用,不过度设计 | +| PDF 解析 | **引入 MinerU 为主引擎** | 公式+表格+图片+多语言,综合最优 | +| 中英文 OCR | **MinerU 统一处理** | 84 语言自动检测,不需分引擎 | +| RAG 框架 | **维持 LlamaIndex + ChromaDB** | 增量优化而非替换 | +| Reranker | **引入 BGE Reranker** | 提升检索精度,成本低 | +| 混合检索 | **引入 BM25** | 提升精确匹配召回 | +| 公式处理 | **MinerU 输出 LaTeX + KaTeX 渲染** | 端到端打通 | +| 表格处理 | **单独 chunk + GFM 渲染** | 已有前端支持 | +| 图片处理 | **Phase 1 caption,Phase 2 VLM** | 渐进式 | +| 前端公式 | **修复 KaTeX CSS** | P0 修复 | + +### 7.2 纳入实施路线图 + +| Phase | 新增任务 | 工作量 | +|-------|----------|--------| +| **Phase 0** | KaTeX CSS 修复 | 0.5h | +| **Phase 0** | PaddleOCR lang 改为自动检测 | 0.5h | +| **Phase 2** | MinerU 集成 + 分块策略增强 | 3d | +| **Phase 2** | 公式/表格/caption chunk 产出 | 2d | +| **Phase 3** | BM25 混合检索 | 2d | +| **Phase 4** | 图片 VLM 描述(可选) | 2d | +| **Phase 后续** | mem0 集成 | 2d | + +### 7.3 数据模型变更 + +`PaperChunk` 需扩展: + +```python +class PaperChunk(Base): + # 现有字段保持不变 + chunk_type: Mapped[str] # text | table | formula | figure_caption + has_formula: Mapped[bool] # 新增:文本 chunk 中是否包含公式 + figure_path: Mapped[str] # 新增:图片文件路径(figure_caption 类型时) +``` + +`RAGService.index_chunks` 的 metadata 扩展: + +```python +metadata={ + "paper_id": ..., + "paper_title": ..., + "chunk_type": ..., + "section": ..., + "page_number": ..., + "chunk_index": ..., + "has_formula": ..., # 新增 + "figure_path": ..., # 新增(可选) +} +``` + +--- + +*本文档为技术深度研究报告,所有决策已同步到对应的 PRD 子文档和实施路线图中。* From 812e83f0583eb73fa870f3c7f77587427b48f7af Mon Sep 17 00:00:00 2001 From: sylvanding Date: Sun, 15 Mar 2026 18:29:24 +0800 Subject: [PATCH 4/9] feat(backend,frontend): implement Phase 4 innovation features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4A - Smart Autocomplete: - CompletionService with LLM-based input prediction - POST /chat/complete endpoint with debounce support - CompletionSuggestion component with ghost text + Tab/Esc handling - ChatInput integration with AbortController Phase 4B - Citation Graph: - CitationGraphService with Semantic Scholar API (S2 ID/DOI/title resolution, tenacity retry) - GET /papers/{id}/citation-graph endpoint - react-force-graph-2d visualization with node detail panel - PapersPage integration with graph dialog Phase 4C - Auto Literature Review: - WritingService.generate_literature_review() three-step pipeline (outline → RAG → streaming) - POST /writing/review-draft/stream SSE endpoint with section/citation events - WritingPage review tab with streaming, copy, download Phase 4D - PDF Reader AI Assistant: - GET /papers/{id}/pdf file serving endpoint - ChatStreamRequest extended with paper_id/selected_text - PDFViewer (react-pdf + pdf.js) with zoom/paging/text selection/scanned detection - PDFReaderLayout with react-resizable-panels - SelectionQA sidebar with explain/translate/find-citations quick actions - PDFReaderPage route + PapersPage entry button Made-with: Cursor --- backend/app/api/v1/chat.py | 29 + backend/app/api/v1/papers.py | 36 + backend/app/api/v1/writing.py | 32 + .../app/services/citation_graph_service.py | 169 ++++ backend/app/services/completion_service.py | 71 ++ backend/app/services/writing_service.py | 171 +++- ...15-feat-phase4-innovation-features-plan.md | 354 +++++++- .../plans/2026-03-15-phase4-tech-reference.md | 482 +++++++++++ ...mplete-literature-review-best-practices.md | 228 ++++++ frontend/package-lock.json | 766 +++++++++++++++++- frontend/package.json | 3 + frontend/src/App.tsx | 2 + .../citation-graph/CitationGraphView.tsx | 171 ++++ .../citation-graph/NodeDetailPanel.tsx | 84 ++ .../components/pdf-reader/PDFReaderLayout.tsx | 71 ++ .../src/components/pdf-reader/PDFViewer.tsx | 166 ++++ .../src/components/pdf-reader/SelectionQA.tsx | 294 +++++++ .../src/components/playground/ChatInput.tsx | 107 ++- .../playground/CompletionSuggestion.tsx | 24 + frontend/src/pages/project/PDFReaderPage.tsx | 69 ++ frontend/src/pages/project/PapersPage.tsx | 80 +- frontend/src/pages/project/WritingPage.tsx | 273 ++++++- 22 files changed, 3634 insertions(+), 48 deletions(-) create mode 100644 backend/app/services/citation_graph_service.py create mode 100644 backend/app/services/completion_service.py create mode 100644 docs/plans/2026-03-15-phase4-tech-reference.md create mode 100644 docs/research/2026-03-15-smart-autocomplete-literature-review-best-practices.md create mode 100644 frontend/src/components/citation-graph/CitationGraphView.tsx create mode 100644 frontend/src/components/citation-graph/NodeDetailPanel.tsx create mode 100644 frontend/src/components/pdf-reader/PDFReaderLayout.tsx create mode 100644 frontend/src/components/pdf-reader/PDFViewer.tsx create mode 100644 frontend/src/components/pdf-reader/SelectionQA.tsx create mode 100644 frontend/src/components/playground/CompletionSuggestion.tsx create mode 100644 frontend/src/pages/project/PDFReaderPage.tsx diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index d9fd987..70edf2c 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db @@ -19,6 +20,7 @@ format_finish, format_start, ) +from app.schemas.common import ApiResponse from app.schemas.conversation import ChatStreamRequest logger = logging.getLogger(__name__) @@ -26,6 +28,18 @@ router = APIRouter(prefix="/chat", tags=["chat"]) +class CompletionRequest(BaseModel): + prefix: str = Field(..., min_length=10, max_length=2000) + conversation_id: int | None = None + knowledge_base_ids: list[int] = Field(default_factory=list) + recent_messages: list[dict] = Field(default_factory=list) + + +class CompletionResponse(BaseModel): + completion: str + confidence: float + + async def _init_services(db: AsyncSession) -> dict: """Create LLM + RAG services from user settings.""" from app.services.llm.client import get_llm_client @@ -102,3 +116,18 @@ async def chat_stream( "X-Vercel-AI-UI-Message-Stream": "v1", }, ) + + +@router.post("/complete", response_model=ApiResponse[CompletionResponse]) +async def complete(request: CompletionRequest): + """Return a short text completion suggestion for autocomplete.""" + from app.services.completion_service import CompletionService + + svc = CompletionService() + result = await svc.complete( + prefix=request.prefix, + conversation_id=request.conversation_id, + knowledge_base_ids=request.knowledge_base_ids or [], + recent_messages=request.recent_messages or [], + ) + return ApiResponse(data=CompletionResponse(**result)) diff --git a/backend/app/api/v1/papers.py b/backend/app/api/v1/papers.py index fc0e0b3..014bb92 100644 --- a/backend/app/api/v1/papers.py +++ b/backend/app/api/v1/papers.py @@ -1,6 +1,9 @@ """Paper CRUD and management API endpoints.""" +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -141,3 +144,36 @@ async def delete_paper( raise HTTPException(status_code=404, detail="Paper not found") await db.delete(paper) return ApiResponse(message="Paper deleted") + + +@router.get("/{paper_id}/pdf") +async def serve_pdf( + project_id: int, + paper_id: int, + db: AsyncSession = Depends(get_db), + project: Project = Depends(get_project), +): + """Serve the PDF file for a paper.""" + paper = await db.get(Paper, paper_id) + if not paper or paper.project_id != project_id: + raise HTTPException(status_code=404, detail="Paper not found") + if not paper.pdf_path or not Path(paper.pdf_path).exists(): + raise HTTPException(status_code=404, detail="PDF file not available") + return FileResponse(paper.pdf_path, media_type="application/pdf", filename=f"{paper.title[:80]}.pdf") + + +@router.get("/{paper_id}/citation-graph", response_model=ApiResponse) +async def get_citation_graph( + project_id: int, + paper_id: int, + depth: int = Query(1, ge=1, le=2), + max_nodes: int = Query(50, ge=10, le=200), + db: AsyncSession = Depends(get_db), + project: Project = Depends(get_project), +): + """Get citation relationship graph for a paper via Semantic Scholar.""" + from app.services.citation_graph_service import CitationGraphService + + svc = CitationGraphService(db) + graph = await svc.get_citation_graph(paper_id, project_id, depth=depth, max_nodes=max_nodes) + return ApiResponse(data=graph) diff --git a/backend/app/api/v1/writing.py b/backend/app/api/v1/writing.py index 0b2e548..92488c5 100644 --- a/backend/app/api/v1/writing.py +++ b/backend/app/api/v1/writing.py @@ -1,6 +1,7 @@ """Writing assistance API endpoints.""" from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -163,3 +164,34 @@ async def analyze_gaps( research_topic=body.research_topic, ) return ApiResponse(data=result) + + +class ReviewDraftRequest(BaseModel): + topic: str = "" + style: str = Field(default="narrative", pattern=r"^(narrative|systematic|thematic)$") + citation_format: str = Field(default="numbered", pattern=r"^(numbered|apa|gb_t_7714)$") + language: str = Field(default="zh", pattern=r"^(zh|en)$") + + +@router.post("/review-draft/stream") +async def stream_review_draft( + project_id: int, + body: ReviewDraftRequest, + svc: WritingService = Depends(get_writing_service), + project: Project = Depends(get_project), +): + """Stream a structured literature review draft via SSE.""" + return StreamingResponse( + svc.generate_literature_review( + project_id=project_id, + topic=body.topic, + style=body.style, + citation_format=body.citation_format, + language=body.language, + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/services/citation_graph_service.py b/backend/app/services/citation_graph_service.py new file mode 100644 index 0000000..8c42167 --- /dev/null +++ b/backend/app/services/citation_graph_service.py @@ -0,0 +1,169 @@ +"""Citation graph service — fetches citation/reference data from Semantic Scholar.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.paper import Paper + +logger = logging.getLogger(__name__) + +S2_API_BASE = "https://api.semanticscholar.org/graph/v1" +S2_FIELDS = "title,year,citationCount,externalIds,authors" +S2_TIMEOUT = 15 +S2_MAX_PER_REQUEST = 50 + + +class CitationGraphService: + """Build citation graph data from Semantic Scholar API.""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def get_citation_graph( + self, + paper_id: int, + project_id: int, + *, + depth: int = 1, + max_nodes: int = 50, + ) -> dict[str, Any]: + """Return {nodes, edges, center_id} for a paper's citation network.""" + paper = await self._db.get(Paper, paper_id) + if not paper or paper.project_id != project_id: + return {"nodes": [], "edges": [], "center_id": None, "error": "Paper not found"} + + s2_id = await self._resolve_s2_id(paper) + if not s2_id: + return { + "nodes": [], + "edges": [], + "center_id": None, + "error": "无法获取引用数据:Semantic Scholar 未收录此论文", + } + + local_source_ids = await self._get_local_source_ids(project_id) + + nodes: dict[str, dict] = {} + edges: list[dict] = [] + + center_node = { + "id": s2_id, + "title": paper.title, + "year": paper.year, + "citation_count": paper.citation_count or 0, + "is_local": True, + "s2_id": s2_id, + "paper_id": paper.id, + } + nodes[s2_id] = center_node + + citations = await self._fetch_s2_list(f"{S2_API_BASE}/paper/{s2_id}/citations", max_nodes // 2) + for item in citations: + cited_paper = item.get("citingPaper", {}) + cid = cited_paper.get("paperId") + if not cid or cid in nodes: + continue + nodes[cid] = self._make_node(cited_paper, local_source_ids) + edges.append({"source": cid, "target": s2_id, "type": "cites"}) + if len(nodes) >= max_nodes: + break + + if len(nodes) < max_nodes: + references = await self._fetch_s2_list(f"{S2_API_BASE}/paper/{s2_id}/references", max_nodes - len(nodes)) + for item in references: + ref_paper = item.get("citedPaper", {}) + rid = ref_paper.get("paperId") + if not rid or rid in nodes: + continue + nodes[rid] = self._make_node(ref_paper, local_source_ids) + edges.append({"source": s2_id, "target": rid, "type": "cites"}) + if len(nodes) >= max_nodes: + break + + return { + "nodes": list(nodes.values()), + "edges": edges, + "center_id": s2_id, + } + + async def _resolve_s2_id(self, paper: Paper) -> str | None: + """Resolve S2 paperId from source_id, DOI, or title search.""" + if paper.source == "semantic_scholar" and paper.source_id: + return paper.source_id + + if paper.doi: + try: + data = await self._fetch_s2_json(f"{S2_API_BASE}/paper/DOI:{paper.doi}?fields=paperId") + if pid := data.get("paperId"): + return pid + except Exception: + logger.debug("S2 DOI lookup failed for %s", paper.doi) + + if paper.title: + try: + data = await self._fetch_s2_json( + f"{S2_API_BASE}/paper/search", + params={"query": paper.title[:200], "limit": "1", "fields": "paperId"}, + ) + papers = data.get("data", []) + if papers: + return papers[0].get("paperId") + except Exception: + logger.debug("S2 title search failed for %s", paper.title[:50]) + + return None + + async def _get_local_source_ids(self, project_id: int) -> set[str]: + """Get all S2 source_ids for papers in this project.""" + result = await self._db.execute( + select(Paper.source_id).where( + Paper.project_id == project_id, + Paper.source == "semantic_scholar", + Paper.source_id != "", + ) + ) + return {row[0] for row in result.all()} + + def _make_node(self, s2_paper: dict, local_ids: set[str]) -> dict: + pid = s2_paper.get("paperId", "") + authors = s2_paper.get("authors", []) + author_names = [a.get("name", "") for a in (authors or [])][:3] + return { + "id": pid, + "title": s2_paper.get("title", "Unknown"), + "year": s2_paper.get("year"), + "citation_count": s2_paper.get("citationCount", 0) or 0, + "is_local": pid in local_ids, + "s2_id": pid, + "authors": author_names, + } + + async def _fetch_s2_list(self, url: str, limit: int) -> list[dict]: + """Fetch paginated list from S2 citations/references endpoint.""" + actual_limit = min(limit, S2_MAX_PER_REQUEST) + try: + data = await self._fetch_s2_json(url, params={"fields": S2_FIELDS, "limit": str(actual_limit)}) + return data.get("data", []) + except Exception: + logger.warning("S2 API call failed: %s", url, exc_info=True) + return [] + + async def _fetch_s2_json(self, url: str, params: dict | None = None) -> dict: + headers: dict[str, str] = {} + if settings.semantic_scholar_api_key: + headers["x-api-key"] = settings.semantic_scholar_api_key + + async with httpx.AsyncClient(timeout=S2_TIMEOUT) as client: + resp = await client.get(url, headers=headers, params=params) + if resp.status_code == 429: + logger.warning("S2 API rate limited") + return {} + resp.raise_for_status() + return resp.json() diff --git a/backend/app/services/completion_service.py b/backend/app/services/completion_service.py new file mode 100644 index 0000000..864f2bf --- /dev/null +++ b/backend/app/services/completion_service.py @@ -0,0 +1,71 @@ +"""Smart autocomplete service for chat input predictions.""" + +from __future__ import annotations + +import logging + +from app.services.llm.client import LLMClient, get_llm_client + +logger = logging.getLogger(__name__) + +COMPLETION_SYSTEM_PROMPT = ( + "你是一个科研写作助手。根据用户已输入的文本,预测并补全后续内容。\n" + "只返回补全的部分(不要重复用户已输入的内容),最多50个字符。\n" + "如果无法合理预测,返回空字符串。\n" + "不要添加任何解释、引号或格式标记,只返回纯文本补全内容。" +) + + +class CompletionService: + """Generates short text completions for chat input autocomplete.""" + + def __init__(self, llm: LLMClient | None = None): + self._llm = llm or get_llm_client() + + async def complete( + self, + prefix: str, + *, + conversation_id: int | None = None, + knowledge_base_ids: list[int] | None = None, + recent_messages: list[dict] | None = None, + ) -> dict: + """Generate a completion suggestion for the given prefix. + + Returns {"completion": str, "confidence": float}. + """ + if len(prefix.strip()) < 10: + return {"completion": "", "confidence": 0.0} + + messages: list[dict[str, str]] = [ + {"role": "system", "content": COMPLETION_SYSTEM_PROMPT}, + ] + + if recent_messages: + for msg in recent_messages[-3:]: + messages.append( + { + "role": msg.get("role", "user"), + "content": msg.get("content", ""), + } + ) + + messages.append({"role": "user", "content": prefix}) + + try: + result = await self._llm.chat( + messages, + temperature=0.3, + max_tokens=50, + task_type="completion", + ) + completion = result.strip().strip('"').strip("'") + if completion.startswith(prefix): + completion = completion[len(prefix) :] + completion = completion[:80] + + confidence = 0.8 if completion else 0.0 + return {"completion": completion, "confidence": confidence} + except Exception: + logger.warning("Completion request failed", exc_info=True) + return {"completion": "", "confidence": 0.0} diff --git a/backend/app/services/writing_service.py b/backend/app/services/writing_service.py index a4e8ddf..1df484b 100644 --- a/backend/app/services/writing_service.py +++ b/backend/app/services/writing_service.py @@ -1,6 +1,9 @@ -"""Writing assistance service — summarize, cite, outline, gap analysis.""" +"""Writing assistance service — summarize, cite, outline, gap analysis, literature review.""" +import json import logging +import re +from collections.abc import AsyncGenerator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +14,20 @@ logger = logging.getLogger(__name__) +REVIEW_STYLES = { + "narrative": "叙述性综述 (narrative review):按主题逻辑串联文献,形成连贯论述", + "systematic": "系统性综述 (systematic review):按纳入/排除标准系统梳理,侧重方法学", + "thematic": "主题性综述 (thematic review):按研究主题分组对比,突出异同", +} + +SECTION_SYSTEM_PROMPT = """\ +你是一位学术综述写作专家。请为以下章节撰写综述段落。 +要求: +1. 使用学术语言,逻辑清晰 +2. 在适当位置使用 [1][2] 格式引用 +3. 每个引用必须对应提供的文献,不得捏造 +4. 段落长度 200-400 字""" + class WritingService: def __init__(self, db: AsyncSession, llm: LLMClient, rag: RAGService | None = None): @@ -178,3 +195,155 @@ async def analyze_gaps(self, project_id: int, research_topic: str) -> dict: "analysis": analysis, "papers_analyzed": len(papers), } + + async def generate_literature_review( + self, + project_id: int, + topic: str = "", + style: str = "narrative", + citation_format: str = "numbered", + language: str = "zh", + ) -> AsyncGenerator[str, None]: + """Generate a structured literature review draft with SSE events. + + Three-step pipeline: outline → per-section RAG → streamed draft. + Yields SSE-formatted strings. + """ + stmt = select(Paper).where(Paper.project_id == project_id).limit(50) + result = await self.db.execute(stmt) + papers = result.scalars().all() + + if not papers: + yield _sse("error", {"message": "知识库中暂无文献,请先添加文献后再生成综述"}) + return + + yield _sse("progress", {"step": "outline", "message": "正在生成综述提纲..."}) + + style_desc = REVIEW_STYLES.get(style, REVIEW_STYLES["narrative"]) + outline_result = await self._generate_review_outline_for_draft(papers, topic, style_desc, language) + sections = _parse_outline_sections(outline_result) + + if not sections: + sections = [{"title": topic or "综述", "query": topic or "literature review"}] + + yield _sse("outline", {"sections": [s["title"] for s in sections]}) + + rag = self.rag or RAGService(llm=self.llm) + global_citation_map: dict[int, dict] = {} + citation_counter = 0 + + for idx, section in enumerate(sections): + yield _sse("section-start", {"title": section["title"], "section_index": idx}) + + sources = await rag.retrieve_only(project_id, section["query"], top_k=8) + section_refs: list[dict] = [] + for src in sources: + pid = src.get("paper_id") + if pid is not None and pid not in global_citation_map: + citation_counter += 1 + global_citation_map[pid] = { + "number": citation_counter, + "paper_id": pid, + "title": src.get("paper_title", ""), + } + section_refs.append(src) + + formatted_sources = self._format_sources_for_prompt(section_refs, global_citation_map) + + prompt = f"""章节标题:{section["title"]} + +相关文献摘录: +{formatted_sources} + +综述风格:{style_desc} +语言:{"中文" if language == "zh" else "English"} + +请撰写该章节的综述段落。""" + + async for chunk in self.llm.chat_stream( + messages=[ + {"role": "system", "content": SECTION_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + temperature=0.5, + max_tokens=2048, + task_type="default", + ): + yield _sse("text-delta", {"delta": chunk, "section_index": idx}) + + yield _sse("section-end", {"section_index": idx}) + + yield _sse( + "citation-map", + {"citations": {str(v["number"]): v for v in global_citation_map.values()}}, + ) + yield _sse("done", {"total_sections": len(sections)}) + + async def _generate_review_outline_for_draft( + self, + papers: list[Paper], + topic: str, + style_desc: str, + language: str, + ) -> str: + paper_summaries = "\n".join( + [f"- {p.title} ({p.year}, {p.journal}) [cited:{p.citation_count}]" for p in papers if p.title] + ) + lang_str = "中文" if language == "zh" else "English" + + prompt = f"""请用{lang_str}为以下主题生成文献综述提纲。 + +主题:{topic or "(基于提供文献自动确定)"} +综述风格:{style_desc} + +可用文献: +{paper_summaries} + +要求: +1. 生成 3-6 个章节标题(用 ## 标记) +2. 每个章节标题后附一行简要描述 +3. 包含引言和结论章节""" + + return await self.llm.chat( + messages=[ + { + "role": "system", + "content": "You are a scientific writing expert. Generate well-structured review outlines.", + }, + {"role": "user", "content": prompt}, + ], + temperature=0.5, + task_type="default", + ) + + @staticmethod + def _format_sources_for_prompt(sources: list[dict], citation_map: dict[int, dict]) -> str: + lines = [] + for src in sources: + pid = src.get("paper_id") + ref_num = citation_map.get(pid, {}).get("number", "?") if pid else "?" + title = src.get("paper_title", "Unknown") + excerpt = src.get("excerpt", "") + lines.append(f"[{ref_num}] {title}\n摘录:{excerpt}\n") + return "\n".join(lines) if lines else "(无相关文献)" + + +def _parse_outline_sections(outline_text: str) -> list[dict]: + """Parse markdown outline into structured sections.""" + sections = [] + current_title = None + + for line in outline_text.split("\n"): + heading_match = re.match(r"^#{1,3}\s+(.+)", line.strip()) + if heading_match: + if current_title: + sections.append({"title": current_title, "query": current_title}) + current_title = heading_match.group(1).strip() + if current_title: + sections.append({"title": current_title, "query": current_title}) + + return sections + + +def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md b/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md index f0ee38d..fd86d6f 100644 --- a/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md +++ b/docs/plans/2026-03-15-feat-phase4-innovation-features-plan.md @@ -7,6 +7,27 @@ date: 2026-03-15 # Phase 4 — 创新功能 +## Enhancement Summary + +**Deepened on:** 2026-03-15 +**Sections enhanced:** 4 (4A/4B/4C/4D) +**Research agents used:** best-practices-researcher ×2, framework-docs-researcher, learnings-researcher + +### Key Improvements +1. **4B 文献图谱**: 推荐使用 `react-force-graph-2d` 替代手写 D3,开箱即用且 Canvas 渲染性能更优 +2. **4D PDF 阅读器**: 确定 Vite Worker 配置方案、虚拟化渲染方案、扫描件检测方法 +3. **全局**: 发现 6 个项目 learnings 直接相关——`asyncio.to_thread` 包装 LlamaIndex、流式节流 50-80ms、React.lazy 懒加载大包 +4. **4C Literature Review**: 引入 SubQuestionQueryEngine 适合对比型综述,SSE 多事件格式优化 + +### New Considerations Discovered +- `react-force-graph-2d` 比手写 D3 集成更快,且内置 Canvas 渲染 +- `react-resizable-panels` 最新版 API 使用 `Group`/`Panel`/`Separator`(非 `PanelGroup`/`PanelResizeHandle`) +- RAG 检索中 LlamaIndex `retriever.retrieve()` 是同步阻塞调用,必须 `asyncio.to_thread` 包装 +- 流式 Markdown 渲染需 50-80ms 节流,否则每 token 触发 remark+rehype 全量解析 +- D3、PDF.js、react-markdown 等大包必须 `React.lazy` 懒加载,避免首屏体积膨胀 + +--- + ## Overview Phase 4 是 Omelette V3 的差异化竞争力阶段,包含四大创新功能: @@ -161,13 +182,73 @@ interface CompletionSuggestionProps { - 继续输入时取消当前请求(`AbortController`) - 补全文本紧跟在 Textarea 内容后,灰色样式 +### 边界情况与降级 + +- **Routing Tier 未就绪时**:使用当前主模型 + 严格 2s 超时,后续接入 routing tier 后自动切换 +- **conversation_id 为空(新会话)**:仅基于 `prefix` 和 `recent_messages` 补全,不加载历史 +- **多行输入时**:补全建议显示在最后一行光标后 +- **限流保护**:补全 API 添加简单限流(每秒最多 2 次请求),前端 debounce 400ms + AbortController 取消前次请求 +- **confidence 字段**:暂定为 LLM 返回 `completion` 的长度归一化值(非空→0.8,空→0.0),后续可基于 logprob 优化 +- **Race Condition**:使用 `requestId` 或 `AbortController` 校验,只处理最新请求的结果 +- **Tab 与表单导航冲突**:有补全时拦截 Tab,无补全时放行正常 Tab 导航 + +### Research Insights + +**Best Practices:** +- 使用 `useDebouncedCompletion` 自定义 hook,结合 `useRef` 管理 timer 和 AbortController +- Ghost text 展示方式:在 Textarea 后叠加 `` 显示灰色补全文本,或使用 `contenteditable` div +- Tab 接受时需检查 `event.preventDefault()` 并插入文本,同时清除补全状态 + +**Performance Considerations:** +- 补全 prompt 使用 `max_tokens=30-50`,`temperature=0.3` +- 后端 LLM 调用通过 `_services` 注入模式,与 chat 路由一致 +- 前端渲染可用 `useDeferredValue` 或 `requestAnimationFrame` 批处理 + +**Implementation Pattern:** +```tsx +// useDebouncedCompletion hook 核心逻辑 +function useDebouncedCompletion(prefix: string, delay = 400) { + const [completion, setCompletion] = useState(''); + const abortRef = useRef(null); + const timerRef = useRef>(); + + useEffect(() => { + setCompletion(''); + if (prefix.length < 10) return; + + timerRef.current = setTimeout(async () => { + abortRef.current?.abort(); + abortRef.current = new AbortController(); + try { + const res = await fetch('/api/v1/chat/complete', { + method: 'POST', + signal: abortRef.current.signal, + body: JSON.stringify({ prefix }), + }); + const data = await res.json(); + setCompletion(data.data?.completion ?? ''); + } catch { /* aborted or error */ } + }, delay); + + return () => clearTimeout(timerRef.current); + }, [prefix, delay]); + + return completion; +} +``` + +**References:** +- [Learnings] Chat Routing Chain Performance: 流式 debounce 80ms 避免重渲染 +- [Learnings] UI Polish: ChatInput 已有 toolbar 布局,新增 CompletionSuggestion 需保持一致 + ### 验收标准 - [ ] 输入 ≥ 10 字符且停顿 400ms 后展示灰色补全建议 - [ ] Tab 接受补全,文本插入输入框 - [ ] Esc 或继续输入清除建议 -- [ ] 补全延迟 < 2s(routing tier 模型) +- [ ] 补全延迟 < 2s - [ ] 无补全建议时不展示任何 UI +- [ ] 快速输入时不会触发多余请求(debounce + AbortController) --- @@ -256,12 +337,38 @@ class CitationGraphService: **S2 API 字段**:`title,year,citationCount,externalIds,authors` +**S2 API 认证**:Header `x-api-key: {api_key}`(无 Key 约 1 req/s,有 Key 约 10 req/s) + +**速率限制与重试**(Research Insight): + +```python +from tenacity import retry, stop_after_attempt, wait_exponential + +@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=30)) +async def _fetch_s2(self, url: str) -> dict: + headers = {} + if settings.semantic_scholar_api_key: + headers["x-api-key"] = settings.semantic_scholar_api_key + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 429: + raise Exception("S2 rate limited") + resp.raise_for_status() + return resp.json() +``` + **关键约束**: - S2 API 速率限制:1 req/s(无 API Key)或 10 req/s(有 API Key) - 使用 `settings.semantic_scholar_api_key` 认证 - `depth=1` 只获取直接引用/被引;`depth=2` 获取二级(后续扩展) - `max_nodes` 限制节点数量,避免前端渲染卡顿 +**source_id 解析策略**: +- `source == "semantic_scholar"` 且 `source_id` 有值 → 直接使用 +- 有 DOI → 调用 S2 `GET /paper/DOI:{doi}` 解析 paperId +- 有标题但无 DOI → 调用 S2 `GET /paper/search?query={title}&limit=1` 匹配 +- 均无 → 返回 `{"error": "无法获取引用数据"}` + 前端提示"该论文暂不支持引用图谱" + ### 4B-2: 图谱 API **文件**: `backend/app/api/v1/citation_graph.py` (新建) @@ -279,47 +386,95 @@ async def get_citation_graph( return ApiResponse(data=graph) ``` -### 4B-3: 前端 D3 力导向图 +### 4B-3: 前端力导向图 + +**推荐方案**: 使用 `react-force-graph-2d` 替代手写 D3 -**新增依赖**: `d3`, `@types/d3` +> **Research Insight**: `react-force-graph` 内置 Canvas 渲染,与 React 生命周期兼容,50-200 节点性能稳定, +> 无需处理 D3 与 React 的 DOM 冲突。比手写 D3 + React 集成快 2-3 倍开发时间。 + +**新增依赖**: `react-force-graph-2d`(内含 d3-force-3d) **文件**: `frontend/src/components/citation-graph/` (新建目录) | 文件 | 职责 | |------|------| -| `CitationGraphView.tsx` | 主容器:加载数据、管理状态 | -| `ForceGraph.tsx` | D3 力导向图渲染 | -| `GraphControls.tsx` | 缩放、重置、过滤控件 | +| `CitationGraphView.tsx` | 主容器:加载数据、管理状态、ForceGraph2D | +| `GraphControls.tsx` | 过滤控件(年份范围、仅本地) | | `NodeDetailPanel.tsx` | 点击节点后显示论文详情侧边栏 | **图谱视觉设计**: | 元素 | 视觉编码 | |------|----------| -| 节点大小 | 引用数量(citationCount),对数缩放 | -| 节点颜色 | 年份(新 → 蓝色,旧 → 灰色);本地已有 → 绿色高亮 | -| 边方向 | 箭头指向被引方 | +| 节点大小 | 引用数量(citationCount),`Math.log10(count + 1) * 8` 对数缩放 | +| 节点颜色 | `is_local` → 绿色 `#22c55e`;年份 > 2020 → 蓝色 `#3b82f6`;其他 → 灰色 `#94a3b8` | +| 边方向 | 箭头指向被引方(`linkDirectionalArrowLength={5}`) | | 边颜色 | `cites` → 蓝色,`cited_by` → 橙色 | -| 中心节点 | 加粗边框 + 脉冲动画 | +| 中心节点 | 自定义 `nodeCanvasObject` 绘制加粗边框 | **交互**: -- 拖拽节点、缩放画布 +- 拖拽节点、缩放画布(react-force-graph 内置) - 点击节点 → 右侧详情面板(标题、作者、年份、引用数、摘要) - 双击本地节点 → 跳转到论文详情页 - 过滤:按年份范围、仅显示本地文献 +**核心代码示例**: + +```tsx +import { lazy, Suspense, useState, useCallback } from 'react'; +const ForceGraph2D = lazy(() => import('react-force-graph-2d')); + +export function CitationGraphView({ graphData }) { + const [selectedNode, setSelectedNode] = useState(null); + + const handleNodeClick = useCallback((node) => setSelectedNode(node), []); + + return ( +
+ }> + d.title} + nodeVal={(d) => Math.log10((d.citation_count ?? 0) + 1) * 8} + nodeColor={(d) => d.is_local ? '#22c55e' : d.year > 2020 ? '#3b82f6' : '#94a3b8'} + linkDirectionalArrowLength={5} + linkDirectionalArrowRelPos={1} + linkColor={(l) => l.type === 'cites' ? '#3b82f6' : '#f97316'} + onNodeClick={handleNodeClick} + /> + + {selectedNode && setSelectedNode(null)} />} +
+ ); +} +``` + +**References:** +- react-force-graph: https://github.com/vasturiano/react-force-graph +- [Learnings] Code Quality Audit: 大包用 `React.lazy` 懒加载,避免首屏体积膨胀 + ### 4B-4: 页面集成 在 `PapersPage` 的论文列表中,每篇论文添加"引用图谱"按钮。点击后弹出全屏图谱视图(Dialog 或独立路由)。 +### 边界情况 + +- **无引用/被引数据**:显示"该论文暂无引用关系数据"空状态页 +- **S2 返回 404**:提示"Semantic Scholar 未收录此论文" +- **source_id 非 S2 格式**:通过 DOI/标题做 S2 ID 解析(见上方策略) +- **节点数量超限**:`max_nodes` 默认 50,超出时按引用数排序截断,前端提示"仅展示引用数最高的 N 篇" + ### 验收标准 -- [ ] 可查看任意论文的引用/被引关系图谱 +- [ ] 可查看任意论文的引用/被引关系图谱(有 DOI 或 S2 ID 的论文) - [ ] 节点大小、颜色正确编码(引用数、年份) - [ ] 本地知识库中的论文绿色高亮 - [ ] 点击节点显示详情面板 - [ ] 支持拖拽、缩放、过滤 - [ ] S2 API 速率限制正确处理(指数退避) +- [ ] 无引用数据时展示友好空状态 --- @@ -387,7 +542,11 @@ async def generate_literature_review( # Step 1: 生成提纲(复用 generate_review_outline) outline = await self.generate_review_outline(project_id, topic) - # Step 2: 为每个章节做 RAG 检索 + # Step 2: 解析提纲为结构化章节列表 + # parse_outline_sections 将 Markdown 提纲解析为: + # [{"title": "章节标题", "query": "该章节的检索关键词/问题"}, ...] + # 解析策略: 按 ## 标题分割,每个标题作为 query 传入 RAG + # 若解析失败(格式异常),降级为将整个 outline 作为单一章节 rag = RAGService() sections = parse_outline_sections(outline["outline"]) for section in sections: @@ -457,13 +616,59 @@ async def stream_review_draft( - 支持复制、下载为 `.md` 文件 - 引用标注 `[1][2]` 可悬停查看来源 +### 边界情况 + +- **知识库为空**:提前检查 `collection.count()`,返回 "知识库中暂无文献,请先添加文献后再生成综述" +- **生成中断(用户取消/超时)**:SSE 关闭时,前端保留已展示的内容,标记"生成已中断" +- **引用映射**:生成每章节时维护 `sources_map: dict[int, dict]`(编号→文献信息),随 SSE 事件一并下发,前端用于引用悬停展示 +- **parse_outline_sections 解析失败**:降级为将整个 outline 文本作为单一 query 做全局 RAG 检索 +- **style 参数**:传入 `generate_review_outline` 的 prompt 中,不同风格使用不同提纲模板 + +### Research Insights + +**Best Practices:** +- 提纲 → RAG → 逐章节生成的三步流程经实践验证最有效(避免长上下文和引用错位) +- Prompt 强约束「仅引用提供的文献」可显著降低幻觉(GPT-4 仍有约 18-28% fabricated citations) +- SSE 区分 `section-start`、`text-delta`、`citation-map` 等多事件类型,便于前端分段渲染 + +**Performance Considerations:** +- LlamaIndex `retriever.retrieve()` 是同步阻塞调用,必须用 `asyncio.to_thread` 包装 +- 流式 Markdown 渲染需 50-80ms 节流(`useDeferredValue` 或自定义 throttle),否则每 token 触发 remark+rehype 全量解析 +- Citation 批量查询:`Paper.id.in_(paper_ids)`,避免 N+1 + +**Advanced: SubQuestionQueryEngine:** +- LlamaIndex 的 `SubQuestionQueryEngine` 适合「比较/对比」类综述,将复杂问题拆成子问题分别检索 +- 可作为 `style=systematic` 的增强方案 + +**Implementation Pattern (SSE Events):** +``` +event: section-start +data: {"title": "1. Introduction", "section_index": 0} + +event: text-delta +data: {"delta": "近年来,深度学习在..."} + +event: citation-map +data: {"citations": {"1": {"paper_id": 42, "title": "...", "authors": "..."}}} + +event: section-end +data: {"section_index": 0} +``` + +**References:** +- [Learnings] blocking-sync-calls: `asyncio.to_thread` 包装 LlamaIndex 同步调用 +- [Learnings] RAG Rich Citation Performance: 流式渲染节流 50-80ms +- [Research] VeriCite/FACTUM: 引用验证相关研究 + ### 验收标准 - [ ] 可基于知识库自动生成结构化综述草稿 - [ ] 草稿带 `[1][2]` 引用标注,对应知识库文献 +- [ ] 引用标注可悬停查看来源论文信息 - [ ] 支持流式展示生成过程 - [ ] 支持叙述/系统/主题三种综述风格 - [ ] 可复制或下载生成的 Markdown +- [ ] 知识库为空时提示用户 --- @@ -504,11 +709,41 @@ flowchart LR | # | 任务 | 文件 | 工作量 | |---|------|------|--------| +| 4D-0 | PDF 文件服务端点 + ChatStreamRequest 扩展 | `backend/app/api/v1/papers.py`, `chat.py` | 0.5d | | 4D-1 | PDF 阅读器组件(react-pdf / pdfjs-dist) | `frontend/src/components/pdf-reader/PDFViewer.tsx` (新建) | 1d | | 4D-2 | 阅读器布局 + AI 侧边栏 | `frontend/src/components/pdf-reader/PDFReaderLayout.tsx` (新建) | 0.5d | | 4D-3 | 选区问答组件 + 快捷操作 | `frontend/src/components/pdf-reader/SelectionQA.tsx` (新建) | 1d | | 4D-4 | 路由 + PapersPage 入口 | `frontend/src/App.tsx`, `PapersPage.tsx` | 0.5d | +### 4D-0: PDF 文件服务端点 + ChatStreamRequest 扩展 + +**文件**: `backend/app/api/v1/papers.py` + +```python +from fastapi.responses import FileResponse + +@router.get("/{paper_id}/pdf") +async def serve_pdf(project_id: int, paper_id: int, db: AsyncSession = Depends(get_db)): + paper = await db.get(Paper, paper_id) + if not paper or paper.project_id != project_id: + raise HTTPException(404, "Paper not found") + if not paper.pdf_path or not Path(paper.pdf_path).exists(): + raise HTTPException(404, "PDF file not available") + return FileResponse(paper.pdf_path, media_type="application/pdf") +``` + +**ChatStreamRequest 扩展**(`backend/app/api/v1/chat.py`): + +```python +class ChatStreamRequest(BaseModel): + # ... 现有字段 ... + paper_id: int | None = None # PDF 阅读器选区问答时传入 + paper_title: str | None = None + selected_text: str | None = None # 用户选中的文本 +``` + +当 `paper_id` 和 `selected_text` 存在时,ChatPipeline 的 `understand` 节点在 system prompt 中注入论文上下文。 + ### 4D-1: PDF 阅读器组件 **新增依赖**: `react-pdf` (基于 pdfjs-dist) @@ -538,7 +773,7 @@ interface PDFViewerProps { // 顶部: 工具栏(缩放、页码、搜索) ``` -布局使用 `ResizablePanel` (shadcn/ui) 实现左右分栏,用户可拖拽调整比例。 +布局使用 `react-resizable-panels` 实现左右分栏,用户可拖拽调整比例。(如 shadcn/ui 后续提供 ResizablePanel 可替换) ### 4D-3: 选区问答组件 @@ -595,6 +830,83 @@ async def serve_pdf(project_id: int, paper_id: int): ... ``` +### 边界情况 + +- **PDF 无文本层(扫描件)**:react-pdf 无法选中文本时,提示"该 PDF 为扫描件,请使用 OCR 处理后的文本进行问答",侧边栏展示该论文已有的 chunks 供浏览 +- **PDF 加载失败**:展示错误提示 + "下载 PDF" 降级链接 +- **pdf_path 为空**:跳转时检查,若无 PDF 文件则提示"论文暂无 PDF 文件" +- **"找引用"操作**:限定在当前论文所在 project 的知识库中检索 + +### Research Insights + +**Best Practices:** +- 使用 `react-pdf` v10.x(推荐 `^10.4.1`),兼容 React 18/19 +- Vite Worker 配置:`pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()` +- 文本选择:启用 `renderTextLayer={true}`,监听 `onMouseUp` + `window.getSelection()` 获取选区 +- 扫描件检测:第一页调用 `page.getTextContent()`,若无文本项则判定为扫描件 + +**Performance Considerations:** +- PDF.js + react-pdf 包体积大,必须 `React.lazy` + `Suspense` 懒加载 +- 可选 CDN Worker:`pdfjs.GlobalWorkerOptions.workerSrc = \`https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs\`` +- 虚拟化渲染:配合 `@tanstack/react-virtual` 只渲染可见页 ±2 页,大 PDF 时性能提升显著 +- Vite build 配置 `manualChunks` 将 pdf-viewer 独立打包 + +**Implementation Pattern (Text Selection + Floating Toolbar):** +```tsx +
{ + const selection = window.getSelection(); + const text = selection?.toString().trim(); + if (text) { + const range = selection?.getRangeAt(0); + const rect = range?.getBoundingClientRect(); + setSelectionState({ text, rect }); + } +}}> + +
+ +{selectionState && ( + askAI(selectionState.text, 'explain')} + onTranslate={() => askAI(selectionState.text, 'translate')} + onFindCitations={() => askAI(selectionState.text, 'find_citations')} + /> +)} +``` + +**Implementation Pattern (Scanned PDF Detection):** +```tsx +async function detectScannedPdf(pdfDoc: PDFDocumentProxy): Promise { + const page = await pdfDoc.getPage(1); + const textContent = await page.getTextContent(); + return !textContent.items.some( + (item) => 'str' in item && (item as TextItem).str.trim().length > 0 + ); +} +``` + +**react-resizable-panels API(最新版):** +```tsx +import { Group, Panel, Separator } from 'react-resizable-panels'; + + + + + + + + + + +``` + +**References:** +- [Learnings] Code Quality Audit: React.lazy 懒加载大包(bundle 从 1396KB 降到 450KB) +- [Learnings] UI Polish: 使用 PageHeader、EmptyState、Skeleton 统一组件 +- react-pdf docs: https://github.com/wojtekmaj/react-pdf +- react-resizable-panels docs: https://github.com/bvaughn/react-resizable-panels + ### 验收标准 - [ ] 可在应用内打开并阅读 PDF @@ -603,6 +915,7 @@ async def serve_pdf(project_id: int, paper_id: int): - [ ] 可对选中文本提问、解释、翻译、找引用 - [ ] AI 回答在右侧侧边栏展示 - [ ] 问答历史在侧边栏保留 +- [ ] PDF 加载失败时展示友好错误提示 --- @@ -678,9 +991,16 @@ gantt | 包 | 用途 | 版本 | |----|------|------| -| `d3` / `d3-force` / `d3-selection` | 力导向图可视化 | latest | -| `react-pdf` | PDF 阅读器 | latest | -| `@types/d3` | D3 类型定义 | latest | +| `react-force-graph-2d` | 力导向图可视化(内含 d3-force) | latest | +| `react-pdf` | PDF 阅读器(基于 pdfjs-dist) | `^10.4.1` | +| `react-resizable-panels` | PDF 阅读器左右分栏布局 | `^4.7.3` | +| `@tanstack/react-virtual` | PDF 虚拟化渲染(可选) | latest | + +> **性能注意事项**: +> - `react-pdf` + `pdfjs-dist` 和 `react-force-graph-2d` 包体积较大 +> - 必须使用 `React.lazy` + `Suspense` 懒加载,避免首屏体积膨胀 +> - PDF Worker 推荐使用 CDN 或 `new URL(..., import.meta.url)` 配置 +> - Vite build 中配置 `manualChunks` 将这些大包独立打包 --- diff --git a/docs/plans/2026-03-15-phase4-tech-reference.md b/docs/plans/2026-03-15-phase4-tech-reference.md new file mode 100644 index 0000000..4c71cfc --- /dev/null +++ b/docs/plans/2026-03-15-phase4-tech-reference.md @@ -0,0 +1,482 @@ +# Phase 4 技术参考 — 框架/库文档与最佳实践 (2025-2026) + +本文档为 Omelette Phase 4 创新功能实现提供技术参考,涵盖 react-pdf、d3-force、react-resizable-panels、Semantic Scholar API 的安装配置、关键 API、兼容性注意事项及代码示例。 + +--- + +## 1. react-pdf (wojtekmaj/react-pdf) + +### 推荐版本 + +- **react-pdf**: `^10.4.1` +- **pdfjs-dist**: 与 react-pdf 配套版本(通常由 react-pdf 依赖) + +### 安装与配置 + +```bash +npm install react-pdf pdfjs-dist +``` + +### Vite 环境下 pdfjs-dist Worker 配置 + +**关键**:必须在渲染 PDF 的同一模块中配置 worker,否则会报错。 + +```tsx +// 在 PDFViewer 或使用 react-pdf 的入口模块顶部 +import { pdfjs } from 'react-pdf'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); +``` + +### 文本层与选择 + +- 必须导入 TextLayer CSS 以支持文本选择和搜索: + +```tsx +import 'react-pdf/dist/Page/TextLayer.css'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +``` + +- `renderTextLayer={true}` 启用文本层(默认 true) +- `onRenderTextLayerSuccess` 文本层渲染完成回调 + +**文本选择 API**:react-pdf 本身不提供 `onTextSelect`。获取选中文本需结合浏览器 API: + +```tsx +// 在 Document 或 Page 的容器上监听 +
{ + const selection = document.getSelection(); + if (selection) { + const text = selection.toString(); + if (text) onTextSelect?.(text, currentPageNumber); + } +}}> + + + +
+``` + +### 虚拟化渲染 + +react-pdf **无内置虚拟化**。PDF.js 建议单次渲染不超过 ~25 页。大 PDF 推荐: + +1. **Virtuoso / react-virtualized**:仅渲染可见页,用 `Page` 的 `pageNumber` 按需渲染 +2. **渐进渲染**:用 `onRenderSuccess` 逐页渲染,控制内存 +3. **分页导航**:仅渲染当前页 + 前后各 1–2 页 + +```tsx +// 虚拟化示例:仅渲染可见页 +import { useInView } from 'react-intersection-observer'; // 或类似 + +function VirtualizedPage({ pageNumber, width }) { + const { ref, inView } = useInView({ threshold: 0.1 }); + return ( +
+ {inView && ( + + )} +
+ ); +} +``` + +### 与项目技术栈兼容性 + +| 技术 | 兼容性 | +|------|--------| +| React 18/19 | ✅ 支持 | +| TypeScript | ✅ 内置类型 | +| Vite | ✅ 需配置 worker(见上) | +| TailwindCSS | ✅ 可自定义 className | + +### 代码示例(PDFViewer 骨架) + +```tsx +import { useState } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import 'react-pdf/dist/Page/TextLayer.css'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +interface PDFViewerProps { + url: string; + onTextSelect?: (text: string, pageNumber: number) => void; +} + +export function PDFViewer({ url, onTextSelect }: PDFViewerProps) { + const [numPages, setNumPages] = useState(0); + const [pageNumber, setPageNumber] = useState(1); + + return ( +
{ + const sel = document.getSelection(); + if (sel?.toString()) onTextSelect?.(sel.toString(), pageNumber); + }} + > + setNumPages(numPages)} + loading={
Loading PDF...
} + > + +
+ +
+ ); +} +``` + +--- + +## 2. d3-force (D3.js Force Simulation) + +### 推荐版本 + +- **d3**: `^7.9.0`(含 d3-force、d3-selection、d3-drag 等) +- **@types/d3**: `^7.4.3`(若需类型) + +```bash +npm install d3 +npm install -D @types/d3 +``` + +### React 18 集成方式 + +使用 `useRef` 持有 simulation 和 SVG 引用,在 `useEffect` 中初始化并清理: + +```tsx +import { useEffect, useRef } from 'react'; +import * as d3 from 'd3'; + +function ForceGraph({ nodes, links, width, height }) { + const svgRef = useRef(null); + const simulationRef = useRef | null>(null); + + useEffect(() => { + if (!svgRef.current || !nodes.length) return; + + const simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id((d) => d.id).distance(100)) + .force('charge', d3.forceManyBody().strength(-300)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collide', d3.forceCollide().radius(20)) + .on('tick', ticked); + + simulationRef.current = simulation; + + function ticked() { + d3.select(svgRef.current) + .selectAll('.link') + .attr('x1', (d) => d.source.x) + .attr('y1', (d) => d.source.y) + .attr('x2', (d) => d.target.x) + .attr('y2', (d) => d.target.y); + d3.select(svgRef.current) + .selectAll('.node') + .attr('cx', (d) => d.x) + .attr('cy', (d) => d.y); + } + + return () => { + simulation.stop(); + simulationRef.current = null; + }; + }, [nodes, links, width, height]); + + return ( + + ... + ... + + ); +} +``` + +### forceSimulation 关键参数 + +| 方法/力 | 说明 | +|---------|------| +| `d3.forceSimulation(nodes)` | 创建仿真,传入节点数组 | +| `simulation.force(name, force)` | 添加/替换力 | +| `simulation.alpha(alpha)` | 当前 alpha(0–1),越大越活跃 | +| `simulation.alphaDecay(decay)` | alpha 衰减率,默认 0.0228 | +| `simulation.alphaTarget(target)` | 目标 alpha,用于“加热” | +| `simulation.velocityDecay(decay)` | 速度衰减,默认 0.4 | +| `d3.forceLink(links).id(fn).distance(d)` | 连接力,拉近相连节点 | +| `d3.forceManyBody().strength(s)` | 斥力(负)或引力(正) | +| `d3.forceCenter(x, y)` | 将图居中 | +| `d3.forceCollide().radius(r)` | 碰撞检测,防止重叠 | +| `d3.forceX(x).strength(s)` | X 方向定位力 | +| `d3.forceY(y).strength(s)` | Y 方向定位力 | + +### 性能优化 + +1. **Barnes–Hut theta**:`forceManyBody().theta(0.5)` 降低计算量 +2. **限制节点数**:50–200 节点为宜,超出时采样或分页 +3. **alphaMin**:`simulation.alphaMin(0.001)` 提前停止 +4. **拖拽时加热**:`simulation.alphaTarget(0.3).restart()`,松手后 `alphaTarget(0)` +5. **固定节点**:`node.fx = x; node.fy = y` 固定位置,减少计算 + +### TypeScript 类型 + +```ts +interface GraphNode extends d3.SimulationNodeDatum { + id: string; + title?: string; + year?: number; + citation_count?: number; + x?: number; + y?: number; + fx?: number; + fy?: number; +} + +interface GraphLink extends d3.SimulationLinkDatum { + source: string | GraphNode; + target: string | GraphNode; + type?: 'cites' | 'cited_by'; +} +``` + +### 与项目技术栈兼容性 + +| 技术 | 兼容性 | +|------|--------| +| React 18 | ✅ useRef + useEffect 模式 | +| TypeScript | ✅ @types/d3 | +| Vite | ✅ 无特殊配置 | +| TailwindCSS | ✅ 可配合 className | + +--- + +## 3. react-resizable-panels + +### 推荐版本 + +- **react-resizable-panels**: `^4.7.3` + +```bash +npm install react-resizable-panels +``` + +### 组件与基本用法 + +当前 API 使用 **Group**、**Panel**、**Separator**(非 PanelGroup/PanelResizeHandle): + +```tsx +import { Group, Panel, Separator } from 'react-resizable-panels'; + +function PDFReaderLayout() { + return ( + + + + + + + + + + ); +} +``` + +### 关键 Props + +**Group** + +- `orientation`: `"horizontal"` | `"vertical"` +- `defaultLayout`: 持久化布局(百分比数组) +- `onLayoutChanged`: 布局变化后回调 +- `disabled`: 禁用拖拽 + +**Panel** + +- `defaultSize`: 默认占比(数字或 `"50%"`) +- `minSize` / `maxSize`: 最小/最大占比 +- `collapsible`: 是否可折叠 +- `onResize`: 尺寸变化回调 +- `panelRef`: imperative API(`collapse()`, `expand()`, `resize()`) + +**Separator** + +- `className` / `style`: 自定义样式 +- `disabled`: 禁用该分隔条 + +### 与 TailwindCSS 集成 + +- `className` 和 `style` 可自由使用 +- 注意:Group 的 `display`、`flex-direction`、`overflow` 不可覆盖 +- 可用 `data-separator` 选择器自定义 Separator 悬停样式: + +```css +[data-separator]:hover { + background-color: rgb(59 130 246); +} +``` + +### 响应式布局 + +- 使用 `defaultLayout` + `onLayoutChanged` 持久化到 localStorage +- `groupResizeBehavior`: `preserve-relative-size`(默认)或 `preserve-pixel-size` + +```tsx +const [layout, setLayout] = useState(); + + setLayout(sizes)} +> + ... + +``` + +### 与项目技术栈兼容性 + +| 技术 | 兼容性 | +|------|--------| +| React 18/19 | ✅ peerDependencies 支持 | +| TypeScript | ✅ 内置类型 | +| Vite | ✅ 无特殊配置 | +| TailwindCSS v4 | ✅ 可配合使用 | + +--- + +## 4. Semantic Scholar API + +### 基础 URL + +- **Academic Graph API**: `https://api.semanticscholar.org/graph/v1` + +### Citations 与 References 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/paper/{paper_id}/citations` | **GET** | 引用该论文的论文列表(被引) | +| `/paper/{paper_id}/references` | **GET** | 该论文引用的论文列表(参考文献) | + +**paper_id 格式**:S2 paperId(40 字符 hex)或 `DOI:{doi}` + +> 注:部分文档提到 POST 用于分页/大批量,常规场景 GET + limit/offset 即可。 + +### 查询参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `fields` | string | 逗号分隔字段,如 `title,year,citationCount,authors,externalIds` | +| `limit` | int | 每页数量,默认 100 | +| `offset` | int | 分页偏移 | + +### 常用 fields + +- `paperId`, `title`, `year`, `citationCount`, `referenceCount` +- `authors`, `externalIds`(含 DOI) +- `isOpenAccess`, `openAccessPdf` +- `abstract`, `url` + +### 速率限制与认证 + +| 类型 | 限制 | +|------|------| +| 无 API Key | 共享限速,约 1 req/s | +| 有 API Key | 约 10 req/s(可申请更高) | + +**认证**:请求头 `x-api-key: YOUR_API_KEY` + +### 请求示例 + +```python +# Python +import httpx + +BASE = "https://api.semanticscholar.org/graph/v1" +paper_id = "649def34f8be52c8b66281af98ae884c09aef38b" +headers = {"x-api-key": "YOUR_API_KEY"} # 可选 + +# Citations +resp = httpx.get( + f"{BASE}/paper/{paper_id}/citations", + params={"fields": "title,year,citationCount,authors,externalIds", "limit": 20}, + headers=headers, +) +citations = resp.json().get("data", []) + +# References +resp = httpx.get( + f"{BASE}/paper/{paper_id}/references", + params={"fields": "title,year,citationCount,authors,externalIds", "limit": 20}, + headers=headers, +) +references = resp.json().get("data", []) +``` + +### 返回格式 + +```json +{ + "data": [ + { + "paperId": "...", + "title": "...", + "year": 2018, + "citationCount": 365, + "authors": [{"authorId": "...", "name": "..."}], + "externalIds": {"DOI": "10.1234/..."} + } + ], + "next": 20 +} +``` + +### 限制与注意事项 + +- 单次最多返回 9,999 条 +- 单次响应最大约 10 MB +- 建议使用 `fields` 只请求必要字段以提升速度 +- 429 时需指数退避重试 + +--- + +## 依赖汇总(Phase 4 前端) + +```json +{ + "dependencies": { + "react-pdf": "^10.4.1", + "pdfjs-dist": "^4.x", + "d3": "^7.9.0", + "react-resizable-panels": "^4.7.3" + }, + "devDependencies": { + "@types/d3": "^7.4.3" + } +} +``` + +--- + +## 参考资料 + +- [react-pdf README](https://github.com/wojtekmaj/react-pdf) +- [D3 Force API](https://github.com/d3/d3-force) +- [react-resizable-panels](https://react-resizable-panels.vercel.app/) +- [Semantic Scholar API](https://api.semanticscholar.org/api-docs/) +- [Semantic Scholar API Tutorial](https://semanticscholar.org/product/api/tutorial) diff --git a/docs/research/2026-03-15-smart-autocomplete-literature-review-best-practices.md b/docs/research/2026-03-15-smart-autocomplete-literature-review-best-practices.md new file mode 100644 index 0000000..2e4c500 --- /dev/null +++ b/docs/research/2026-03-15-smart-autocomplete-literature-review-best-practices.md @@ -0,0 +1,228 @@ +# 智能补全与 Literature Review 最佳实践研究 + +**日期**: 2026-03-15 +**上下文**: Phase 4 创新功能(智能补全、自动 Literature Review)实现前的研究与方案建议 + +--- + +## 一、智能补全(Smart Autocomplete for Chat Input) + +### 1.1 最佳实践建议(5 条) + +| # | 实践 | 说明 | +|---|------|------| +| 1 | **Debounce + AbortController 双保险** | Debounce 减少请求频率;AbortController 取消过时请求,避免 race condition(旧请求晚于新请求返回)。两者缺一不可。 | +| 2 | **最小触发阈值** | 输入 ≥ 10 字符且停顿 300–400ms 再触发,避免短输入时无意义调用。可配置化。 | +| 3 | **补全长度与 prompt 约束** | 限制 `max_tokens=30–50`,prompt 明确要求「只返回补全部分,不重复已输入内容」。非指令微调模型在 completion 任务上往往优于指令模型。 | +| 4 | **灰色 ghost text + Tab 接受** | 补全以灰色/半透明文本紧跟在光标后;Tab 接受、Esc 忽略、继续输入取消。与 VS Code/Cursor 一致,用户心智负担低。 | +| 5 | **服务端限流与超时** | 每用户每秒最多 2 次补全请求;2s 超时熔断,静默返回空,不展示错误。 | + +### 1.2 边界情况与陷阱 + +| 场景 | 风险 | 建议 | +|------|------|------| +| **Race condition** | 快速输入时,旧请求晚于新请求返回,展示错误补全 | 使用 `AbortController` 取消前次请求;或使用 `requestId` 校验,只处理最新请求的响应 | +| **多行输入** | Textarea 中光标位置复杂,补全展示位置难算 | 方案 A:补全始终在最后一行末尾;方案 B:用 `contenteditable` div 精确控制(复杂度高) | +| **Tab 与表单导航冲突** | 默认 Tab 会切换焦点,可能误触发 | 有补全时 `e.preventDefault()` 拦截 Tab,无补全时放行 | +| **LLM 返回重复内容** | 模型可能重复用户已输入部分 | Prompt 强调「仅返回后续补全,不包含已输入内容」;后处理可做 prefix 去重 | +| **冷启动延迟** | 首次调用 LLM 延迟高 | 使用 Routing Tier 小模型;或考虑 WebSocket 长连接预热(后续优化) | + +### 1.3 推荐技术方案与库 + +| 类别 | 推荐 | 说明 | +|------|------|------| +| **Debounce + Abort** | 自实现 `useDebouncedCompletion` | 结合 `useRef` 存 timer 和 `AbortController`,onChange 时 `clearTimeout` + `abort()`,再 `setTimeout` 发起新请求 | +| **Ghost text 展示** | 重叠 input 或 span 方案 | 两方案:(1) 上下叠放两个 input,上层可编辑、下层 disabled 显示灰色补全;(2) 在 input 后追加 `` 显示补全 | +| **开源库** | GhostComplete、fude | GhostComplete:38kB、零依赖、支持学习;fude:含 @mentions + AI 补全,适合复杂场景。Omelette 当前为简单 Textarea,可先自实现,后续评估 fude | + +### 1.4 代码示例 + +**React Debounce + AbortController 模式**: + +```tsx +function useCompletion(prefix: string, options: { kbIds: number[] }) { + const [completion, setCompletion] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const timerRef = useRef>(); + const controllerRef = useRef(); + + useEffect(() => { + if (prefix.length < 10) { + setCompletion(''); + return; + } + + clearTimeout(timerRef.current); + controllerRef.current?.abort(); + controllerRef.current = new AbortController(); + const signal = controllerRef.current.signal; + + timerRef.current = setTimeout(async () => { + setIsLoading(true); + try { + const res = await fetch('/api/v1/chat/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefix, knowledge_base_ids: options.kbIds }), + signal, + }); + const data = await res.json(); + if (data.data?.completion) setCompletion(data.data.completion); + else setCompletion(''); + } catch (err) { + if ((err as Error).name !== 'AbortError') console.error(err); + setCompletion(''); + } finally { + setIsLoading(false); + } + }, 400); + + return () => { + clearTimeout(timerRef.current); + controllerRef.current?.abort(); + }; + }, [prefix, options.kbIds]); + + return { completion, isLoading }; +} +``` + +**LLM 补全 Prompt 模板**: + +``` +你是一个科研写作助手。根据用户已输入的文本,预测并补全后续内容。 +规则: +1. 只返回补全的部分,不要重复用户已输入的内容 +2. 最多 50 个字符 +3. 如果无法合理预测,返回空字符串 + +用户已输入:{prefix} +``` + +--- + +## 二、自动 Literature Review 生成 + +### 2.1 最佳实践建议(5 条) + +| # | 实践 | 说明 | +|---|------|------| +| 1 | **提纲 → RAG 检索 → 逐章节生成** | 先 LLM 生成提纲,再按章节 query 做 RAG 检索,最后逐章节生成。避免一次性生成导致上下文过长、引用错位。 | +| 2 | **引用强约束:仅允许引用检索到的来源** | Prompt 明确「每个引用必须对应提供的文献」「禁止引用未提供的来源」。Evidence Bundle 模式可将幻觉率从 23.7% 降至 3.2%。 | +| 3 | **SSE 流式 + 多事件类型** | 使用 `StreamingResponse` + `text/event-stream`;可区分 `event: chunk`(正文)、`event: citation`(引用映射)、`event: section`(章节切换),前端按需渲染。 | +| 4 | **SubQuestionQueryEngine 用于复杂综述** | 将「比较 A 与 B」「各方法优劣」等复杂问题拆成子问题,分别检索后合成。适合跨文档对比型综述。 | +| 5 | **引用格式在 Markdown 中的表示** | numbered: `[1][2,3]`;APA: `(Author, Year)`;GB/T 7714: `[1]` 上标或 `[1]` 方括号。文末维护 `[1] Author. Title[J]. Journal, Year.` 格式的参考文献列表。 | + +### 2.2 边界情况与陷阱 + +| 场景 | 风险 | 建议 | +|------|------|------| +| **引用幻觉** | GPT-4 仍有 18–28% fabricated citations | 强制「仅引用提供的 sources」;可引入 VeriCite/FACTUM 类后处理验证(进阶) | +| **引用与段落错位** | 生成时 [1][2] 与段落不对应 | Prompt 要求「按段落—引用对应」;生成时维护 `sources_map`,随 SSE 下发 | +| **知识库为空** | 直接生成会完全幻觉 | 提前 `collection.count()` 检查,返回友好提示 | +| **SSE 中断** | 用户取消或超时 | 前端保留已展示内容,标记「生成已中断」;后端在 generator 中捕获 `asyncio.CancelledError` 优雅退出 | +| **SubQuestionQueryEngine 延迟** | 子问题串行执行,总时长可能 > 2min | 可并行执行子问题(LlamaIndex Workflow 版支持);或对简单综述不用 SubQuestion,直接用 outline + 单次 RAG | + +### 2.3 推荐技术方案与库 + +| 类别 | 推荐 | 说明 | +|------|------|------| +| **提纲解析** | 按 `##` 分割 + 标题作为 query | `parse_outline_sections()` 将 Markdown 提纲解析为 `[{title, query}, ...]`;解析失败时降级为整段 outline 作为单一 query | +| **RAG 检索** | 现有 RAGService + `retrieve_only` | 需新增 `retrieve_only(project_id, query, top_k)` 仅检索不生成,返回 `sources` 列表 | +| **SubQuestionQueryEngine** | 可选增强 | 适合「比较/对比」类综述;每个 project 的 index 作为 QueryEngineTool,SubQuestionQueryEngine 分解问题并合成 | +| **引用验证** | Prompt 强约束 + 可选 NLI | 首选 prompt 约束;进阶可引入 NLI 模型做 claim–evidence 对齐验证 | +| **SSE 格式** | FastAPI StreamingResponse | 与现有 chat stream 一致;可 yield `{"event":"chunk","data":"..."}` 或纯 `data: {...}\n\n` | + +### 2.4 代码示例 + +**SSE 流式综述生成(FastAPI)**: + +```python +async def generate_literature_review( + self, project_id: int, topic: str, citation_format: str = "numbered" +) -> AsyncGenerator[str, None]: + outline = await self.generate_review_outline(project_id, topic) + sections = parse_outline_sections(outline["outline"]) + if not sections: + sections = [{"title": "Overview", "query": outline["outline"][:200]}] + + for i, section in enumerate(sections): + sources = await self.rag.retrieve_only(project_id, section["query"], top_k=8) + section["sources"] = sources + # 下发 section 元数据 + yield f"data: {json.dumps({'event': 'section', 'index': i, 'title': section['title']})}\n\n" + + prompt = f"""你是一个学术综述写作助手。为以下章节撰写综述段落。 + +章节标题:{section['title']} +相关文献摘录: +{format_sources_for_prompt(sources)} + +要求: +1. 使用学术语言,逻辑清晰 +2. 在适当位置使用 [1][2] 格式引用 +3. 每个引用必须对应上面提供的文献,禁止编造 +4. 段落长度 200-400 字""" + + async for chunk in self.llm.stream_chat([{"role": "user", "content": prompt}], max_tokens=500): + yield f"data: {json.dumps({'event': 'chunk', 'data': chunk})}\n\n" +``` + +**Markdown 引用格式示例**: + +```markdown +## 2. 研究方法 + +近年来,深度学习在 NLP 领域取得显著进展[1]。Transformer 架构[2,3] 成为主流,但计算成本较高[4]。 + +## 参考文献 + +[1] Author A. Title of Paper[J]. Journal Name, 2024, 10(2): 1-15. +[2] Author B. Another Paper[C]. Conference, 2023. +``` + +**LlamaIndex SubQuestionQueryEngine 用于综述**(可选): + +```python +from llama_index.core.tools import QueryEngineTool, ToolMetadata +from llama_index.core.query_engine import SubQuestionQueryEngine + +# 每个 project 的 index 作为 tool +query_engine_tools = [ + QueryEngineTool( + query_engine=vector_index.as_query_engine(), + metadata=ToolMetadata( + name="project_papers", + description="Literature in the project knowledge base", + ), + ), +] +sub_engine = SubQuestionQueryEngine.from_defaults(query_engine_tools=query_engine_tools) + +# 复杂查询如 "Compare method A and method B across papers" +response = sub_engine.query("Compare the methodologies of papers on topic X") +``` + +--- + +## 三、总结对照表 + +| 维度 | 智能补全 | Literature Review | +|------|----------|-------------------| +| **核心模式** | debounce 400ms + AbortController + 单次 LLM | 提纲 → RAG 检索 → 逐章节流式生成 | +| **关键约束** | max_tokens≤50,只返回补全部分 | 仅引用检索到的来源 | +| **流式** | 非流式(单次返回) | SSE 流式 | +| **可选增强** | WebSocket 预热、本地小模型 | SubQuestionQueryEngine、引用验证 | +| **与现有代码关系** | 新增 CompletionService + ChatInput 扩展 | 扩展 WritingService + 新增 retrieve_only | + +--- + +## 四、参考文献 + +- [VeriCite: Towards Reliable Citations in RAG](https://arxiv.org/abs/2510.11394) +- [FACTUM: Detecting Citation Hallucination in RAG](https://www.emergentmind.com/papers/2601.05866) +- [LiRA: Multi-Agent Literature Review Framework](https://arxiv.org/html/2510.05138v2) +- [LlamaIndex SubQuestionQueryEngine](https://docs.llamaindex.ai/en/stable/examples/query_engine/sub_question_query_engine/) +- [FastAPI SSE Streaming](https://fastapi.tiangolo.com/tutorial/server-sent-events/) +- [Debounced fetch with AbortController](https://svarden.se/post/debounced-fetch-with-abort-controller) +- [Ghost text autocomplete Stack Overflow](https://stackoverflow.com/questions/63854661/autocomplete-suggestion-in-an-input-as-gray-letters-after-the-cursor) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c85ad68..4753b1e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,8 +26,11 @@ "react": "^19.2.0", "react-diff-viewer-continued": "^4.2.0", "react-dom": "^19.2.0", + "react-force-graph-2d": "^1.29.1", "react-i18next": "^16.5.7", "react-markdown": "^10.1.0", + "react-pdf": "^10.4.1", + "react-resizable-panels": "^4.7.3", "react-router-dom": "^7.13.1", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", @@ -2231,6 +2234,256 @@ "node": ">=18" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.96.tgz", + "integrity": "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.96", + "@napi-rs/canvas-darwin-arm64": "0.1.96", + "@napi-rs/canvas-darwin-x64": "0.1.96", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", + "@napi-rs/canvas-linux-arm64-musl": "0.1.96", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", + "@napi-rs/canvas-linux-x64-gnu": "0.1.96", + "@napi-rs/canvas-linux-x64-musl": "0.1.96", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", + "@napi-rs/canvas-win32-x64-msvc": "0.1.96" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.96.tgz", + "integrity": "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.96.tgz", + "integrity": "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.96.tgz", + "integrity": "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.96.tgz", + "integrity": "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.96.tgz", + "integrity": "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.96.tgz", + "integrity": "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.96.tgz", + "integrity": "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.96.tgz", + "integrity": "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.96.tgz", + "integrity": "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.96.tgz", + "integrity": "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.96.tgz", + "integrity": "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -4666,6 +4919,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -5339,6 +5598,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5627,6 +5895,16 @@ "node": ">=6.0.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5807,6 +6085,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -6274,6 +6564,223 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -7349,6 +7856,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -7369,6 +7890,32 @@ } } }, + "node_modules/force-graph": { + "version": "1.51.2", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.2.tgz", + "integrity": "sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -8133,6 +8680,15 @@ "node": ">=8" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8146,6 +8702,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -8443,6 +9008,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -8613,6 +9187,18 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/katex": { "version": "0.16.38", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", @@ -8955,6 +9541,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9015,6 +9607,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -9069,6 +9673,24 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -9425,6 +10047,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -10321,7 +10960,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10668,6 +11306,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10753,6 +11403,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/preact": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", + "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10841,6 +11501,23 @@ "node": ">=6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -11065,6 +11742,23 @@ "react": "^19.2.4" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-i18next": { "version": "16.5.7", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.7.tgz", @@ -11099,6 +11793,21 @@ "dev": true, "license": "MIT" }, + "node_modules/react-kapsule": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", + "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -11126,6 +11835,35 @@ "react": ">=18" } }, + "node_modules/react-pdf": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", + "integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -11183,6 +11921,16 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.7.3.tgz", + "integrity": "sha512-PYcYMLtvJD+Pr0TQNeMvddcnLOwUa/Yb4iNwU7ThNLlHaQYEEC9MIBWHaBGODzYuXIkPRZ/OWe5sbzG1Rzq5ew==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-router": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", @@ -12220,7 +12968,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -12230,6 +12977,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -13034,6 +13787,15 @@ "node": ">=18" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 77f401f..877f11e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,8 +31,11 @@ "react": "^19.2.0", "react-diff-viewer-continued": "^4.2.0", "react-dom": "^19.2.0", + "react-force-graph-2d": "^1.29.1", "react-i18next": "^16.5.7", "react-markdown": "^10.1.0", + "react-pdf": "^10.4.1", + "react-resizable-panels": "^4.7.3", "react-router-dom": "^7.13.1", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 89562e8..caa23a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ const PapersPage = lazy(() => import('@/pages/project/PapersPage')); const WritingPage = lazy(() => import('@/pages/project/WritingPage')); const TasksPage = lazy(() => import('@/pages/project/TasksPage')); const DiscoveryPage = lazy(() => import('@/pages/project/DiscoveryPage')); +const PDFReaderPage = lazy(() => import('@/pages/project/PDFReaderPage')); const queryClient = new QueryClient({ defaultOptions: { @@ -42,6 +43,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/citation-graph/CitationGraphView.tsx b/frontend/src/components/citation-graph/CitationGraphView.tsx new file mode 100644 index 0000000..ae96dc8 --- /dev/null +++ b/frontend/src/components/citation-graph/CitationGraphView.tsx @@ -0,0 +1,171 @@ +import { lazy, Suspense, useState, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ZoomIn, ZoomOut, Maximize, Filter } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import NodeDetailPanel from './NodeDetailPanel'; + +const ForceGraph2D = lazy(() => import('react-force-graph-2d')); + +export interface GraphNode { + id: string; + title: string; + year: number | null; + citation_count: number; + is_local: boolean; + s2_id: string; + authors?: string[]; + paper_id?: number; + x?: number; + y?: number; +} + +export interface GraphLink { + source: string; + target: string; + type: 'cites' | 'cited_by'; +} + +export interface GraphData { + nodes: GraphNode[]; + edges: GraphLink[]; + center_id: string | null; + error?: string; +} + +interface CitationGraphViewProps { + data: GraphData; + isLoading?: boolean; + projectId: number; +} + +function GraphSkeleton() { + return ( +
+ +
+ ); +} + +export default function CitationGraphView({ data, isLoading, projectId }: CitationGraphViewProps) { + const { t } = useTranslation(); + const [selectedNode, setSelectedNode] = useState(null); + const [showLocalOnly, setShowLocalOnly] = useState(false); + + const graphData = useMemo(() => { + let nodes = data.nodes; + let edges = data.edges; + + if (showLocalOnly) { + const localIds = new Set(nodes.filter((n) => n.is_local).map((n) => n.id)); + localIds.add(data.center_id ?? ''); + nodes = nodes.filter((n) => localIds.has(n.id)); + edges = edges.filter((e) => localIds.has(e.source as string) && localIds.has(e.target as string)); + } + + return { nodes, links: edges }; + }, [data, showLocalOnly]); + + const handleNodeClick = useCallback((node: GraphNode) => { + setSelectedNode(node); + }, []); + + const nodeColor = useCallback((node: GraphNode) => { + if (node.id === data.center_id) return '#ef4444'; + if (node.is_local) return '#22c55e'; + if (node.year && node.year >= 2020) return '#3b82f6'; + return '#94a3b8'; + }, [data.center_id]); + + const nodeVal = useCallback((node: GraphNode) => { + return Math.log10((node.citation_count || 0) + 1) * 6 + 2; + }, []); + + const linkColor = useCallback((link: GraphLink) => { + return link.type === 'cites' ? '#93c5fd' : '#fdba74'; + }, []); + + if (isLoading) return ; + + if (data.error) { + return ( +
+

{data.error}

+
+ ); + } + + if (!data.nodes.length) { + return ( +
+

{t('papers.citationGraph.empty', '暂无引用关系数据')}

+
+ ); + } + + const localCount = data.nodes.filter((n) => n.is_local).length; + + return ( +
+
+ + {data.nodes.length} {t('papers.citationGraph.nodes', '节点')} + + + {data.edges.length} {t('papers.citationGraph.edges', '连接')} + + {localCount > 0 && ( + + {localCount} {t('papers.citationGraph.local', '本地')} + + )} + +
+ +
+
+ 中心 + 本地 + 近年 + 其他 +
+
+ + }> + `${node.title}\n(${node.year ?? '?'}) 引用:${node.citation_count}`} + nodeVal={nodeVal} + nodeColor={nodeColor} + linkSource="source" + linkTarget="target" + linkDirectionalArrowLength={4} + linkDirectionalArrowRelPos={1} + linkColor={linkColor} + linkWidth={1} + onNodeClick={handleNodeClick} + cooldownTicks={100} + width={undefined} + height={undefined} + /> + + + {selectedNode && ( + setSelectedNode(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/citation-graph/NodeDetailPanel.tsx b/frontend/src/components/citation-graph/NodeDetailPanel.tsx new file mode 100644 index 0000000..c5ff65b --- /dev/null +++ b/frontend/src/components/citation-graph/NodeDetailPanel.tsx @@ -0,0 +1,84 @@ +import { useTranslation } from 'react-i18next'; +import { X, ExternalLink, BookOpen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useNavigate } from 'react-router-dom'; +import type { GraphNode } from './CitationGraphView'; + +interface NodeDetailPanelProps { + node: GraphNode; + projectId: number; + onClose: () => void; +} + +export default function NodeDetailPanel({ node, projectId, onClose }: NodeDetailPanelProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( +
+
+

{node.title}

+ +
+ +
+ {node.year && ( +
+ {t('papers.citationGraph.year', '年份')}: + {node.year} +
+ )} + +
+ {t('papers.citationGraph.citations', '引用数')}: + {node.citation_count} +
+ + {node.authors && node.authors.length > 0 && ( +
+ {t('papers.citationGraph.authors', '作者')}: +

{node.authors.join(', ')}

+
+ )} + + {node.is_local && ( + + {t('papers.citationGraph.inLibrary', '已在知识库中')} + + )} + +
+ {node.is_local && node.paper_id && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/pdf-reader/PDFReaderLayout.tsx b/frontend/src/components/pdf-reader/PDFReaderLayout.tsx new file mode 100644 index 0000000..3ad418a --- /dev/null +++ b/frontend/src/components/pdf-reader/PDFReaderLayout.tsx @@ -0,0 +1,71 @@ +import { lazy, Suspense, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Group, Panel, Separator } from 'react-resizable-panels'; +import { Loader2, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { SelectionQA } from './SelectionQA'; + +const PDFViewer = lazy(() => import('./PDFViewer')); + +interface PDFReaderLayoutProps { + pdfUrl: string; + paperId: number; + paperTitle: string; + projectId: number; + onBack: () => void; +} + +export default function PDFReaderLayout({ + pdfUrl, + paperId, + paperTitle, + projectId, + onBack, +}: PDFReaderLayoutProps) { + const { t } = useTranslation(); + const [selectedText, setSelectedText] = useState(''); + const [selectedPage, setSelectedPage] = useState(1); + + const handleTextSelect = useCallback((text: string, pageNumber: number) => { + setSelectedText(text); + setSelectedPage(pageNumber); + }, []); + + return ( +
+ {/* Header */} +
+ +

{paperTitle}

+
+ + {/* Main content */} +
+ + + + +
+ }> + + + + + + + + +
+ + ); +} diff --git a/frontend/src/components/pdf-reader/PDFViewer.tsx b/frontend/src/components/pdf-reader/PDFViewer.tsx new file mode 100644 index 0000000..031dd87 --- /dev/null +++ b/frontend/src/components/pdf-reader/PDFViewer.tsx @@ -0,0 +1,166 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import 'react-pdf/dist/Page/TextLayer.css'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import { + ZoomIn, + ZoomOut, + ChevronLeft, + ChevronRight, + Loader2, + AlertTriangle, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); + +interface PDFViewerProps { + url: string; + onTextSelect?: (text: string, pageNumber: number) => void; +} + +const ZOOM_STEP = 0.15; +const MIN_SCALE = 0.5; +const MAX_SCALE = 3.0; + +export default function PDFViewer({ url, onTextSelect }: PDFViewerProps) { + const { t } = useTranslation(); + const [numPages, setNumPages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [scale, setScale] = useState(1.0); + const [isScanned, setIsScanned] = useState(false); + const [loadError, setLoadError] = useState(null); + const containerRef = useRef(null); + + const onDocumentLoadSuccess = useCallback( + async (pdf: { numPages: number; getPage: (n: number) => Promise<{ getTextContent: () => Promise<{ items: unknown[] }> }> }) => { + setNumPages(pdf.numPages); + setLoadError(null); + + try { + const page = await pdf.getPage(1); + const textContent = await page.getTextContent(); + const hasText = textContent.items.some( + (item: unknown) => + typeof item === 'object' && + item !== null && + 'str' in item && + typeof (item as { str: string }).str === 'string' && + (item as { str: string }).str.trim().length > 0 + ); + setIsScanned(!hasText); + } catch { + setIsScanned(false); + } + }, + [] + ); + + const handleMouseUp = useCallback(() => { + if (!onTextSelect) return; + const selection = window.getSelection(); + const text = selection?.toString().trim(); + if (text && text.length > 0) { + onTextSelect(text, currentPage); + } + }, [onTextSelect, currentPage]); + + const zoomIn = () => setScale((s) => Math.min(s + ZOOM_STEP, MAX_SCALE)); + const zoomOut = () => setScale((s) => Math.max(s - ZOOM_STEP, MIN_SCALE)); + const prevPage = () => setCurrentPage((p) => Math.max(p - 1, 1)); + const nextPage = () => setCurrentPage((p) => Math.min(p + 1, numPages)); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft' || e.key === 'PageUp') prevPage(); + if (e.key === 'ArrowRight' || e.key === 'PageDown') nextPage(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); + + if (loadError) { + return ( + + ); + } + + return ( +
+ {/* Toolbar */} +
+
+ + + {Math.round(scale * 100)}% + + +
+
+ + + {currentPage} / {numPages || '?'} + + +
+ {isScanned && ( + + {t('pdf.scannedWarning', '扫描件 - 文本选择受限')} + + )} +
+ + {/* PDF Content */} +
+
+ setLoadError(err?.message ?? 'PDF load failed')} + loading={ +
+ + {t('pdf.loading', '加载 PDF...')} +
+ }> + + +
+ } + /> + +
+
+ + ); +} diff --git a/frontend/src/components/pdf-reader/SelectionQA.tsx b/frontend/src/components/pdf-reader/SelectionQA.tsx new file mode 100644 index 0000000..aee9662 --- /dev/null +++ b/frontend/src/components/pdf-reader/SelectionQA.tsx @@ -0,0 +1,294 @@ +import { useState, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + MessageSquare, + Languages, + BookOpen, + Search, + Send, + Loader2, + Trash2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface QAEntry { + id: string; + question: string; + answer: string; + action?: string; +} + +interface SelectionQAProps { + selectedText: string; + selectedPage: number; + paperId: number; + paperTitle: string; + projectId: number; +} + +const QUICK_ACTIONS = [ + { id: 'explain', icon: MessageSquare, labelKey: 'pdf.explain' as const, fallback: '解释这段话' }, + { id: 'translate', icon: Languages, labelKey: 'pdf.translate' as const, fallback: '翻译' }, + { id: 'find_citations', icon: Search, labelKey: 'pdf.findCitations' as const, fallback: '找相关引用' }, +] as const; + +export function SelectionQA({ + selectedText, + selectedPage, + paperId, + paperTitle, + projectId, +}: SelectionQAProps) { + const { t } = useTranslation(); + const [history, setHistory] = useState([]); + const [freeQuestion, setFreeQuestion] = useState(''); + const [streaming, setStreaming] = useState(false); + const abortRef = useRef(null); + const historyEndRef = useRef(null); + + const askAI = useCallback( + async (question: string, action?: string) => { + if (streaming) return; + setStreaming(true); + + const entryId = crypto.randomUUID(); + const newEntry: QAEntry = { id: entryId, question, answer: '', action }; + setHistory((prev) => [...prev, newEntry]); + + const ctrl = new AbortController(); + abortRef.current = ctrl; + + try { + const message = buildMessage(selectedText, question, action); + const res = await fetch('/api/v1/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message, + knowledge_base_ids: [], + tool_mode: 'qa', + paper_id: paperId, + paper_title: paperTitle, + selected_text: selectedText, + }), + signal: ctrl.signal, + }); + + if (!res.ok || !res.body) { + setHistory((prev) => + prev.map((e) => (e.id === entryId ? { ...e, answer: '请求失败' } : e)) + ); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const textChunks = extractTextFromSSE(buffer); + if (textChunks) { + setHistory((prev) => + prev.map((e) => + e.id === entryId ? { ...e, answer: textChunks } : e + ) + ); + } + } + + const finalText = extractTextFromSSE(buffer); + if (finalText) { + setHistory((prev) => + prev.map((e) => + e.id === entryId ? { ...e, answer: finalText } : e + ) + ); + } + } catch (err) { + if ((err as Error).name !== 'AbortError') { + setHistory((prev) => + prev.map((e) => (e.id === entryId ? { ...e, answer: '请求出错' } : e)) + ); + } + } finally { + setStreaming(false); + abortRef.current = null; + setTimeout(() => historyEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100); + } + }, + [streaming, selectedText, paperId, paperTitle] + ); + + const handleQuickAction = useCallback( + (actionId: string, label: string) => { + if (!selectedText) return; + askAI(label, actionId); + }, + [selectedText, askAI] + ); + + const handleFreeQuestion = useCallback(() => { + if (!freeQuestion.trim()) return; + askAI(freeQuestion.trim()); + setFreeQuestion(''); + }, [freeQuestion, askAI]); + + return ( +
+ {/* Header */} +
+
+ + {t('pdf.aiAssistant', 'AI 助手')} +
+ {history.length > 0 && ( + + )} +
+ + {/* Selected Text Display */} + {selectedText && ( +
+

+ {t('pdf.selectedText', '选中文本')} (p.{selectedPage}) +

+

{selectedText}

+
+ {QUICK_ACTIONS.map((action) => ( + + ))} +
+
+ )} + + {/* QA History */} +
+ {history.length === 0 ? ( +
+

+ {t('pdf.selectToAsk', '选中 PDF 中的文本,向 AI 提问')} +

+
+ ) : ( +
+ {history.map((entry) => ( +
+
+
+ Q +
+

{entry.question}

+
+
+
+ A +
+ {entry.answer ? ( +
+ {entry.answer} +
+ ) : ( + + )} +
+
+ ))} +
+
+ )} +
+ + {/* Free Question Input */} +
+
+ setFreeQuestion(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleFreeQuestion(); + } + }} + placeholder={t('pdf.askPlaceholder', '自由提问...')} + disabled={streaming} + className={cn( + 'flex-1 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs', + 'focus:outline-none focus:ring-1 focus:ring-ring', + 'disabled:opacity-50' + )} + /> + +
+
+
+ ); +} + +function buildMessage(selectedText: string, question: string, action?: string): string { + const context = selectedText + ? `[选中文本] ${selectedText}\n\n` + : ''; + + if (action === 'explain') { + return `${context}请解释以下文本的含义和学术意义:\n${selectedText}`; + } + if (action === 'translate') { + const hasChineseChars = /[\u4e00-\u9fff]/.test(selectedText); + const targetLang = hasChineseChars ? 'English' : '中文'; + return `${context}请将以下文本翻译为${targetLang}:\n${selectedText}`; + } + if (action === 'find_citations') { + return `${context}请在知识库中找到与以下内容相关的文献引用:\n${selectedText}`; + } + return `${context}${question}`; +} + +function extractTextFromSSE(buffer: string): string { + let text = ''; + for (const line of buffer.split('\n')) { + if (!line.startsWith('0:')) continue; + try { + const content = JSON.parse(line.slice(2)); + if (typeof content === 'string') { + text += content; + } + } catch { + /* skip malformed */ + } + } + return text; +} diff --git a/frontend/src/components/playground/ChatInput.tsx b/frontend/src/components/playground/ChatInput.tsx index a2eb393..a9ed343 100644 --- a/frontend/src/components/playground/ChatInput.tsx +++ b/frontend/src/components/playground/ChatInput.tsx @@ -1,11 +1,13 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Send, Loader2, Paperclip, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import ToolModeSelector from './ToolModeSelector'; +import CompletionSuggestion from './CompletionSuggestion'; import type { ToolMode } from '@/types/chat'; +import { api } from '@/lib/api'; interface KBInfo { id: number; @@ -21,8 +23,12 @@ interface ChatInputProps { onToolModeChange?: (mode: ToolMode) => void; selectedKBs?: KBInfo[]; onRemoveKB?: (id: number) => void; + conversationId?: number | null; } +const COMPLETION_DEBOUNCE_MS = 400; +const COMPLETION_MIN_LENGTH = 10; + export default function ChatInput({ onSend, isLoading, @@ -32,20 +38,91 @@ export default function ChatInput({ onToolModeChange, selectedKBs, onRemoveKB, + conversationId, }: ChatInputProps) { const { t } = useTranslation(); const [value, setValue] = useState(''); + const [completion, setCompletion] = useState(''); const textareaRef = useRef(null); + const abortRef = useRef(null); + const timerRef = useRef>(); + + const clearCompletion = useCallback(() => { + setCompletion(''); + abortRef.current?.abort(); + if (timerRef.current) clearTimeout(timerRef.current); + }, []); + + const fetchCompletion = useCallback( + (prefix: string) => { + clearCompletion(); + if (prefix.trim().length < COMPLETION_MIN_LENGTH || isLoading || disabled) return; + + timerRef.current = setTimeout(async () => { + const controller = new AbortController(); + abortRef.current = controller; + try { + const res = await api.post<{ completion: string; confidence: number }>( + '/chat/complete', + { + prefix, + conversation_id: conversationId ?? undefined, + knowledge_base_ids: selectedKBs?.map((kb) => kb.id) ?? [], + }, + { signal: controller.signal }, + ); + if (!controller.signal.aborted && res.data?.completion) { + setCompletion(res.data.completion); + } + } catch { + /* aborted or error — silently ignore */ + } + }, COMPLETION_DEBOUNCE_MS); + }, + [clearCompletion, conversationId, selectedKBs, isLoading, disabled], + ); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const newVal = e.target.value; + setValue(newVal); + fetchCompletion(newVal); + }; + + const acceptCompletion = () => { + if (completion) { + const newVal = value + completion; + setValue(newVal); + setCompletion(''); + } + }; const handleSubmit = () => { const trimmed = value.trim(); if (!trimmed || isLoading || disabled) return; + clearCompletion(); onSend(trimmed); setValue(''); requestAnimationFrame(() => textareaRef.current?.focus()); }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && completion) { + e.preventDefault(); + acceptCompletion(); + return; + } + if (e.key === 'Escape' && completion) { + e.preventDefault(); + clearCompletion(); + return; + } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -90,16 +167,24 @@ export default function ChatInput({ > -