diff --git a/backend/.env.example b/backend/.env.example index 18a68d9..5abdea1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,3 @@ -ENTERPRISE_BACKEND_VERSION=prod-v1.1.0 - # ==================== 服务器配置 ==================== # API 服务器配置 API.HOST=0.0.0.0 @@ -79,7 +77,6 @@ EMBEDDING_DIMENSION=1536 # Embedding 维度 OCR_ACCESS_KEY_ID=修改此项为阿里云 OCR 访问密钥ID OCR_ACCESS_KEY_SECRET=修改此项为阿里云 OCR 访问密钥Secret OCR_ENDPOINT=修改此项为阿里云 OCR 终端节点 -OCR_TIMEOUT_SECONDS=120 OCR_USE_LOCAL=false MODERATION_ENABLED=false diff --git a/backend/api/chat_file.py b/backend/api/chat_file.py index 0c51953..39b52d1 100644 --- a/backend/api/chat_file.py +++ b/backend/api/chat_file.py @@ -19,6 +19,14 @@ from services.chat_thread_file_service import ChatThreadFileService from services.vector_service import get_vector_service from services.oss_service import get_oss_service from services.kb_text_limits import decode_txt_char_count, validate_chat_file_text_length +from services.file_progress import ( + FileProgressReporter, + make_db_progress_callback, + STAGE_PROCESSING, + STAGE_DOWNLOADING, + STAGE_SUMMARIZING, + stage_label, +) from models.chat_thread_file import ( ChatThreadFileUploadResponse, ChatThreadFileListResponse @@ -47,10 +55,13 @@ async def process_chat_file_background( file_type: 文件类型(pdf 或 url) """ pool = await get_db_pool() + progress_cb = make_db_progress_callback(pool, file_id, ChatThreadFileService) + progress = FileProgressReporter(progress_cb) async with pool.acquire() as conn: local_file_path = None try: logger.info(f"开始后台处理聊天文件 ID: {file_id}, thread_id: {thread_id}, 路径: {file_path}") + await progress.report(30, STAGE_PROCESSING) # file_path 是 OSS URL,需要先下载到本地临时文件 oss_service = get_oss_service() @@ -65,6 +76,7 @@ async def process_chat_file_background( return logger.info(f"检测到 OSS URL,开始下载文件: {file_path}") + await progress.report(32, STAGE_DOWNLOADING) # 从 OSS URL 提取对象名称 oss_object_name = oss_service.extract_object_name_from_url(file_path, thread_id=thread_id) @@ -92,7 +104,8 @@ async def process_chat_file_background( thread_id, file_type, file_id=file_id, - source_url=file_path # 🔑 传递原始 OSS URL + source_url=file_path, # 🔑 传递原始 OSS URL + progress=progress, ) # 检查处理结果 @@ -104,6 +117,7 @@ async def process_chat_file_background( # 生成文件摘要 summary_text = None try: + await progress.report(92, STAGE_SUMMARIZING) # 判断是否为图片类型 image_types = {'png', 'jpg', 'jpeg', 'bmp'} is_image = file_type.lower() in image_types @@ -542,6 +556,9 @@ async def upload_chat_file( file_size, file_type # 使用检测到的文件类型 ) + await ChatThreadFileService.update_file_progress( + conn, file_record.id, 30, STAGE_PROCESSING + ) logger.info(f"✅ 文件记录已创建: ID={file_record.id}, 状态={file_record.status}") # 添加后台任务处理向量化(传递 OSS URL 和文件类型) @@ -567,6 +584,8 @@ async def upload_chat_file( file_size=file_record.file_size, status=file_record.status, chunk_count=file_record.chunk_count, + progress_percent=30, + processing_stage=STAGE_PROCESSING, created_at=file_record.created_at, file_url=file_url # 返回 OSS URL ).dict() @@ -644,6 +663,8 @@ async def get_chat_thread_files( file_size=f.file_size, status=f.status, chunk_count=f.chunk_count, + progress_percent=f.progress_percent, + processing_stage=f.processing_stage, created_at=f.created_at, file_url=f.file_path # file_path 存储的是 OSS URL ).dict() @@ -734,6 +755,9 @@ async def get_file_processing_status( "file_type": file.file_type, "status": file.status, "chunk_count": file.chunk_count, + "progress_percent": file.progress_percent, + "processing_stage": file.processing_stage, + "stage_label": stage_label(file.processing_stage), "created_at": file.created_at.isoformat() if file.created_at else None, "updated_at": file.updated_at.isoformat() if file.updated_at else None, } diff --git a/backend/api/kb_file_router.py b/backend/api/kb_file_router.py index 0d7918e..bf4de61 100644 --- a/backend/api/kb_file_router.py +++ b/backend/api/kb_file_router.py @@ -25,6 +25,14 @@ from services.audit_service import AuditService from services.vector_service import get_vector_service from services.kb_text_limits import decode_txt_char_count, validate_kb_text_length from services.oss_service import get_oss_service +from services.file_progress import ( + FileProgressReporter, + make_db_progress_callback, + STAGE_PROCESSING, + STAGE_DOWNLOADING, + STAGE_SUMMARIZING, + stage_label, +) from utils.helpers import BaseResponse from logger.logging import get_logger @@ -82,14 +90,18 @@ async def process_file_background( file_type: 文件类型 """ pool = await get_db_pool() + progress_cb = make_db_progress_callback(pool, file_id, KnowledgeBaseFileService) + progress = FileProgressReporter(progress_cb) async with pool.acquire() as conn: local_file_path = None try: logger.info(f"开始后台处理文件 ID: {file_id}, 路径: {file_path}, 类型: {file_type}") + await progress.report(30, STAGE_PROCESSING) oss_service = get_oss_service() if oss_service.enabled and file_path.startswith(('http://', 'https://')): logger.info(f"检测到 OSS URL,开始下载文件: {file_path}") + await progress.report(32, STAGE_DOWNLOADING) oss_object_name = oss_service.extract_object_name_from_url(file_path, knowledge_base_id) if not oss_object_name: logger.error(f"无法从 OSS URL 提取对象名称: {file_path}") @@ -114,7 +126,8 @@ async def process_file_background( knowledge_base_id, file_type, file_id=file_id, - source_url=file_path # 🔑 传递原始 OSS URL + source_url=file_path, # 🔑 传递原始 OSS URL + progress=progress, ) # 检查处理结果 @@ -126,6 +139,7 @@ async def process_file_background( # 生成文件摘要 summary_text = None try: + await progress.report(92, STAGE_SUMMARIZING) from services.summary_service import SummaryService from langchain_core.documents import Document @@ -480,6 +494,9 @@ async def upload_file( # 添加后台任务 logger.info(f"🚀 添加后台向量化任务: file_id={file_record.id}, type={file_type}") + await KnowledgeBaseFileService.update_file_progress( + conn, file_record.id, 30, STAGE_PROCESSING + ) background_tasks.add_task(process_file_background, file_record.id, file_path, kb_id, file_type) return BaseResponse( @@ -491,6 +508,8 @@ async def upload_file( file_size=file_record.file_size, status=file_record.status, chunk_count=file_record.chunk_count, + progress_percent=30, + processing_stage=STAGE_PROCESSING, created_at=file_record.created_at, file_url=file_url or file_path ).dict() @@ -575,6 +594,8 @@ async def get_knowledge_base_files( "file_type": r["file_type"], "status": r["status"], "chunk_count": r["chunk_count"], + "progress_percent": r.get("progress_percent", 0), + "processing_stage": r.get("processing_stage"), "created_at": r["created_at"].isoformat() if r.get("created_at") else None, "file_url": r["file_path"], "uploader_name": r.get("uploader_name"), @@ -613,6 +634,8 @@ async def get_file_detail( file_size=file.file_size, status=file.status, chunk_count=file.chunk_count, + progress_percent=file.progress_percent, + processing_stage=file.processing_stage, created_at=file.created_at, file_url=file.file_path ).dict() @@ -652,6 +675,9 @@ async def get_file_processing_status( "file_type": file.file_type, "status": file.status, "chunk_count": file.chunk_count, + "progress_percent": file.progress_percent, + "processing_stage": file.processing_stage, + "stage_label": stage_label(file.processing_stage), "created_at": file.created_at.isoformat() if file.created_at else None, "updated_at": file.updated_at.isoformat() if file.updated_at else None, } diff --git a/backend/core/config.py b/backend/core/config.py index c41b8bf..1ec048c 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -11,11 +11,8 @@ from urllib.parse import quote_plus from pydantic import AliasChoices, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -# backend/ 目录 -# 编译为二进制后 __file__ 路径不可靠,优先使用环境变量 APP_DIR, -# 其次使用当前工作目录(Docker WORKDIR /app 固定为 /app)。 -import os as _os -_BACKEND_DIR = Path(_os.environ.get("APP_DIR", "")).resolve() if _os.environ.get("APP_DIR") else Path(_os.getcwd()) +# backend/ 目录(与 uvicorn CWD 无关,始终读取该目录下的 .env) +_BACKEND_DIR = Path(__file__).resolve().parent.parent class Settings(BaseSettings): @@ -105,7 +102,6 @@ class Settings(BaseSettings): ocr_access_key_id: Optional[str] = None ocr_access_key_secret: Optional[str] = None ocr_endpoint: str = "ocr-api.cn-hangzhou.aliyuncs.com" # OCR 服务端点 - ocr_timeout_seconds: float = 120.0 # OCR 请求超时(含上传图片 body) # 微信小程序配置 wechat_app_id: Optional[str] = None diff --git a/backend/core/llm_catalog.py b/backend/core/llm_catalog.py index fe0a2ac..fd1d853 100644 --- a/backend/core/llm_catalog.py +++ b/backend/core/llm_catalog.py @@ -248,6 +248,7 @@ def build_chat_model( p = normalize_provider(provider) if p == "tongyi": api_key = (os.getenv("DASHSCOPE_API_KEY") or "").strip() + print("-----------------------------------api_key: ", api_key) if not api_key: raise ValueError("缺少 DASHSCOPE_API_KEY") base_url = llm_env.tongyi_openai_compatible_base_url().strip().rstrip("/") diff --git a/backend/migrations/20260616_add_file_progress.sql b/backend/migrations/20260616_add_file_progress.sql new file mode 100644 index 0000000..80047b9 --- /dev/null +++ b/backend/migrations/20260616_add_file_progress.sql @@ -0,0 +1,58 @@ +-- 文件/任务处理进度(方案 B:真实百分比 + 阶段) +-- 在 PostgreSQL 上执行本脚本后再部署新版后端与前端。 + +-- 知识库文件 +ALTER TABLE public.knowledge_base_file + ADD COLUMN IF NOT EXISTS progress_percent smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS processing_stage varchar(50) NULL; + +ALTER TABLE public.knowledge_base_file + DROP CONSTRAINT IF EXISTS ck_kb_file_progress_percent; +ALTER TABLE public.knowledge_base_file + ADD CONSTRAINT ck_kb_file_progress_percent + CHECK (progress_percent >= 0 AND progress_percent <= 100); + +COMMENT ON COLUMN public.knowledge_base_file.progress_percent IS '处理进度 0-100'; +COMMENT ON COLUMN public.knowledge_base_file.processing_stage IS 'processing 阶段: uploading/parsing/ocr/embedding/summarizing/completed 等'; + +-- 聊天文件 +ALTER TABLE public.chat_thread_file + ADD COLUMN IF NOT EXISTS progress_percent smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS processing_stage varchar(50) NULL; + +ALTER TABLE public.chat_thread_file + DROP CONSTRAINT IF EXISTS ck_chat_thread_file_progress_percent; +ALTER TABLE public.chat_thread_file + ADD CONSTRAINT ck_chat_thread_file_progress_percent + CHECK (progress_percent >= 0 AND progress_percent <= 100); + +COMMENT ON COLUMN public.chat_thread_file.progress_percent IS '处理进度 0-100'; +COMMENT ON COLUMN public.chat_thread_file.processing_stage IS 'processing 阶段'; + +-- 知识图谱 +ALTER TABLE public.graphs + ADD COLUMN IF NOT EXISTS progress_percent smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS processing_stage varchar(50) NULL; + +ALTER TABLE public.graphs + DROP CONSTRAINT IF EXISTS ck_graphs_progress_percent; +ALTER TABLE public.graphs + ADD CONSTRAINT ck_graphs_progress_percent + CHECK (progress_percent >= 0 AND progress_percent <= 100); + +COMMENT ON COLUMN public.graphs.progress_percent IS '构建进度 0-100'; +COMMENT ON COLUMN public.graphs.processing_stage IS '构建阶段: extracting/indexing/completed 等'; + +-- 知识加工任务(可选:与文件上传同属长任务) +ALTER TABLE public.knowledge_processing_task + ADD COLUMN IF NOT EXISTS progress_percent smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS processing_stage varchar(50) NULL; + +ALTER TABLE public.knowledge_processing_task + DROP CONSTRAINT IF EXISTS ck_kb_processing_task_progress_percent; +ALTER TABLE public.knowledge_processing_task + ADD CONSTRAINT ck_kb_processing_task_progress_percent + CHECK (progress_percent >= 0 AND progress_percent <= 100); + +COMMENT ON COLUMN public.knowledge_processing_task.progress_percent IS '任务进度 0-100'; +COMMENT ON COLUMN public.knowledge_processing_task.processing_stage IS '任务阶段'; diff --git a/backend/models/chat_thread_file.py b/backend/models/chat_thread_file.py index 5574c44..c75bd4a 100644 --- a/backend/models/chat_thread_file.py +++ b/backend/models/chat_thread_file.py @@ -17,6 +17,8 @@ class ChatThreadFile(BaseModel): file_type: str = Field(default="pdf", max_length=50) status: str = Field(default="processing", max_length=20) chunk_count: int = 0 + progress_percent: int = 0 + processing_stage: Optional[str] = Field(default=None, max_length=50) created_at: Optional[datetime] = None updated_at: Optional[datetime] = None is_deleted: bool = False @@ -48,6 +50,8 @@ class ChatThreadFileUploadResponse(BaseModel): file_size: int status: str chunk_count: int + progress_percent: int = 0 + processing_stage: Optional[str] = None created_at: datetime file_url: Optional[str] = Field(None, description="文件访问 URL(OSS 或本地路径)") diff --git a/backend/models/knowledge_base_file.py b/backend/models/knowledge_base_file.py index f96b03c..e2ae22b 100644 --- a/backend/models/knowledge_base_file.py +++ b/backend/models/knowledge_base_file.py @@ -17,6 +17,8 @@ class KnowledgeBaseFile(BaseModel): file_type: str = Field(default="pdf", max_length=50) status: str = Field(default="processing", max_length=20) chunk_count: int = 0 + progress_percent: int = 0 + processing_stage: Optional[str] = Field(default=None, max_length=50) created_at: Optional[datetime] = None updated_at: Optional[datetime] = None is_deleted: bool = False @@ -48,6 +50,8 @@ class FileUploadResponse(BaseModel): file_size: int status: str chunk_count: int + progress_percent: int = 0 + processing_stage: Optional[str] = None created_at: datetime file_url: Optional[str] = Field(None, description="文件访问 URL(OSS 或本地路径)") diff --git a/backend/services/chat_thread_file_service.py b/backend/services/chat_thread_file_service.py index fce9e9f..6898eb3 100644 --- a/backend/services/chat_thread_file_service.py +++ b/backend/services/chat_thread_file_service.py @@ -13,6 +13,12 @@ from logger.logging import get_logger logger = get_logger(__name__) +_FILE_COLUMNS = """ +id, thread_id, user_id, file_name, file_path, file_size, +file_type, status, chunk_count, progress_percent, processing_stage, +created_at, updated_at, is_deleted, deleted_at +""" + class ChatThreadFileService: """聊天对话文件服务类""" @@ -57,12 +63,11 @@ class ChatThreadFileService: # 插入文件记录 row = await conn.fetchrow( - """ + f""" INSERT INTO chat_thread_file (thread_id, user_id, file_name, file_path, file_size, file_type, status) VALUES ($1, $2, $3, $4, $5, $6, 'processing') - RETURNING id, thread_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + RETURNING {_FILE_COLUMNS} """, thread_id, user_id, file_name, file_path, file_size, file_type ) @@ -96,14 +101,37 @@ class ChatThreadFileService: bool: 是否更新成功 """ try: - result = await conn.execute( - """ - UPDATE chat_thread_file - SET status = $1, chunk_count = $2 - WHERE id = $3 - """, - status, chunk_count, file_id - ) + if status == "completed": + result = await conn.execute( + """ + UPDATE chat_thread_file + SET status = $1, chunk_count = $2, + progress_percent = 100, processing_stage = 'completed', + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + status, chunk_count, file_id + ) + elif status == "failed": + result = await conn.execute( + """ + UPDATE chat_thread_file + SET status = $1, chunk_count = $2, + progress_percent = 0, processing_stage = 'failed', + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + status, chunk_count, file_id + ) + else: + result = await conn.execute( + """ + UPDATE chat_thread_file + SET status = $1, chunk_count = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + status, chunk_count, file_id + ) return result == "UPDATE 1" @@ -111,6 +139,30 @@ class ChatThreadFileService: logger.error(f"更新文件状态失败: {e}") return False + @staticmethod + async def update_file_progress( + conn: asyncpg.Connection, + file_id: int, + progress_percent: int, + processing_stage: str, + ) -> bool: + """更新文件处理进度(0-100)与阶段。""" + try: + result = await conn.execute( + """ + UPDATE chat_thread_file + SET progress_percent = $1, processing_stage = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + max(0, min(100, int(progress_percent))), + processing_stage, + file_id, + ) + return result == "UPDATE 1" + except Exception as e: + logger.error(f"更新文件进度失败: {e}") + return False + @staticmethod async def save_chunks( conn: asyncpg.Connection, @@ -174,9 +226,8 @@ class ChatThreadFileService: """ try: row = await conn.fetchrow( - """ - SELECT id, thread_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + f""" + SELECT {_FILE_COLUMNS} FROM chat_thread_file WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE """, @@ -278,9 +329,8 @@ class ChatThreadFileService: # 获取列表 rows = await conn.fetch( - """ - SELECT id, thread_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + f""" + SELECT {_FILE_COLUMNS} FROM chat_thread_file WHERE thread_id = $1 AND user_id = $2 AND is_deleted = FALSE ORDER BY created_at DESC @@ -313,9 +363,8 @@ class ChatThreadFileService: """ try: rows = await conn.fetch( - """ - SELECT id, thread_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + f""" + SELECT {_FILE_COLUMNS} FROM chat_thread_file WHERE thread_id = $1 AND is_deleted = FALSE """, diff --git a/backend/services/file_progress.py b/backend/services/file_progress.py new file mode 100644 index 0000000..50e05f4 --- /dev/null +++ b/backend/services/file_progress.py @@ -0,0 +1,83 @@ +""" +文件/长任务处理进度:阶段常量与上报助手。 + +进度约定: + - 0–30:HTTP 上传(主要由前端 onUploadProgress 展示;入库后后端从 30 起) + - 30–90:解析 / OCR / 分块 / 向量化 + - 90–100:摘要与收尾 +""" +from __future__ import annotations + +from typing import Awaitable, Callable, Optional, Type + +import asyncpg + +# ---------- 阶段 ---------- +STAGE_UPLOADING = "uploading" +STAGE_PROCESSING = "processing" +STAGE_DOWNLOADING = "downloading" +STAGE_PARSING = "parsing" +STAGE_OCR = "ocr" +STAGE_SPLITTING = "splitting" +STAGE_EMBEDDING = "embedding" +STAGE_SUMMARIZING = "summarizing" +STAGE_EXTRACTING = "extracting" +STAGE_INDEXING = "indexing" +STAGE_COMPLETED = "completed" +STAGE_FAILED = "failed" + +ProgressCallback = Callable[[int, str], Awaitable[None]] + +STAGE_LABELS: dict[str, str] = { + STAGE_UPLOADING: "上传中", + STAGE_PROCESSING: "处理中", + STAGE_DOWNLOADING: "下载文件", + STAGE_PARSING: "解析文档", + STAGE_OCR: "文字识别", + STAGE_SPLITTING: "文本分块", + STAGE_EMBEDDING: "向量化", + STAGE_SUMMARIZING: "生成摘要", + STAGE_EXTRACTING: "抽取实体关系", + STAGE_INDEXING: "建立索引", + STAGE_COMPLETED: "已完成", + STAGE_FAILED: "失败", +} + + +async def _noop_progress(_percent: int, _stage: str) -> None: + pass + + +class FileProgressReporter: + """节流上报,避免同一进度重复写库。""" + + def __init__(self, callback: Optional[ProgressCallback] = None): + self._callback = callback or _noop_progress + self._last_percent = -1 + + async def report(self, percent: int, stage: str) -> None: + percent = max(0, min(100, int(percent))) + if percent <= self._last_percent: + return + self._last_percent = percent + await self._callback(percent, stage) + + +def make_db_progress_callback( + pool, + file_id: int, + service_cls: Type, +) -> ProgressCallback: + """为知识库/聊天文件创建写库进度回调。""" + + async def _callback(percent: int, stage: str) -> None: + async with pool.acquire() as conn: + await service_cls.update_file_progress(conn, file_id, percent, stage) + + return _callback + + +def stage_label(stage: Optional[str]) -> str: + if not stage: + return "处理中" + return STAGE_LABELS.get(stage, stage) diff --git a/backend/services/knowledge_base_file_service.py b/backend/services/knowledge_base_file_service.py index 53bc5bf..b0423ce 100644 --- a/backend/services/knowledge_base_file_service.py +++ b/backend/services/knowledge_base_file_service.py @@ -13,6 +13,12 @@ from logger.logging import get_logger logger = get_logger(__name__) +_FILE_COLUMNS = """ +id, knowledge_base_id, user_id, file_name, file_path, file_size, +file_type, status, chunk_count, progress_percent, processing_stage, +created_at, updated_at, is_deleted, deleted_at +""" + class KnowledgeBaseFileService: """知识库文件服务类""" @@ -25,9 +31,8 @@ class KnowledgeBaseFileService: ) -> Optional[KnowledgeBaseFile]: """按知识库 + 文件名查询未删除的文件记录。""" row = await conn.fetchrow( - """ - SELECT id, knowledge_base_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + f""" + SELECT {_FILE_COLUMNS} FROM knowledge_base_file WHERE knowledge_base_id = $1 AND file_name = $2 AND is_deleted = FALSE """, @@ -69,14 +74,14 @@ class KnowledgeBaseFileService: if existing: if existing.status == "failed": row = await conn.fetchrow( - """ + f""" UPDATE knowledge_base_file SET file_path = $1, file_size = $2, file_type = $3, status = 'processing', chunk_count = 0, + progress_percent = 0, processing_stage = 'processing', user_id = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5 - RETURNING id, knowledge_base_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + RETURNING {_FILE_COLUMNS} """, file_path, file_size, @@ -93,12 +98,11 @@ class KnowledgeBaseFileService: # 插入文件记录 row = await conn.fetchrow( - """ + f""" INSERT INTO knowledge_base_file (knowledge_base_id, user_id, file_name, file_path, file_size, file_type, status) VALUES ($1, $2, $3, $4, $5, $6, 'processing') - RETURNING id, knowledge_base_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + RETURNING {_FILE_COLUMNS} """, knowledge_base_id, user_id, file_name, file_path, file_size, file_type ) @@ -132,14 +136,37 @@ class KnowledgeBaseFileService: bool: 是否更新成功 """ try: - result = await conn.execute( - """ - UPDATE knowledge_base_file - SET status = $1, chunk_count = $2 - WHERE id = $3 - """, - status, chunk_count, file_id - ) + if status == "completed": + result = await conn.execute( + """ + UPDATE knowledge_base_file + SET status = $1, chunk_count = $2, + progress_percent = 100, processing_stage = 'completed', + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + status, chunk_count, file_id + ) + elif status == "failed": + result = await conn.execute( + """ + UPDATE knowledge_base_file + SET status = $1, chunk_count = $2, + progress_percent = 0, processing_stage = 'failed', + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + status, chunk_count, file_id + ) + else: + result = await conn.execute( + """ + UPDATE knowledge_base_file + SET status = $1, chunk_count = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + status, chunk_count, file_id + ) return result == "UPDATE 1" @@ -147,6 +174,30 @@ class KnowledgeBaseFileService: logger.error(f"更新文件状态失败: {e}") return False + @staticmethod + async def update_file_progress( + conn: asyncpg.Connection, + file_id: int, + progress_percent: int, + processing_stage: str, + ) -> bool: + """更新文件处理进度(0-100)与阶段。""" + try: + result = await conn.execute( + """ + UPDATE knowledge_base_file + SET progress_percent = $1, processing_stage = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, + max(0, min(100, int(progress_percent))), + processing_stage, + file_id, + ) + return result == "UPDATE 1" + except Exception as e: + logger.error(f"更新文件进度失败: {e}") + return False + @staticmethod async def save_chunks( conn: asyncpg.Connection, @@ -204,9 +255,8 @@ class KnowledgeBaseFileService: """ try: row = await conn.fetchrow( - """ - SELECT id, knowledge_base_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + f""" + SELECT {_FILE_COLUMNS} FROM knowledge_base_file WHERE id = $1 AND is_deleted = FALSE """, @@ -252,6 +302,7 @@ class KnowledgeBaseFileService: """ SELECT f.id, f.knowledge_base_id, f.user_id, f.file_name, f.file_path, f.file_size, f.file_type, f.status, f.chunk_count, + f.progress_percent, f.processing_stage, f.created_at, f.updated_at, f.is_deleted, f.deleted_at, COALESCE(NULLIF(TRIM(u.display_name),''), u.username) AS uploader_name FROM knowledge_base_file f @@ -316,9 +367,8 @@ class KnowledgeBaseFileService: """ try: rows = await conn.fetch( - """ - SELECT id, knowledge_base_id, user_id, file_name, file_path, file_size, - file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at + f""" + SELECT {_FILE_COLUMNS} FROM knowledge_base_file WHERE knowledge_base_id = $1 """, diff --git a/backend/services/vector_service.py b/backend/services/vector_service.py index a852651..f6bc47d 100644 --- a/backend/services/vector_service.py +++ b/backend/services/vector_service.py @@ -80,12 +80,37 @@ from logger.logging import get_logger from core.config import settings from core.llm_env import tongyi_embedding_api_base, tongyi_embedding_api_key from services.kb_text_limits import validate_kb_text_length, validate_chat_file_text_length +from services.file_progress import ( + FileProgressReporter, + STAGE_PARSING, + STAGE_OCR, + STAGE_SPLITTING, + STAGE_EMBEDDING, +) logger = get_logger(__name__) +# 阿里云 OCR 图片边长限制(见 illegalImageSize 错误说明) +ALIYUN_OCR_MAX_SIDE = 8192 +ALIYUN_OCR_MIN_SIDE = 5 + +_VISION_EXTRACT_PROMPT = ( + "详细描述图片中的内容:场景、人物、物体、图表及所有可见文字(逐字提取)。" + "用通顺中文输出,便于后续检索与问答。" +) + # 通义 text-embedding-v4(OpenAI 兼容)单次请求最多 10 条,超出会 400/500 _TONGYI_EMBEDDING_MAX_BATCH = 10 +# 阿里云 OCR 图片边长限制:https://help.aliyun.com/document_detail/442266.html +ALIYUN_OCR_MAX_SIDE = 8192 +ALIYUN_OCR_MIN_SIDE = 5 + +_VISION_IMAGE_PROMPT = ( + "详细描述图片中的内容:场景、人物、物体、图表及所有可见文字(逐字提取)。" + "用通顺中文输出,便于后续检索与问答。" +) + class TongyiEmbeddings(Embeddings): """通义千问 Embedding 封装:固定走 ZL/DashScope 网关,并按 API 上限分批请求。""" @@ -205,21 +230,15 @@ class VectorService: # 初始化阿里云 OCR(图片、扫描 PDF、DOCX 内嵌图均依赖云端识别) self.ocr_engine = None - self._ocr_timeout_ms = int(settings.ocr_timeout_seconds * 1000) if ALIYUN_OCR_AVAILABLE and settings.ocr_access_key_id and settings.ocr_access_key_secret: try: config = open_api_models.Config( access_key_id=settings.ocr_access_key_id, access_key_secret=settings.ocr_access_key_secret, - endpoint=settings.ocr_endpoint, - connect_timeout=self._ocr_timeout_ms, - read_timeout=self._ocr_timeout_ms, + endpoint=settings.ocr_endpoint ) self.ocr_engine = OcrClient(config) - logger.info( - "✅ 阿里云 OCR 已启用,将使用云端 OCR 服务识别图片文字 " - f"(endpoint={settings.ocr_endpoint}, timeout={settings.ocr_timeout_seconds}s)" - ) + logger.info("✅ 阿里云 OCR 已启用,将使用云端 OCR 服务识别图片文字") except Exception as e: logger.warning(f"⚠️ 阿里云 OCR 初始化失败: {e}") elif not ALIYUN_OCR_AVAILABLE: @@ -229,6 +248,89 @@ class VectorService: if not self.ocr_engine: logger.warning("⚠️ OCR 服务不可用,图片与扫描件内容将无法通过 OCR 提取。请配置阿里云 OCR") + + def _resize_image_bytes_for_ocr(self, image_bytes: bytes) -> bytes: + """将图片缩放到阿里云 OCR 允许的像素范围内。""" + if not PILLOW_AVAILABLE or not image_bytes: + return image_bytes + try: + with Image.open(io.BytesIO(image_bytes)) as img: + w, h = img.size + if ( + ALIYUN_OCR_MIN_SIDE <= w <= ALIYUN_OCR_MAX_SIDE + and ALIYUN_OCR_MIN_SIDE <= h <= ALIYUN_OCR_MAX_SIDE + ): + return image_bytes + + scale = min(ALIYUN_OCR_MAX_SIDE / w, ALIYUN_OCR_MAX_SIDE / h, 1.0) + if w < ALIYUN_OCR_MIN_SIDE or h < ALIYUN_OCR_MIN_SIDE: + scale = max(scale, ALIYUN_OCR_MIN_SIDE / min(w, h)) + + new_w = max(ALIYUN_OCR_MIN_SIDE, min(ALIYUN_OCR_MAX_SIDE, int(w * scale))) + new_h = max(ALIYUN_OCR_MIN_SIDE, min(ALIYUN_OCR_MAX_SIDE, int(h * scale))) + if (new_w, new_h) == (w, h): + return image_bytes + + rgb = img.convert("RGB") + resized = rgb.resize((new_w, new_h), Image.Resampling.LANCZOS) + buf = io.BytesIO() + fmt = "PNG" if image_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "JPEG" + save_kwargs = {"format": fmt} + if fmt == "JPEG": + save_kwargs["quality"] = 90 + resized.save(buf, **save_kwargs) + logger.info(f"📐 [OCR] 图片已缩放: {w}x{h} -> {new_w}x{new_h}") + return buf.getvalue() + except Exception as e: + logger.warning(f"图片缩放失败,将使用原图尝试 OCR: {e}") + return image_bytes + + def _ensure_image_file_for_ocr(self, image_path: str) -> None: + """就地规范化图片文件,满足阿里云 OCR 像素限制。""" + try: + with open(image_path, "rb") as f: + raw = f.read() + normalized = self._resize_image_bytes_for_ocr(raw) + if normalized != raw: + with open(image_path, "wb") as f: + f.write(normalized) + except OSError as e: + logger.warning(f"规范化 OCR 图片文件失败: {image_path}, {e}") + + async def _describe_image_file_with_vision(self, image_path: str) -> str: + """使用视觉模型理解单张图片(本地文件)。""" + from services.vision_service import VisionService + + try: + with open(image_path, "rb") as f: + image_bytes = f.read() + except OSError as e: + logger.warning(f"读取图片失败,无法使用视觉模型: {image_path}, {e}") + return "" + + ext = os.path.splitext(image_path)[1].lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".bmp": "image/bmp", + ".webp": "image/webp", + ".gif": "image/gif", + } + vision_text = await VisionService.get_image_description_from_bytes( + image_bytes, + prompt=_VISION_EXTRACT_PROMPT, + mime_hint=mime_map.get(ext, "image/jpeg"), + ) + return (vision_text or "").strip() + + def _cleanup_temp_image_paths(self, image_paths: List[str]) -> None: + for img_path in image_paths: + try: + if os.path.exists(img_path): + os.remove(img_path) + except OSError: + pass def _ocr_image(self, image_path: str) -> str: """ @@ -265,6 +367,8 @@ class VectorService: # 读取图片文件为字节流 with open(image_path, 'rb') as f: image_bytes = f.read() + + image_bytes = self._resize_image_bytes_for_ocr(image_bytes) image_size_kb = len(image_bytes) / 1024 logger.info(f"📊 [阿里云OCR] 图片大小: {image_size_kb:.2f}KB") @@ -276,13 +380,8 @@ class VectorService: # 构建请求 request = ocr_models.RecognizeGeneralRequest(body=body_stream) - # 运行时选项(默认超时过短会导致大图片 write timeout) - runtime = util_models.RuntimeOptions( - connect_timeout=self._ocr_timeout_ms, - read_timeout=self._ocr_timeout_ms, - autoretry=True, - max_attempts=2, - ) + # 运行时选项 + runtime = util_models.RuntimeOptions() logger.debug(f"☁️ [阿里云OCR] 调用 API: recognize_general_with_options") # 调用阿里云 OCR API(使用 with_options 版本) @@ -387,7 +486,6 @@ class VectorService: 处理知识库图片:优先 OCR;OCR 无文字或失败时,必须使用视觉模型提取内容。 """ from langchain_core.documents import Document - from services.vision_service import VisionService ocr_docs: List = [] try: @@ -399,41 +497,17 @@ class VectorService: return ocr_docs logger.info("OCR 未识别到文字,使用视觉模型处理图片...") - vision_prompt = ( - "详细描述图片中的内容:场景、人物、物体、图表及所有可见文字(逐字提取)。" - "用通顺中文输出,便于后续检索与问答。" - ) - try: - with open(file_path, "rb") as f: - image_bytes = f.read() - except OSError as e: - logger.error(f"读取图片文件失败,无法进行视觉理解: {e}") - return [] + vision_text = await self._describe_image_file_with_vision(file_path) - ext = os.path.splitext(file_path)[1].lower() - mime_map = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".bmp": "image/bmp", - ".webp": "image/webp", - ".gif": "image/gif", - } - vision_text = await VisionService.get_image_description_from_bytes( - image_bytes, - prompt=vision_prompt, - mime_hint=mime_map.get(ext, "image/jpeg"), - ) - - if not vision_text or not vision_text.strip(): + if not vision_text: logger.warning(f"视觉模型也未提取到内容: {file_path}") return [] logger.info(f"视觉模型成功提取图片内容,共 {len(vision_text)} 字符") return [ Document( - page_content=f"【图片内容描述】\n{vision_text.strip()}", + page_content=f"【图片内容描述】\n{vision_text}", metadata={ "source": file_path, "file_type": "image", @@ -444,6 +518,83 @@ class VectorService: ) ] + async def _process_pdf_with_ocr_and_vision_fallback(self, file_path: str) -> List: + """ + 处理扫描版 PDF:逐页 OCR;单页 OCR 失败或无文字时,回退视觉模型。 + """ + from langchain_core.documents import Document + + image_paths = await asyncio.to_thread(self._extract_images_from_pdf, file_path) + if not image_paths: + logger.warning("未能从 PDF 提取任何页面") + return [] + + page_texts: List[str] = [] + ocr_page_count = 0 + vision_page_count = 0 + + try: + for img_path in image_paths: + await asyncio.to_thread(self._ensure_image_file_for_ocr, img_path) + + ocr_results: List[Tuple[int, str]] = [] + if self.ocr_engine: + logger.info(f"开始并发 OCR 识别 {len(image_paths)} 页 PDF(并发数: 4)") + ocr_results = await asyncio.to_thread( + self._ocr_images_concurrent, image_paths, 4 + ) + else: + logger.warning("OCR 不可用,扫描版 PDF 将直接使用视觉模型逐页识别") + ocr_results = [(idx, "") for idx in range(len(image_paths))] + + for idx, ocr_text in ocr_results: + page_no = idx + 1 + if ocr_text and ocr_text.strip(): + page_texts.append(f"[第 {page_no} 页]\n{ocr_text.strip()}") + ocr_page_count += 1 + logger.info(f"第 {page_no} 页 OCR 识别到 {len(ocr_text)} 字符") + continue + + logger.info(f"第 {page_no} 页 OCR 无结果,尝试视觉模型...") + try: + vision_text = await self._describe_image_file_with_vision(image_paths[idx]) + if vision_text: + page_texts.append(f"[第 {page_no} 页 - 视觉理解]\n{vision_text}") + vision_page_count += 1 + logger.info(f"第 {page_no} 页视觉模型提取 {len(vision_text)} 字符") + else: + logger.warning(f"第 {page_no} 页视觉模型也未提取到内容") + except Exception as e: + logger.warning(f"第 {page_no} 页视觉模型失败: {e}") + + if not page_texts: + logger.warning("PDF OCR/视觉均未识别到任何文字内容") + return [] + + full_content = "\n\n".join(page_texts) + logger.info( + f"PDF 处理完成:共 {len(page_texts)} 页有效内容 " + f"(OCR {ocr_page_count} 页, 视觉 {vision_page_count} 页)," + f"总计 {len(full_content)} 字符" + ) + return [ + Document( + page_content=full_content, + metadata={ + "source": file_path, + "file_type": "pdf", + "is_image_pdf": True, + "page_count": len(page_texts), + "has_ocr": ocr_page_count > 0, + "has_vision": vision_page_count > 0, + "ocr_provider": "aliyun" if ocr_page_count > 0 else "", + "vision_provider": "qwen-vl" if vision_page_count > 0 else "", + }, + ) + ] + finally: + self._cleanup_temp_image_paths(image_paths) + def _extract_images_from_docx(self, docx_path: str) -> List[str]: """ 从 DOCX 文件中提取所有图片并转换为标准格式(PNG/JPG) @@ -620,6 +771,8 @@ class VectorService: extracted_image_paths = image_paths.copy() # 保存副本供后续使用 if image_paths: + for img_path in image_paths: + self._ensure_image_file_for_ocr(img_path) # 使用多线程并发处理(最多并发 4 张图片) logger.info(f"开始并发 OCR 识别 {len(image_paths)} 张图片(并发数: 4)") ocr_results = self._ocr_images_concurrent(image_paths, max_workers=4) @@ -686,17 +839,20 @@ class VectorService: try: logger.debug(f" 🔄 [PDF页面提取] 处理第 {page_num + 1}/{total_pages} 页") page = pdf_document[page_num] - - # 将页面转换为图片(提高分辨率以提升 OCR 效果) - # zoom=2 表示 2 倍分辨率(DPI 约 144) - mat = fitz.Matrix(2, 2) + + # 按页尺寸计算缩放,避免超过阿里云 OCR 8192px 上限 + max_dim = max(page.rect.width, page.rect.height, 1.0) + zoom = min(2.0, ALIYUN_OCR_MAX_SIDE / max_dim) + zoom = max(zoom, 0.1) + mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat) # 保存为临时图片文件 tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') pix.save(tmp_file.name) tmp_file.close() - + + self._ensure_image_file_for_ocr(tmp_file.name) file_size_kb = os.path.getsize(tmp_file.name) / 1024 image_paths.append(tmp_file.name) logger.info(f"✅ [PDF页面提取] 第 {page_num + 1} 页已转换: {os.path.basename(tmp_file.name)} ({file_size_kb:.2f}KB)") @@ -760,6 +916,9 @@ class VectorService: if not image_paths: logger.warning("未能从 PDF 提取任何页面") return [] + + for img_path in image_paths: + self._ensure_image_file_for_ocr(img_path) # 2. 使用多线程并发 OCR 识别 logger.info(f"开始并发 OCR 识别 {len(image_paths)} 页 PDF(并发数: 4)") @@ -775,12 +934,7 @@ class VectorService: logger.warning(f"第 {idx + 1} 页 OCR 未识别到文字") # 4. 清理临时图片文件 - for img_path in image_paths: - try: - if os.path.exists(img_path): - os.remove(img_path) - except: - pass + self._cleanup_temp_image_paths(image_paths) if not page_texts: logger.warning("PDF OCR 未识别到任何文字内容") @@ -927,7 +1081,8 @@ class VectorService: knowledge_base_id: int, file_type: str = "pdf", file_id: Optional[int] = None, - source_url: Optional[str] = None + source_url: Optional[str] = None, + progress: Optional[FileProgressReporter] = None, ) -> ProcessResult: """ 处理文档文件:加载、分割、向量化(支持多种文档格式,包括图片 OCR) @@ -947,6 +1102,8 @@ class VectorService: """ try: logger.info(f"开始处理文件: {file_path}, 类型: {file_type}") + if progress: + await progress.report(35, STAGE_PARSING) # 1. 获取合适的加载器 loader = self._get_loader_for_file(file_path, file_type) @@ -956,9 +1113,13 @@ class VectorService: logger.warning(error_msg) return ProcessResult(success=False, chunks=[], chunk_count=0, error_message=error_msg) + ocr_stage = loader in ("image_ocr", "docx_with_images") or file_type.lower() == "pdf" + # 2. 加载文档(特殊处理图片 OCR 和 DOCX,放到线程池执行) if loader == "image_ocr": logger.info("🔄 处理图片:OCR → 视觉模型回退...") + if progress: + await progress.report(38, STAGE_OCR) docs = await self._process_image_with_vision_fallback(file_path) elif loader == "docx_with_images": logger.info("🔄 在线程池中处理 DOCX 文件(提取图片并 OCR)...") @@ -970,20 +1131,18 @@ class VectorService: # 特殊处理:检测 PDF 是否为图片型(扫描版) if file_type.lower() == "pdf" and self._is_image_pdf(docs): - logger.info("检测到图片型 PDF(扫描版),切换到 OCR 模式") - if self.ocr_engine: - logger.info("🔄 在线程池中执行 PDF OCR...") - docs = await asyncio.to_thread(self._process_pdf_with_ocr, file_path) - if not docs: - error_msg = "图片型 PDF OCR 识别失败" - logger.warning(error_msg) - return ProcessResult(success=False, chunks=[], chunk_count=0, error_message=error_msg) - else: - error_msg = "检测到图片型 PDF(扫描版),但 OCR 服务不可用" + logger.info("检测到图片型 PDF(扫描版),OCR → 视觉模型回退") + if progress: + await progress.report(38, STAGE_OCR) + docs = await self._process_pdf_with_ocr_and_vision_fallback(file_path) + if not docs: + error_msg = "图片型 PDF OCR/视觉识别均失败" logger.warning(error_msg) return ProcessResult(success=False, chunks=[], chunk_count=0, error_message=error_msg) logger.info(f"文档加载完成,共 {len(docs)} 个文档片段") + if progress: + await progress.report(50, STAGE_OCR if ocr_stage else STAGE_PARSING) if not docs: error_msg = ( @@ -1004,6 +1163,8 @@ class VectorService: # 2. 分割文本 all_splits = self.text_splitter.split_documents(docs) logger.info(f"文本分割完成,共 {len(all_splits)} 个块") + if progress: + await progress.report(55, STAGE_SPLITTING) # 检查是否有内容 if not all_splits: @@ -1021,6 +1182,8 @@ class VectorService: # 3. 向量化并存储 collection_name = f"kb_{knowledge_base_id}" vector_store = self.get_vector_store(collection_name) + if progress: + await progress.report(60, STAGE_EMBEDDING) # 🔑 关键:在向量化前,将 file_id、chunk_index 和 source_url 添加到 metadata if file_id is not None or source_url is not None: @@ -1041,6 +1204,8 @@ class VectorService: # 添加文档到向量库 vector_ids = vector_store.add_documents(documents=all_splits) logger.info(f"向量化完成,共 {len(vector_ids)} 个向量") + if progress: + await progress.report(88, STAGE_EMBEDDING) # 4. 准备返回数据 chunks = [] @@ -1166,7 +1331,8 @@ class VectorService: thread_id: str, file_type: str = "pdf", file_id: Optional[int] = None, - source_url: Optional[str] = None + source_url: Optional[str] = None, + progress: Optional[FileProgressReporter] = None, ) -> ProcessResult: """ 处理聊天对话文件:加载、分割、向量化(支持多种格式,包括 URL 和图片 OCR) @@ -1187,9 +1353,12 @@ class VectorService: """ try: logger.info(f"开始处理聊天文件: {file_path}, thread_id: {thread_id}, 类型: {file_type}") + if progress: + await progress.report(35, STAGE_PARSING) docs = [] extracted_image_paths = [] # 用于保存 DOCX 中提取的图片路径 + ocr_stage = file_type.lower() in ("png", "jpg", "jpeg", "bmp", "pdf") # 特殊处理 URL(放到线程池执行) if file_type == "url": @@ -1213,8 +1382,10 @@ class VectorService: # 特殊处理图片 OCR 和 DOCX(放到线程池执行,避免阻塞事件循环) if loader == "image_ocr": - logger.info("🔄 在线程池中执行图片 OCR...") - docs = await asyncio.to_thread(self._process_image_ocr, file_path) + logger.info("🔄 处理图片:OCR → 视觉模型回退...") + if progress: + await progress.report(38, STAGE_OCR) + docs = await self._process_image_with_vision_fallback(file_path) elif loader == "docx_with_images": logger.info("🔄 在线程池中处理 DOCX 文件(提取图片并 OCR)...") docs, extracted_image_paths = await asyncio.to_thread(self._process_docx_with_images, file_path) @@ -1225,21 +1396,20 @@ class VectorService: # 特殊处理:检测 PDF 是否为图片型(扫描版) if file_type.lower() == "pdf" and self._is_image_pdf(docs): - logger.info("检测到图片型 PDF(扫描版),切换到 OCR 模式") - if self.ocr_engine: - logger.info("🔄 在线程池中执行 PDF OCR...") - docs = await asyncio.to_thread(self._process_pdf_with_ocr, file_path) - if not docs: - error_msg = "图片型 PDF OCR 识别失败" - logger.warning(error_msg) - return ProcessResult(success=False, chunks=[], chunk_count=0, error_message=error_msg) - else: - error_msg = "检测到图片型 PDF(扫描版),但 OCR 服务不可用" + logger.info("检测到图片型 PDF(扫描版),OCR → 视觉模型回退") + if progress: + await progress.report(38, STAGE_OCR) + docs = await self._process_pdf_with_ocr_and_vision_fallback(file_path) + if not docs: + error_msg = "图片型 PDF OCR/视觉识别均失败" logger.warning(error_msg) return ProcessResult(success=False, chunks=[], chunk_count=0, error_message=error_msg) logger.info(f"文档加载完成,共 {len(docs)} 个文档片段") + if progress and docs: + await progress.report(50, STAGE_OCR if ocr_stage else STAGE_PARSING) + if not docs: error_msg = "未能加载到任何内容" logger.warning(error_msg) @@ -1256,6 +1426,8 @@ class VectorService: # 分割文本 all_splits = self.text_splitter.split_documents(docs) logger.info(f"文本分割完成,共 {len(all_splits)} 个块") + if progress: + await progress.report(55, STAGE_SPLITTING) # 检查是否有内容 if not all_splits: @@ -1273,6 +1445,8 @@ class VectorService: # 向量化并存储(使用 thread_id 作为集合名) collection_name = f"thread_{thread_id}" vector_store = self.get_vector_store(collection_name) + if progress: + await progress.report(60, STAGE_EMBEDDING) # 🔑 关键:在向量化前,将 file_id、chunk_index 和 source_url 添加到 metadata if file_id is not None or source_url is not None: @@ -1292,6 +1466,8 @@ class VectorService: # 添加文档到向量库 vector_ids = vector_store.add_documents(documents=all_splits) logger.info(f"向量化完成,共 {len(vector_ids)} 个向量") + if progress: + await progress.report(88, STAGE_EMBEDDING) # 准备返回数据 chunks = [] diff --git a/ddl.sql b/ddl.sql new file mode 100644 index 0000000..a1cf69a --- /dev/null +++ b/ddl.sql @@ -0,0 +1,2117 @@ +-- +-- PostgreSQL database dump +-- + +\restrict IIs1ywVfui7Zb3f5SRgOBwj2LGJXpFF5jpVnQbpR7cCgjNpjBLehhEAmULW9yma + +-- Dumped from database version 17.4 (Debian 17.4-1.pgdg120+2) +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: chat_message_file; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_message_file ( + id integer NOT NULL, + thread_id character varying(255) NOT NULL, + checkpoint_id character varying(255) NOT NULL, + message_index integer NOT NULL, + file_id integer NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +-- +-- Name: chat_message_file_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_message_file_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_message_file_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_message_file_id_seq OWNED BY public.chat_message_file.id; + + +-- +-- Name: chat_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_messages ( + id integer NOT NULL, + thread_id character varying(255) NOT NULL, + checkpoint_id character varying(255) NOT NULL, + message_index integer NOT NULL, + role character varying(20) NOT NULL, + content text NOT NULL, + injected_content text, + has_files boolean DEFAULT false, + metadata jsonb, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + name character varying(255) +); + + +-- +-- Name: TABLE chat_messages; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.chat_messages IS '聊天消息表,存储用户原始消息和AI响应的关键信息'; + + +-- +-- Name: COLUMN chat_messages.thread_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.thread_id IS '会话线程ID'; + + +-- +-- Name: COLUMN chat_messages.checkpoint_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.checkpoint_id IS '关联的checkpoint ID'; + + +-- +-- Name: COLUMN chat_messages.message_index; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.message_index IS '消息在checkpoint中的索引(从0开始)'; + + +-- +-- Name: COLUMN chat_messages.role; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.role IS '消息角色:user、assistant、system、tool'; + + +-- +-- Name: COLUMN chat_messages.content; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.content IS '用户的原始问题或AI的响应'; + + +-- +-- Name: COLUMN chat_messages.injected_content; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.injected_content IS '注入给AI的完整内容(包含文件内容)'; + + +-- +-- Name: COLUMN chat_messages.has_files; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.has_files IS '是否关联了文件'; + + +-- +-- Name: COLUMN chat_messages.metadata; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.metadata IS '额外信息:token、模型、推理内容等'; + + +-- +-- Name: COLUMN chat_messages.name; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_messages.name IS '工具消息时的工具名称(如 internet_search、text_to_image)'; + + +-- +-- Name: chat_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_messages_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_messages_id_seq OWNED BY public.chat_messages.id; + + +-- +-- Name: chat_thread_chunk; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_thread_chunk ( + id integer NOT NULL, + file_id integer NOT NULL, + thread_id character varying(255) NOT NULL, + chunk_index integer NOT NULL, + content text NOT NULL, + metadata jsonb, + vector_id character varying(255), + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + summary text +); + + +-- +-- Name: chat_thread_chunk_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_thread_chunk_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_thread_chunk_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_thread_chunk_id_seq OWNED BY public.chat_thread_chunk.id; + + +-- +-- Name: chat_thread_file; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_thread_file ( + id integer NOT NULL, + thread_id character varying(255) NOT NULL, + user_id integer NOT NULL, + file_name character varying(255) NOT NULL, + file_path character varying(500) NOT NULL, + file_size integer DEFAULT 0, + file_type character varying(50) DEFAULT 'pdf'::character varying, + status character varying(20) DEFAULT 'processing'::character varying, + chunk_count integer DEFAULT 0, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + is_deleted boolean DEFAULT false, + deleted_at timestamp with time zone, + progress_percent smallint DEFAULT 0 NOT NULL, + processing_stage character varying(50), + CONSTRAINT ck_chat_thread_file_progress_percent CHECK (((progress_percent >= 0) AND (progress_percent <= 100))) +); + + +-- +-- Name: COLUMN chat_thread_file.progress_percent; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_thread_file.progress_percent IS '处理进度 0-100'; + + +-- +-- Name: COLUMN chat_thread_file.processing_stage; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_thread_file.processing_stage IS 'processing 阶段'; + + +-- +-- Name: chat_thread_file_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_thread_file_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_thread_file_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_thread_file_id_seq OWNED BY public.chat_thread_file.id; + + +-- +-- Name: chat_threads; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_threads ( + id integer NOT NULL, + thread_id character varying(255) NOT NULL, + user_id integer NOT NULL, + title character varying(50) NOT NULL, + first_query text NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + message_count integer DEFAULT 1, + is_deleted boolean DEFAULT false, + knowledge_base_id integer, + novel_graph_id integer, + knowledge_graph_id integer, + ip character varying(128) +); + + +-- +-- Name: TABLE chat_threads; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.chat_threads IS '聊天会话记录表,记录每个用户的会话基本信息'; + + +-- +-- Name: COLUMN chat_threads.id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.id IS '主键 ID'; + + +-- +-- Name: COLUMN chat_threads.thread_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.thread_id IS '会话线程 ID(UUID 格式)'; + + +-- +-- Name: COLUMN chat_threads.user_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.user_id IS '用户 ID,关联 user_list 表'; + + +-- +-- Name: COLUMN chat_threads.title; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.title IS '会话标题(首次请求内容的前10个字)'; + + +-- +-- Name: COLUMN chat_threads.first_query; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.first_query IS '首次请求的完整内容'; + + +-- +-- Name: COLUMN chat_threads.created_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.created_at IS '会话创建时间'; + + +-- +-- Name: COLUMN chat_threads.updated_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.updated_at IS '最后更新时间'; + + +-- +-- Name: COLUMN chat_threads.message_count; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.message_count IS '该会话的消息总数'; + + +-- +-- Name: COLUMN chat_threads.is_deleted; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.is_deleted IS '是否已删除(软删除标记)'; + + +-- +-- Name: COLUMN chat_threads.knowledge_graph_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.knowledge_graph_id IS '绑定的知识图谱 graphs.id,与 knowledge_base_id 二选一'; + + +-- +-- Name: COLUMN chat_threads.ip; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.chat_threads.ip IS '最近一次发起聊天时的客户端 IP(可选)'; + + +-- +-- Name: chat_threads_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_threads_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_threads_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_threads_id_seq OWNED BY public.chat_threads.id; + + +-- +-- Name: checkpoint_blobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.checkpoint_blobs ( + thread_id text NOT NULL, + checkpoint_ns text DEFAULT ''::text NOT NULL, + channel text NOT NULL, + version text NOT NULL, + type text NOT NULL, + blob bytea +); + + +-- +-- Name: checkpoint_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.checkpoint_migrations ( + v integer NOT NULL +); + + +-- +-- Name: checkpoint_writes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.checkpoint_writes ( + thread_id text NOT NULL, + checkpoint_ns text DEFAULT ''::text NOT NULL, + checkpoint_id text NOT NULL, + task_id text NOT NULL, + idx integer NOT NULL, + channel text NOT NULL, + type text, + blob bytea NOT NULL, + task_path text DEFAULT ''::text NOT NULL +); + + +-- +-- Name: checkpoints; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.checkpoints ( + thread_id text NOT NULL, + checkpoint_ns text DEFAULT ''::text NOT NULL, + checkpoint_id text NOT NULL, + parent_checkpoint_id text, + type text, + checkpoint jsonb NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL +); + + +-- +-- Name: department; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.department ( + id integer NOT NULL, + enterprise_id integer NOT NULL, + name character varying(255) NOT NULL, + parent_id integer, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + leader_user_id integer +); + + +-- +-- Name: TABLE department; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.department IS '部门'; + + +-- +-- Name: COLUMN department.leader_user_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.department.leader_user_id IS '部门负责人 user_id,对应 role=leader 的用户'; + + +-- +-- Name: department_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.department_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: department_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.department_id_seq OWNED BY public.department.id; + + +-- +-- Name: enterprise; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.enterprise ( + id integer NOT NULL, + name character varying(255) NOT NULL, + code character varying(64), + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + ai_display_name character varying(128) DEFAULT '智能助手 AI'::character varying NOT NULL +); + + +-- +-- Name: TABLE enterprise; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.enterprise IS '企业(单租户部署通常仅一条记录)'; + + +-- +-- Name: COLUMN enterprise.ai_display_name; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.enterprise.ai_display_name IS 'AI 助手对外展示名称(写入系统提示词等),管理端可修改'; + + +-- +-- Name: enterprise_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.enterprise_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: enterprise_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.enterprise_id_seq OWNED BY public.enterprise.id; + + +-- +-- Name: graphs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.graphs ( + id integer NOT NULL, + user_id integer NOT NULL, + name character varying(255) NOT NULL, + description text, + csv_file_name character varying(255), + node_count integer DEFAULT 0, + edge_count integer DEFAULT 0, + neo4j_graph_id character varying(100) NOT NULL, + graph_type character varying(20) DEFAULT 'knowledge'::character varying NOT NULL, + build_status character varying(20), + build_error text, + rag_chunk_count integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + enterprise_id integer NOT NULL, + department_id integer, + creator_id integer NOT NULL, + visibility character varying(32) DEFAULT 'private'::character varying NOT NULL, + progress_percent smallint DEFAULT 0 NOT NULL, + processing_stage character varying(50), + CONSTRAINT ck_graphs_progress_percent CHECK (((progress_percent >= 0) AND (progress_percent <= 100))), + CONSTRAINT ck_graphs_visibility CHECK (((visibility)::text = ANY ((ARRAY['private'::character varying, 'department'::character varying, 'enterprise'::character varying])::text[]))) +); + + +-- +-- Name: TABLE graphs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.graphs IS '知识图谱元数据表,图数据在 Neo4j;向量块数量见 rag_chunk_count'; + + +-- +-- Name: COLUMN graphs.neo4j_graph_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.neo4j_graph_id IS 'Neo4j 中图谱唯一标识'; + + +-- +-- Name: COLUMN graphs.graph_type; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.graph_type IS '兼容字段,默认 knowledge'; + + +-- +-- Name: COLUMN graphs.build_status; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.build_status IS '构建状态:pending/processing/completed/failed'; + + +-- +-- Name: COLUMN graphs.build_error; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.build_error IS '构建失败时的错误信息'; + + +-- +-- Name: COLUMN graphs.rag_chunk_count; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.rag_chunk_count IS 'Chroma 中知识图谱 RAG 分块数量'; + + +-- +-- Name: COLUMN graphs.creator_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.creator_id IS '创建者(与 user_id 通常一致,用于权限判断)'; + + +-- +-- Name: COLUMN graphs.visibility; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.visibility IS 'private | department | enterprise'; + + +-- +-- Name: COLUMN graphs.progress_percent; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.progress_percent IS '构建进度 0-100'; + + +-- +-- Name: COLUMN graphs.processing_stage; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.graphs.processing_stage IS '构建阶段: extracting/indexing/completed 等'; + + +-- +-- Name: graphs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.graphs_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: graphs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.graphs_id_seq OWNED BY public.graphs.id; + + +-- +-- Name: kb_audit_log; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.kb_audit_log ( + id integer NOT NULL, + enterprise_id integer NOT NULL, + actor_id integer NOT NULL, + target_user_id integer, + department_id integer, + kb_id integer, + file_id integer, + action character varying(50) NOT NULL, + ip character varying(128), + user_agent text, + metadata jsonb, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: TABLE kb_audit_log; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.kb_audit_log IS '知识库操作审计日志'; + + +-- +-- Name: COLUMN kb_audit_log.action; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.kb_audit_log.action IS 'upload | download | delete | archive | create_kb | delete_kb | permission_change'; + + +-- +-- Name: kb_audit_log_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.kb_audit_log_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: kb_audit_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.kb_audit_log_id_seq OWNED BY public.kb_audit_log.id; + + +-- +-- Name: knowledge_base; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.knowledge_base ( + id integer NOT NULL, + user_id integer NOT NULL, + name character varying(255) NOT NULL, + description text, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + is_deleted boolean DEFAULT false, + deleted_at timestamp with time zone, + enterprise_id integer DEFAULT 1 NOT NULL, + department_id integer, + creator_id integer, + visibility character varying(32) DEFAULT 'private'::character varying NOT NULL, + CONSTRAINT ck_knowledge_base_visibility CHECK (((visibility)::text = ANY ((ARRAY['private'::character varying, 'department'::character varying, 'enterprise'::character varying])::text[]))) +); + + +-- +-- Name: COLUMN knowledge_base.creator_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_base.creator_id IS '创建者(与 user_id 通常一致,用于权限判断)'; + + +-- +-- Name: COLUMN knowledge_base.visibility; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_base.visibility IS 'private | department | enterprise'; + + +-- +-- Name: knowledge_base_chunk; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.knowledge_base_chunk ( + id integer NOT NULL, + file_id integer NOT NULL, + knowledge_base_id integer NOT NULL, + chunk_index integer NOT NULL, + content text NOT NULL, + metadata jsonb, + vector_id character varying(255), + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + summary text +); + + +-- +-- Name: knowledge_base_chunk_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.knowledge_base_chunk_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: knowledge_base_chunk_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.knowledge_base_chunk_id_seq OWNED BY public.knowledge_base_chunk.id; + + +-- +-- Name: knowledge_base_file; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.knowledge_base_file ( + id integer NOT NULL, + knowledge_base_id integer NOT NULL, + user_id integer NOT NULL, + file_name character varying(255) NOT NULL, + file_path character varying(500) NOT NULL, + file_size bigint NOT NULL, + file_type character varying(50) DEFAULT 'pdf'::character varying NOT NULL, + status character varying(20) DEFAULT 'processing'::character varying NOT NULL, + chunk_count integer DEFAULT 0, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + is_deleted boolean DEFAULT false, + deleted_at timestamp with time zone, + progress_percent smallint DEFAULT 0 NOT NULL, + processing_stage character varying(50), + CONSTRAINT ck_kb_file_progress_percent CHECK (((progress_percent >= 0) AND (progress_percent <= 100))) +); + + +-- +-- Name: COLUMN knowledge_base_file.progress_percent; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_base_file.progress_percent IS '处理进度 0-100'; + + +-- +-- Name: COLUMN knowledge_base_file.processing_stage; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_base_file.processing_stage IS 'processing 阶段: uploading/parsing/ocr/embedding/summarizing/completed 等'; + + +-- +-- Name: knowledge_base_file_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.knowledge_base_file_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: knowledge_base_file_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.knowledge_base_file_id_seq OWNED BY public.knowledge_base_file.id; + + +-- +-- Name: knowledge_base_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.knowledge_base_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: knowledge_base_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.knowledge_base_id_seq OWNED BY public.knowledge_base.id; + + +-- +-- Name: knowledge_processing_task; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.knowledge_processing_task ( + id integer NOT NULL, + user_id integer NOT NULL, + knowledge_base_id integer NOT NULL, + task_name character varying(255) NOT NULL, + instruction text NOT NULL, + file_ids integer[] NOT NULL, + task_type character varying(50) NOT NULL, + status character varying(20) DEFAULT 'pending'::character varying, + result text, + result_file_url text, + error_message text, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + started_at timestamp with time zone, + completed_at timestamp with time zone, + progress_percent smallint DEFAULT 0 NOT NULL, + processing_stage character varying(50), + CONSTRAINT ck_kb_processing_task_progress_percent CHECK (((progress_percent >= 0) AND (progress_percent <= 100))) +); + + +-- +-- Name: TABLE knowledge_processing_task; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.knowledge_processing_task IS '知识加工任务表:合并、对比、总结等异步任务'; + + +-- +-- Name: COLUMN knowledge_processing_task.result_file_url; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_processing_task.result_file_url IS '加工结果文件的 OSS 下载链接'; + + +-- +-- Name: COLUMN knowledge_processing_task.progress_percent; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_processing_task.progress_percent IS '任务进度 0-100'; + + +-- +-- Name: COLUMN knowledge_processing_task.processing_stage; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.knowledge_processing_task.processing_stage IS '任务阶段'; + + +-- +-- Name: knowledge_processing_task_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.knowledge_processing_task_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: knowledge_processing_task_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.knowledge_processing_task_id_seq OWNED BY public.knowledge_processing_task.id; + + +-- +-- Name: user_list; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_list ( + id integer NOT NULL, + username character varying(50) NOT NULL, + email character varying(255) NOT NULL, + phone character varying(255) NOT NULL, + github_id character varying(100), + github_username character varying(100), + github_avatar_url text, + github_access_token text, + github_token_expires_at timestamp with time zone, + display_name character varying(100), + avatar_url text, + bio text, + is_active boolean DEFAULT true, + email_verified boolean DEFAULT false, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + last_login_at timestamp with time zone, + hashed_password character varying(255), + is_search boolean DEFAULT false, + is_reasoner boolean DEFAULT false, + enterprise_id integer DEFAULT 1 NOT NULL, + department_id integer, + role character varying(32) DEFAULT 'employee'::character varying NOT NULL, + is_first_login boolean DEFAULT true NOT NULL, + allow_kb_upload boolean DEFAULT true NOT NULL +); + + +-- +-- Name: COLUMN user_list.role; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.user_list.role IS 'admin | leader | employee'; + + +-- +-- Name: COLUMN user_list.is_first_login; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.user_list.is_first_login IS '首次登录可强制改密(可选业务)'; + + +-- +-- Name: COLUMN user_list.allow_kb_upload; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.user_list.allow_kb_upload IS '是否允许上传文件到知识库(上级领导或 admin 可关闭)'; + + +-- +-- Name: user_list_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_list_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_list_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_list_id_seq OWNED BY public.user_list.id; + + +-- +-- Name: chat_message_file id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_file ALTER COLUMN id SET DEFAULT nextval('public.chat_message_file_id_seq'::regclass); + + +-- +-- Name: chat_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages ALTER COLUMN id SET DEFAULT nextval('public.chat_messages_id_seq'::regclass); + + +-- +-- Name: chat_thread_chunk id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_thread_chunk ALTER COLUMN id SET DEFAULT nextval('public.chat_thread_chunk_id_seq'::regclass); + + +-- +-- Name: chat_thread_file id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_thread_file ALTER COLUMN id SET DEFAULT nextval('public.chat_thread_file_id_seq'::regclass); + + +-- +-- Name: chat_threads id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_threads ALTER COLUMN id SET DEFAULT nextval('public.chat_threads_id_seq'::regclass); + + +-- +-- Name: department id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.department ALTER COLUMN id SET DEFAULT nextval('public.department_id_seq'::regclass); + + +-- +-- Name: enterprise id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enterprise ALTER COLUMN id SET DEFAULT nextval('public.enterprise_id_seq'::regclass); + + +-- +-- Name: graphs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs ALTER COLUMN id SET DEFAULT nextval('public.graphs_id_seq'::regclass); + + +-- +-- Name: kb_audit_log id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log ALTER COLUMN id SET DEFAULT nextval('public.kb_audit_log_id_seq'::regclass); + + +-- +-- Name: knowledge_base id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base ALTER COLUMN id SET DEFAULT nextval('public.knowledge_base_id_seq'::regclass); + + +-- +-- Name: knowledge_base_chunk id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_chunk ALTER COLUMN id SET DEFAULT nextval('public.knowledge_base_chunk_id_seq'::regclass); + + +-- +-- Name: knowledge_base_file id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_file ALTER COLUMN id SET DEFAULT nextval('public.knowledge_base_file_id_seq'::regclass); + + +-- +-- Name: knowledge_processing_task id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_processing_task ALTER COLUMN id SET DEFAULT nextval('public.knowledge_processing_task_id_seq'::regclass); + + +-- +-- Name: user_list id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list ALTER COLUMN id SET DEFAULT nextval('public.user_list_id_seq'::regclass); + + +-- +-- Name: chat_message_file chat_message_file_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_file + ADD CONSTRAINT chat_message_file_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_messages chat_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_thread_chunk chat_thread_chunk_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_thread_chunk + ADD CONSTRAINT chat_thread_chunk_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_thread_file chat_thread_file_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_thread_file + ADD CONSTRAINT chat_thread_file_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_threads chat_threads_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_threads + ADD CONSTRAINT chat_threads_pkey PRIMARY KEY (id); + + +-- +-- Name: checkpoint_blobs checkpoint_blobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.checkpoint_blobs + ADD CONSTRAINT checkpoint_blobs_pkey PRIMARY KEY (thread_id, checkpoint_ns, channel, version); + + +-- +-- Name: checkpoint_migrations checkpoint_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.checkpoint_migrations + ADD CONSTRAINT checkpoint_migrations_pkey PRIMARY KEY (v); + + +-- +-- Name: checkpoint_writes checkpoint_writes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.checkpoint_writes + ADD CONSTRAINT checkpoint_writes_pkey PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx); + + +-- +-- Name: checkpoints checkpoints_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.checkpoints + ADD CONSTRAINT checkpoints_pkey PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id); + + +-- +-- Name: department department_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.department + ADD CONSTRAINT department_pkey PRIMARY KEY (id); + + +-- +-- Name: enterprise enterprise_code_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enterprise + ADD CONSTRAINT enterprise_code_key UNIQUE (code); + + +-- +-- Name: enterprise enterprise_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enterprise + ADD CONSTRAINT enterprise_pkey PRIMARY KEY (id); + + +-- +-- Name: graphs graphs_neo4j_graph_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs + ADD CONSTRAINT graphs_neo4j_graph_id_key UNIQUE (neo4j_graph_id); + + +-- +-- Name: graphs graphs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs + ADD CONSTRAINT graphs_pkey PRIMARY KEY (id); + + +-- +-- Name: kb_audit_log kb_audit_log_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_pkey PRIMARY KEY (id); + + +-- +-- Name: knowledge_base_chunk knowledge_base_chunk_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_chunk + ADD CONSTRAINT knowledge_base_chunk_pkey PRIMARY KEY (id); + + +-- +-- Name: knowledge_base_file knowledge_base_file_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_file + ADD CONSTRAINT knowledge_base_file_pkey PRIMARY KEY (id); + + +-- +-- Name: knowledge_base knowledge_base_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base + ADD CONSTRAINT knowledge_base_pkey PRIMARY KEY (id); + + +-- +-- Name: knowledge_processing_task knowledge_processing_task_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_processing_task + ADD CONSTRAINT knowledge_processing_task_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_messages uk_checkpoint_message; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT uk_checkpoint_message UNIQUE (checkpoint_id, message_index); + + +-- +-- Name: chat_message_file uk_message_file; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_file + ADD CONSTRAINT uk_message_file UNIQUE (checkpoint_id, message_index, file_id); + + +-- +-- Name: chat_threads uk_thread_id; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_threads + ADD CONSTRAINT uk_thread_id UNIQUE (thread_id); + + +-- +-- Name: user_list unique_email; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT unique_email UNIQUE (email); + + +-- +-- Name: user_list unique_github_id; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT unique_github_id UNIQUE (github_id); + + +-- +-- Name: department uq_department_enterprise_name; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.department + ADD CONSTRAINT uq_department_enterprise_name UNIQUE (enterprise_id, name); + + +-- +-- Name: user_list user_list_phone_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT user_list_phone_key UNIQUE (phone); + + +-- +-- Name: user_list user_list_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT user_list_pkey PRIMARY KEY (id); + + +-- +-- Name: user_list user_list_username_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT user_list_username_key UNIQUE (username); + + +-- +-- Name: checkpoint_blobs_thread_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX checkpoint_blobs_thread_id_idx ON public.checkpoint_blobs USING btree (thread_id); + + +-- +-- Name: checkpoint_writes_thread_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX checkpoint_writes_thread_id_idx ON public.checkpoint_writes USING btree (thread_id); + + +-- +-- Name: checkpoints_thread_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX checkpoints_thread_id_idx ON public.checkpoints USING btree (thread_id); + + +-- +-- Name: idx_chat_message_file_checkpoint; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_message_file_checkpoint ON public.chat_message_file USING btree (checkpoint_id, message_index); + + +-- +-- Name: idx_chat_message_file_file_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_message_file_file_id ON public.chat_message_file USING btree (file_id); + + +-- +-- Name: idx_chat_message_file_thread_checkpoint; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_message_file_thread_checkpoint ON public.chat_message_file USING btree (thread_id, checkpoint_id); + + +-- +-- Name: idx_chat_message_file_thread_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_message_file_thread_id ON public.chat_message_file USING btree (thread_id); + + +-- +-- Name: idx_chat_messages_checkpoint_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_checkpoint_id ON public.chat_messages USING btree (checkpoint_id); + + +-- +-- Name: idx_chat_messages_content_search; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_content_search ON public.chat_messages USING gin (to_tsvector('simple'::regconfig, content)); + + +-- +-- Name: idx_chat_messages_has_files; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_has_files ON public.chat_messages USING btree (has_files); + + +-- +-- Name: idx_chat_messages_metadata; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_metadata ON public.chat_messages USING gin (metadata); + + +-- +-- Name: idx_chat_messages_role; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_role ON public.chat_messages USING btree (role); + + +-- +-- Name: idx_chat_messages_thread_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_thread_created ON public.chat_messages USING btree (thread_id, created_at DESC); + + +-- +-- Name: idx_chat_messages_thread_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_thread_id ON public.chat_messages USING btree (thread_id); + + +-- +-- Name: idx_chat_thread_chunk_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_chunk_created_at ON public.chat_thread_chunk USING btree (created_at); + + +-- +-- Name: idx_chat_thread_chunk_file_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_chunk_file_id ON public.chat_thread_chunk USING btree (file_id); + + +-- +-- Name: idx_chat_thread_chunk_file_thread; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_chunk_file_thread ON public.chat_thread_chunk USING btree (file_id, thread_id); + + +-- +-- Name: idx_chat_thread_chunk_thread_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_chunk_thread_id ON public.chat_thread_chunk USING btree (thread_id); + + +-- +-- Name: idx_chat_thread_chunk_vector_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_chunk_vector_id ON public.chat_thread_chunk USING btree (vector_id); + + +-- +-- Name: idx_chat_thread_file_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_created_at ON public.chat_thread_file USING btree (created_at); + + +-- +-- Name: idx_chat_thread_file_is_deleted; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_is_deleted ON public.chat_thread_file USING btree (is_deleted); + + +-- +-- Name: idx_chat_thread_file_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_status ON public.chat_thread_file USING btree (status); + + +-- +-- Name: idx_chat_thread_file_thread_deleted; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_thread_deleted ON public.chat_thread_file USING btree (thread_id, is_deleted); + + +-- +-- Name: idx_chat_thread_file_thread_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_thread_id ON public.chat_thread_file USING btree (thread_id); + + +-- +-- Name: idx_chat_thread_file_thread_user; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_thread_user ON public.chat_thread_file USING btree (thread_id, user_id); + + +-- +-- Name: idx_chat_thread_file_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_thread_file_user_id ON public.chat_thread_file USING btree (user_id); + + +-- +-- Name: idx_chat_threads_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_threads_created_at ON public.chat_threads USING btree (created_at DESC); + + +-- +-- Name: idx_chat_threads_knowledge_graph_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_threads_knowledge_graph_id ON public.chat_threads USING btree (knowledge_graph_id); + + +-- +-- Name: idx_chat_threads_novel_graph_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_threads_novel_graph_id ON public.chat_threads USING btree (novel_graph_id); + + +-- +-- Name: idx_chat_threads_user_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_threads_user_created ON public.chat_threads USING btree (user_id, created_at DESC); + + +-- +-- Name: idx_chat_threads_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_threads_user_id ON public.chat_threads USING btree (user_id); + + +-- +-- Name: idx_department_enterprise_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_department_enterprise_id ON public.department USING btree (enterprise_id); + + +-- +-- Name: idx_department_leader_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_department_leader_user_id ON public.department USING btree (leader_user_id); + + +-- +-- Name: idx_graphs_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_created_at ON public.graphs USING btree (created_at DESC); + + +-- +-- Name: idx_graphs_creator; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_creator ON public.graphs USING btree (creator_id); + + +-- +-- Name: idx_graphs_ent_vis; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_ent_vis ON public.graphs USING btree (enterprise_id, visibility); + + +-- +-- Name: idx_graphs_enterprise; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_enterprise ON public.graphs USING btree (enterprise_id); + + +-- +-- Name: idx_graphs_graph_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_graph_type ON public.graphs USING btree (user_id, graph_type); + + +-- +-- Name: idx_graphs_neo4j_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_neo4j_id ON public.graphs USING btree (neo4j_graph_id); + + +-- +-- Name: idx_graphs_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_graphs_user_id ON public.graphs USING btree (user_id); + + +-- +-- Name: idx_kb_audit_log_action; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_action ON public.kb_audit_log USING btree (action); + + +-- +-- Name: idx_kb_audit_log_actor_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_actor_id ON public.kb_audit_log USING btree (actor_id); + + +-- +-- Name: idx_kb_audit_log_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_created_at ON public.kb_audit_log USING btree (created_at DESC); + + +-- +-- Name: idx_kb_audit_log_department_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_department_id ON public.kb_audit_log USING btree (department_id); + + +-- +-- Name: idx_kb_audit_log_ent_dept_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_ent_dept_created ON public.kb_audit_log USING btree (enterprise_id, department_id, created_at DESC); + + +-- +-- Name: idx_kb_audit_log_enterprise_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_enterprise_id ON public.kb_audit_log USING btree (enterprise_id); + + +-- +-- Name: idx_kb_audit_log_kb_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_kb_id ON public.kb_audit_log USING btree (kb_id); + + +-- +-- Name: idx_kb_audit_log_target_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_audit_log_target_user_id ON public.kb_audit_log USING btree (target_user_id); + + +-- +-- Name: idx_kb_chunk_file_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_chunk_file_id ON public.knowledge_base_chunk USING btree (file_id); + + +-- +-- Name: idx_kb_chunk_kb_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_chunk_kb_id ON public.knowledge_base_chunk USING btree (knowledge_base_id); + + +-- +-- Name: idx_kb_chunk_metadata; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_chunk_metadata ON public.knowledge_base_chunk USING gin (metadata); + + +-- +-- Name: idx_kb_chunk_vector_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_chunk_vector_id ON public.knowledge_base_chunk USING btree (vector_id); + + +-- +-- Name: idx_kb_file_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_file_created_at ON public.knowledge_base_file USING btree (created_at); + + +-- +-- Name: idx_kb_file_kb_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_file_kb_id ON public.knowledge_base_file USING btree (knowledge_base_id); + + +-- +-- Name: idx_kb_file_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_file_status ON public.knowledge_base_file USING btree (status); + + +-- +-- Name: idx_kb_file_unique_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_kb_file_unique_active ON public.knowledge_base_file USING btree (knowledge_base_id, file_name) WHERE (is_deleted = false); + + +-- +-- Name: idx_kb_file_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_file_user_id ON public.knowledge_base_file USING btree (user_id); + + +-- +-- Name: idx_kb_processing_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_processing_created_at ON public.knowledge_processing_task USING btree (created_at DESC); + + +-- +-- Name: idx_kb_processing_kb_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_processing_kb_id ON public.knowledge_processing_task USING btree (knowledge_base_id); + + +-- +-- Name: idx_kb_processing_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_processing_status ON public.knowledge_processing_task USING btree (status); + + +-- +-- Name: idx_kb_processing_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_processing_user_id ON public.knowledge_processing_task USING btree (user_id); + + +-- +-- Name: idx_kb_processing_user_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_kb_processing_user_status ON public.knowledge_processing_task USING btree (user_id, status); + + +-- +-- Name: idx_knowledge_base_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_created_at ON public.knowledge_base USING btree (created_at); + + +-- +-- Name: idx_knowledge_base_creator; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_creator ON public.knowledge_base USING btree (creator_id); + + +-- +-- Name: idx_knowledge_base_ent_vis; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_ent_vis ON public.knowledge_base USING btree (enterprise_id, visibility) WHERE (is_deleted = false); + + +-- +-- Name: idx_knowledge_base_enterprise; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_enterprise ON public.knowledge_base USING btree (enterprise_id); + + +-- +-- Name: idx_knowledge_base_is_deleted; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_is_deleted ON public.knowledge_base USING btree (is_deleted); + + +-- +-- Name: idx_knowledge_base_user_deleted; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_user_deleted ON public.knowledge_base USING btree (user_id, is_deleted); + + +-- +-- Name: idx_knowledge_base_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_user_id ON public.knowledge_base USING btree (user_id); + + +-- +-- Name: idx_knowledge_base_user_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_knowledge_base_user_name ON public.knowledge_base USING btree (user_id, name); + + +-- +-- Name: idx_user_list_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_created_at ON public.user_list USING btree (created_at); + + +-- +-- Name: idx_user_list_department_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_department_id ON public.user_list USING btree (department_id); + + +-- +-- Name: idx_user_list_email; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_email ON public.user_list USING btree (email); + + +-- +-- Name: idx_user_list_enterprise_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_enterprise_id ON public.user_list USING btree (enterprise_id); + + +-- +-- Name: idx_user_list_github_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_github_id ON public.user_list USING btree (github_id); + + +-- +-- Name: idx_user_list_role; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_role ON public.user_list USING btree (role); + + +-- +-- Name: idx_user_list_username; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_user_list_username ON public.user_list USING btree (username); + + +-- +-- Name: uk_chat_thread_file_thread_name_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX uk_chat_thread_file_thread_name_active ON public.chat_thread_file USING btree (thread_id, file_name) WHERE (is_deleted = false); + + +-- +-- Name: uk_user_knowledge_base_name_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX uk_user_knowledge_base_name_active ON public.knowledge_base USING btree (user_id, name) WHERE (is_deleted = false); + + +-- +-- Name: department department_enterprise_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.department + ADD CONSTRAINT department_enterprise_id_fkey FOREIGN KEY (enterprise_id) REFERENCES public.enterprise(id) ON DELETE CASCADE; + + +-- +-- Name: department department_leader_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.department + ADD CONSTRAINT department_leader_user_id_fkey FOREIGN KEY (leader_user_id) REFERENCES public.user_list(id) ON DELETE SET NULL; + + +-- +-- Name: department department_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.department + ADD CONSTRAINT department_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.department(id) ON DELETE SET NULL; + + +-- +-- Name: chat_message_file fk_chat_message_file_file; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_file + ADD CONSTRAINT fk_chat_message_file_file FOREIGN KEY (file_id) REFERENCES public.chat_thread_file(id) ON DELETE CASCADE; + + +-- +-- Name: chat_thread_chunk fk_chat_thread_chunk_file; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_thread_chunk + ADD CONSTRAINT fk_chat_thread_chunk_file FOREIGN KEY (file_id) REFERENCES public.chat_thread_file(id) ON DELETE CASCADE; + + +-- +-- Name: chat_thread_file fk_chat_thread_file_user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_thread_file + ADD CONSTRAINT fk_chat_thread_file_user FOREIGN KEY (user_id) REFERENCES public.user_list(id) ON DELETE CASCADE; + + +-- +-- Name: chat_threads fk_chat_threads_knowledge_graph; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_threads + ADD CONSTRAINT fk_chat_threads_knowledge_graph FOREIGN KEY (knowledge_graph_id) REFERENCES public.graphs(id) ON DELETE SET NULL; + + +-- +-- Name: graphs fk_graphs_user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs + ADD CONSTRAINT fk_graphs_user FOREIGN KEY (user_id) REFERENCES public.user_list(id) ON DELETE CASCADE; + + +-- +-- Name: knowledge_base_chunk fk_kb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_chunk + ADD CONSTRAINT fk_kb FOREIGN KEY (knowledge_base_id) REFERENCES public.knowledge_base(id) ON DELETE CASCADE; + + +-- +-- Name: knowledge_base_chunk fk_kb_file; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_chunk + ADD CONSTRAINT fk_kb_file FOREIGN KEY (file_id) REFERENCES public.knowledge_base_file(id) ON DELETE CASCADE; + + +-- +-- Name: knowledge_processing_task fk_kb_processing_kb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_processing_task + ADD CONSTRAINT fk_kb_processing_kb FOREIGN KEY (knowledge_base_id) REFERENCES public.knowledge_base(id) ON DELETE CASCADE; + + +-- +-- Name: knowledge_processing_task fk_kb_processing_user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_processing_task + ADD CONSTRAINT fk_kb_processing_user FOREIGN KEY (user_id) REFERENCES public.user_list(id) ON DELETE CASCADE; + + +-- +-- Name: knowledge_base_file fk_knowledge_base; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base_file + ADD CONSTRAINT fk_knowledge_base FOREIGN KEY (knowledge_base_id) REFERENCES public.knowledge_base(id) ON DELETE CASCADE; + + +-- +-- Name: chat_threads fk_user_id; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_threads + ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES public.user_list(id) ON DELETE CASCADE; + + +-- +-- Name: graphs graphs_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs + ADD CONSTRAINT graphs_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.user_list(id) ON DELETE SET NULL; + + +-- +-- Name: graphs graphs_department_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs + ADD CONSTRAINT graphs_department_id_fkey FOREIGN KEY (department_id) REFERENCES public.department(id) ON DELETE SET NULL; + + +-- +-- Name: graphs graphs_enterprise_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.graphs + ADD CONSTRAINT graphs_enterprise_id_fkey FOREIGN KEY (enterprise_id) REFERENCES public.enterprise(id); + + +-- +-- Name: kb_audit_log kb_audit_log_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_actor_id_fkey FOREIGN KEY (actor_id) REFERENCES public.user_list(id) ON DELETE CASCADE; + + +-- +-- Name: kb_audit_log kb_audit_log_department_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_department_id_fkey FOREIGN KEY (department_id) REFERENCES public.department(id) ON DELETE SET NULL; + + +-- +-- Name: kb_audit_log kb_audit_log_enterprise_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_enterprise_id_fkey FOREIGN KEY (enterprise_id) REFERENCES public.enterprise(id) ON DELETE CASCADE; + + +-- +-- Name: kb_audit_log kb_audit_log_file_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_file_id_fkey FOREIGN KEY (file_id) REFERENCES public.knowledge_base_file(id) ON DELETE SET NULL; + + +-- +-- Name: kb_audit_log kb_audit_log_kb_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_kb_id_fkey FOREIGN KEY (kb_id) REFERENCES public.knowledge_base(id) ON DELETE SET NULL; + + +-- +-- Name: kb_audit_log kb_audit_log_target_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.kb_audit_log + ADD CONSTRAINT kb_audit_log_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES public.user_list(id) ON DELETE SET NULL; + + +-- +-- Name: knowledge_base knowledge_base_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base + ADD CONSTRAINT knowledge_base_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.user_list(id) ON DELETE CASCADE; + + +-- +-- Name: knowledge_base knowledge_base_department_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base + ADD CONSTRAINT knowledge_base_department_id_fkey FOREIGN KEY (department_id) REFERENCES public.department(id) ON DELETE SET NULL; + + +-- +-- Name: knowledge_base knowledge_base_enterprise_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.knowledge_base + ADD CONSTRAINT knowledge_base_enterprise_id_fkey FOREIGN KEY (enterprise_id) REFERENCES public.enterprise(id); + + +-- +-- Name: user_list user_list_department_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT user_list_department_id_fkey FOREIGN KEY (department_id) REFERENCES public.department(id) ON DELETE SET NULL; + + +-- +-- Name: user_list user_list_enterprise_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_list + ADD CONSTRAINT user_list_enterprise_id_fkey FOREIGN KEY (enterprise_id) REFERENCES public.enterprise(id); + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict IIs1ywVfui7Zb3f5SRgOBwj2LGJXpFF5jpVnQbpR7cCgjNpjBLehhEAmULW9yma + diff --git a/frontend/src/utils/fileUploadProgress.js b/frontend/src/utils/fileUploadProgress.js new file mode 100644 index 0000000..9d9e518 --- /dev/null +++ b/frontend/src/utils/fileUploadProgress.js @@ -0,0 +1,40 @@ +/** 方案 B:上传 0–30%,后端处理 30–100% */ + +export const UPLOAD_PHASE_MAX = 30 + +const STAGE_LABELS = { + uploading: '上传中', + processing: '处理中', + downloading: '下载文件', + parsing: '解析文档', + ocr: '文字识别', + splitting: '文本分块', + embedding: '向量化', + summarizing: '生成摘要', + extracting: '抽取实体关系', + indexing: '建立索引', + completed: '已完成', + failed: '失败', +} + +export function calcUploadPercent(loaded, total) { + if (!total) return 0 + return Math.min(UPLOAD_PHASE_MAX, Math.round((loaded * UPLOAD_PHASE_MAX) / total)) +} + +export function mergeProgressPercent(uploadPercent, backendPercent) { + const backend = backendPercent ?? 0 + const upload = uploadPercent ?? 0 + if (backend > UPLOAD_PHASE_MAX) return backend + return Math.max(upload, backend) +} + +export function stageDisplayLabel(stage) { + if (!stage) return '处理中' + return STAGE_LABELS[stage] || stage +} + +export function progressMessage(stage, percent) { + const label = stageDisplayLabel(stage) + return percent > 0 ? `${label}... ${percent}%` : `${label}...` +} diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue index b5cb895..d913d13 100644 --- a/frontend/src/views/Chat.vue +++ b/frontend/src/views/Chat.vue @@ -350,6 +350,9 @@