RAG/api/main.py

2064 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
FastAPI main application
"""
import warnings
warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*", category=UserWarning)
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import StreamingResponse, Response, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from loguru import logger
from config import settings
from db_utils import get_db_connection, init_session_db
from rag import VectorStoreManager, RAGEngine, FileParser
from sync_service import SyncServiceManager
import requests
from datetime import datetime
import time
import uuid
import threading
import logging
from fastapi import Request
from fastapi.responses import PlainTextResponse
import sqlite3
import json
import os
import asyncio
import re
import markdown2
from pathlib import Path
import hashlib
from sync.base_sync import BaseSync
# Global instances
vector_store_manager: Optional[VectorStoreManager] = None
rag_engine: Optional[RAGEngine] = None
sync_manager: Optional[SyncServiceManager] = None
file_parser: Optional[FileParser] = None
auto_sync_task = None # Keep reference to prevent garbage collection
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events"""
global vector_store_manager, rag_engine, sync_manager, file_parser, auto_sync_task
# Startup
try:
logger.info("Initializing RAG services...")
# Initialize vector store
try:
vector_store_manager = VectorStoreManager()
logger.info("✓ VectorStoreManager initialized")
except Exception as e:
logger.error(f"Failed to initialize VectorStoreManager: {e}")
raise
# Initialize RAG engine
try:
rag_engine = RAGEngine(vector_store_manager)
logger.info("✓ RAGEngine initialized")
except Exception as e:
logger.error(f"Failed to initialize RAGEngine: {e}")
raise
# Initialize file parser
try:
file_parser = FileParser()
logger.info("✓ FileParser initialized")
except Exception as e:
logger.error(f"Failed to initialize FileParser: {e}")
raise
logger.info("✓ Core RAG services initialized")
# Initialize sync service in background (non-blocking)
# This allows API to start immediately even if MySQL connection fails
async def init_sync_service():
"""Initialize sync service and start sync in background"""
global sync_manager
try:
logger.info("Initializing sync services in background...")
# Run SyncServiceManager initialization in thread pool to avoid blocking event loop
# SyncServiceManager.__init__() contains synchronous data source connection checks
loop = asyncio.get_event_loop()
sync_manager = await loop.run_in_executor(None, SyncServiceManager)
# Start all sync services in background
# This will start auto sync with recovery for each data source
if settings.AUTO_SYNC:
logger.info(f"Starting auto sync services (interval: {settings.SYNC_INTERVAL}s)...")
# Start all sync services in background
await sync_manager.start_all_sync_services()
else:
logger.info("Auto sync is disabled, skipping auto sync service startup")
except Exception as e:
logger.error(f"Failed to initialize sync services: {e}")
logger.warning("API is still available, but sync services are disabled. Please check data source connections.")
# Don't raise - allow API to continue running
# Start sync service initialization in background (non-blocking)
# This ensures API can respond immediately while sync runs in background
sync_init_task = asyncio.create_task(init_sync_service())
auto_sync_task = sync_init_task # Store reference for shutdown
logger.info("✓ RAG API is now available (sync running in background)")
except Exception as e:
logger.error(f"Failed to initialize core services: {e}")
raise
yield
# Shutdown
if auto_sync_task and not auto_sync_task.done():
logger.info("Stopping sync services...")
# Wait a bit for the task to finish gracefully
try:
await asyncio.wait_for(auto_sync_task, timeout=10.0)
except asyncio.TimeoutError:
logger.warning("Sync task did not stop in time, cancelling...")
auto_sync_task.cancel()
try:
await auto_sync_task
except asyncio.CancelledError:
pass
if sync_manager:
sync_manager.stop_all_sync_services()
sync_manager.close_all()
logger.info("RAG services shut down")
# Check if static assets are available for offline use
STATIC_ROOT_DIR = Path(__file__).parent.parent / "static"
STATIC_DIR = STATIC_ROOT_DIR / "swagger-ui"
SWAGGER_UI_BUNDLE = STATIC_DIR / "swagger-ui-bundle.js"
SWAGGER_UI_CSS = STATIC_DIR / "swagger-ui.css"
REDOC_BUNDLE = STATIC_DIR / "redoc.standalone.js"
# Determine if we should use local assets or CDN
USE_LOCAL_ASSETS = (
SWAGGER_UI_BUNDLE.exists() and
SWAGGER_UI_CSS.exists() and
REDOC_BUNDLE.exists()
)
if USE_LOCAL_ASSETS:
logger.info("Using local Swagger UI assets for offline mode")
else:
logger.warning(
"Local Swagger UI assets not found. Swagger UI will use CDN resources. "
"For offline use, run: python download_swagger_assets.py"
)
# Initialize FastAPI app
app = FastAPI(
title=settings.API_TITLE,
version=settings.API_VERSION,
description="RAG API for local knowledge base retrieval and generation",
lifespan=lifespan,
swagger_ui_parameters={
"persistAuthorization": True,
},
)
# Mount static files directories if they exist
if STATIC_ROOT_DIR.exists():
# Mount the entire static directory to serve all static assets
app.mount("/static", StaticFiles(directory=str(STATIC_ROOT_DIR)), name="static")
logger.info(f"Mounted static directory: {STATIC_ROOT_DIR}")
# Add route for chat interface
@app.get("/chat", include_in_schema=False)
async def chat_interface():
"""Serve the chat interface"""
return FileResponse(STATIC_ROOT_DIR / "chat" / "index.html")
# Add route for configuration management interface
@app.get("/config", include_in_schema=False)
async def config_interface():
"""Serve the configuration management interface"""
return FileResponse(STATIC_ROOT_DIR / "config" / "index.html")
# Add favicon route to prevent 404 errors
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""Return empty favicon to prevent 404 errors"""
# Return a minimal 1x1 transparent PNG
# This prevents browser from requesting favicon and getting 404
return Response(
content=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82',
media_type="image/png"
)
# Override Swagger UI to use local assets when available
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
"""Custom Swagger UI that uses local assets in offline mode"""
if USE_LOCAL_ASSETS:
# Use local static files
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
swagger_favicon_url="/favicon.ico",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
init_oauth=app.swagger_ui_init_oauth,
swagger_ui_parameters=app.swagger_ui_parameters,
)
else:
# Fallback to default (CDN)
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
swagger_favicon_url="/favicon.ico",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
init_oauth=app.swagger_ui_init_oauth,
swagger_ui_parameters=app.swagger_ui_parameters,
)
# Override ReDoc to use local assets when available
@app.get("/redoc", include_in_schema=False)
async def custom_redoc_html():
"""Custom ReDoc that uses local assets in offline mode"""
if USE_LOCAL_ASSETS:
# Use local static files
return get_redoc_html(
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_js_url="/static/swagger-ui/redoc.standalone.js",
redoc_favicon_url="/favicon.ico",
with_google_fonts=False, # Disable Google Fonts for offline use
)
else:
# Fallback to default (CDN)
return get_redoc_html(
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_favicon_url="/favicon.ico",
with_google_fonts=True,
)
# Request/Response models
class QueryRequest(BaseModel):
"""Query request model"""
query: str = Field(..., description="User query string", min_length=1)
top_k: Optional[int] = Field(None, description="Number of documents to retrieve", ge=1, le=20)
stream: bool = Field(True, description="Whether to stream the response")
class RetrieveRequest(BaseModel):
"""Retrieve request model - only retrieves documents from ChromaDB, no LLM generation"""
query: str = Field(..., description="Query string for retrieval", min_length=1)
top_k: Optional[int] = Field(None, description="Number of documents to retrieve", ge=1, le=20)
class RetrievedDocument(BaseModel):
"""Retrieved document model"""
content: str = Field(..., description="Document content/text")
score: Optional[float] = Field(None, description="Similarity score")
metadata: dict = Field(default_factory=dict, description="Document metadata")
doc_id: Optional[str] = Field(None, description="Document ID")
class RetrieveResponse(BaseModel):
"""Retrieve response model"""
query: str = Field(..., description="Original query")
documents: list[RetrievedDocument] = Field(..., description="Retrieved documents")
count: int = Field(..., description="Number of documents retrieved")
class SyncRequest(BaseModel):
"""Manual sync request model"""
full_sync: bool = Field(False, description="Whether to perform full sync")
force: bool = Field(False, description="Whether to force re-processing of all documents (even if they exist)")
source_name: Optional[str] = Field(None, description="Specific data source to sync (optional, syncs all if not provided)")
class HealthResponse(BaseModel):
"""Health check response"""
status: str
message: str
class DocumentChunk(BaseModel):
"""Document chunk model"""
id: str = Field(..., description="Chunk ID")
text: str = Field(..., description="Chunk content")
metadata: dict = Field(default_factory=dict, description="Chunk metadata")
chunk_index: Optional[int] = Field(None, description="Chunk index (if document was chunked)")
class DocumentResponse(BaseModel):
"""Document response model"""
doc_id: str = Field(..., description="Document ID")
chunks: List[DocumentChunk] = Field(..., description="Document chunks")
total_chunks: int = Field(..., description="Total number of chunks")
full_text: str = Field(..., description="Full document text (all chunks combined)")
class UploadResponse(BaseModel):
"""File upload response model"""
doc_id: str = Field(..., description="Document ID")
filename: str = Field(..., description="Original filename")
file_type: str = Field(..., description="File type/extension")
chunks: int = Field(..., description="Number of chunks created")
message: str = Field(..., description="Upload status message")
# User authentication models
class UserRegisterRequest(BaseModel):
"""Request model for user registration"""
username: str
password: str
class UserLoginRequest(BaseModel):
"""Request model for user login"""
username: str
password: str
class UserResponse(BaseModel):
"""Response model for user operations"""
id: str
username: str
message: str
# --- Compatibility layer: endpoints copied from example.py (Flask) ---
# These endpoints provide a backward-compatible interface so existing clients
# that used the Flask-based API can keep working.
# In-memory conversation store and lock (thread-safe)
conversations = {}
conv_lock = threading.Lock()
# Configure logging for this compatibility layer
compat_logger = logging.getLogger("compat_api")
compat_logger.setLevel(logging.INFO)
# Session persistence (SQLite)
DATA_DIR = Path(__file__).parent.parent / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = DATA_DIR / "sessions.db"
def hash_password(password: str) -> str:
"""Hash a password using SHA-256"""
return hashlib.sha256(password.encode()).hexdigest()
def load_sessions():
conn = sqlite3.connect(DB_PATH)
try:
cur = conn.execute("SELECT id, user_login, title, data, update_time FROM sessions")
rows = cur.fetchall()
with conv_lock:
for rid, user_login, title, data_text, update_time in rows:
try:
data = json.loads(data_text) if data_text else {"messages": []}
except Exception:
data = {"messages": []}
conversations[rid] = {
"title": title or "",
"user_login": user_login or "",
"messages": data.get("messages", []),
"create_time": update_time,
"update_time": update_time,
"active_stream": False
}
finally:
conn.close()
def serialize_conversation(conv):
"""安全的序列化函数,处理可能包含协程的情况"""
messages = conv.get("messages", [])
# 清理消息列表,移除不可序列化的对象
cleaned_messages = []
for msg in messages:
if asyncio.iscoroutine(msg):
# 如果是协程,记录错误或跳过
compat_logger.warning(f"发现协程对象在消息列表中: {msg}")
continue
elif isinstance(msg, dict):
# 递归清理字典中的值
cleaned_msg = {}
for key, value in msg.items():
if not asyncio.iscoroutine(value):
cleaned_msg[key] = value
else:
cleaned_msg[key] = "<coroutine>"
cleaned_messages.append(cleaned_msg)
else:
cleaned_messages.append(msg)
return json.dumps(
{"messages": cleaned_messages},
ensure_ascii=False,
default=str # 处理其他不可序列化的类型
)
def save_session(conv_id: str):
# Upsert session into SQLite
with conv_lock:
conv = conversations.get(conv_id)
if not conv:
return
title = conv.get("title", "")
user_login = conv.get("user_login", "")
data_text = serialize_conversation(conv)
update_time = conv.get("update_time", datetime.now().isoformat())
conn = sqlite3.connect(DB_PATH)
try:
conn.execute(
"REPLACE INTO sessions (id, user_login, title, data, update_time) VALUES (?, ?, ?, ?, ?)",
(conv_id, user_login, title, data_text, update_time)
)
conn.commit()
finally:
conn.close()
# Initialize DB and load sessions at import
init_session_db()
load_sessions()
def build_prompt_from_history(conv_id: str, max_messages: int = 10) -> str:
"""Build a single prompt string that includes recent conversation history."""
def filter_coroutines_and_join(parts):
"""
过滤掉协程对象和协程字符串,只保留真正的内容
"""
filtered_parts = []
for item in parts:
# 如果是协程对象,直接跳过
if asyncio.iscoroutine(item):
continue
# 如果是字符串,检查是否包含协程描述
if isinstance(item, str):
# 检查是否包含协程标记
if contains_coroutine_marker(item):
continue
# 保留非协程相关的字符串
filtered_parts.append(item)
# 其他非字符串类型(如果不是协程)
else:
filtered_parts.append(str(item))
return "\n".join(filtered_parts)
def contains_coroutine_marker(text):
"""检查文本是否包含协程标记"""
coroutine_patterns = [
r'<coroutine object',
r'<coroutine>',
r'coroutine object at 0x',
r'coroutine at 0x'
]
for pattern in coroutine_patterns:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
with conv_lock:
conv = conversations.get(conv_id)
if not conv:
return None
msgs = conv.get("messages", [])[-max_messages:]
if len(msgs) <= 1: # only new query content
return None
parts = []
for m in msgs[:-1]: # Exclude the last message (current user query)
role = m.get("role", "user")
content = m.get("content", "")
if role == "user":
parts.append(f"User: {content}")
else:
parts.append(f"Assistant: {content}")
if len(parts) == 0:
return None
else:
return filter_coroutines_and_join(parts) # 防止coroutine对象被放入messages中
# API endpoints
@app.get("/", response_model=HealthResponse)
async def root():
"""Root endpoint"""
return HealthResponse(
status="ok",
message="RAG API is running"
)
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint"""
try:
if vector_store_manager is None or rag_engine is None:
raise HTTPException(status_code=503, detail="Services not initialized")
return HealthResponse(
status="healthy",
message="All services are running"
)
except Exception as e:
raise HTTPException(status_code=503, detail=f"Service unhealthy: {str(e)}")
@app.post("/register", response_model=UserResponse)
async def register_user(request: UserRegisterRequest):
"""Register a new user"""
try:
# Check if username already exists
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT * FROM users WHERE username = ?", (request.username,))
if cur.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Username already exists")
# Create new user
user_id = str(uuid.uuid4())
hashed_pw = hash_password(request.password)
create_time = datetime.now().isoformat()
conn.execute(
"INSERT INTO users (id, username, password, create_time) VALUES (?, ?, ?, ?)",
(user_id, request.username, hashed_pw, create_time)
)
conn.commit()
conn.close()
return UserResponse(
id=user_id,
username=request.username,
message="User registered successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration failed: {e}")
raise HTTPException(status_code=500, detail="Registration failed")
@app.post("/login", response_model=UserResponse)
async def login_user(request: UserLoginRequest):
"""Login a user"""
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT id, password FROM users WHERE username = ?", (request.username,))
user = cur.fetchone()
conn.close()
if not user or user[1] != hash_password(request.password):
raise HTTPException(status_code=401, detail="Invalid username or password")
return UserResponse(
id=user[0],
username=request.username,
message="Login successful"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Login failed: {e}")
raise HTTPException(status_code=500, detail="Login failed")
@app.post("/query")
async def query(request: QueryRequest):
"""
Query the RAG system (retrieves documents and generates LLM response)
Args:
request: Query request with query string and options
Returns:
Streaming response or complete response
"""
if rag_engine is None:
raise HTTPException(status_code=503, detail="RAG engine not initialized")
try:
if request.stream:
# Stream response
return StreamingResponse(
rag_engine.query_stream(request.query, None, request.top_k),
media_type="text/event-stream",
headers={
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
)
else:
# Return complete response (run in thread pool for better concurrency)
response = await rag_engine.query(request.query, None, request.top_k)
return response
except Exception as e:
logger.error(f"Error processing query: {e}")
raise HTTPException(status_code=500, detail=f"Error processing query: {str(e)}")
# ----------------------
# Backward-compatible Flask-style endpoints (from example.py)
# ----------------------
@app.get('/conversations')
def list_conversations():
with conv_lock:
conv_list = [
{
"id": conv_id,
"title": conv["title"],
"update_time": conv["update_time"],
"active_stream": conv.get("active_stream", False)
}
for conv_id, conv in conversations.items()
]
conv_list.sort(key=lambda x: x["update_time"], reverse=True)
return conv_list
@app.get('/conversations/{user_login}')
def list_conversations_by_user(user_login: str):
with conv_lock:
conv_list = [
{
"id": conv_id,
"user_login": conv.get("user_login", ""),
"title": conv["title"],
"update_time": conv["update_time"],
"active_stream": conv.get("active_stream", False)
}
for conv_id, conv in conversations.items()
if conv.get("user_login") == user_login
]
conv_list.sort(key=lambda x: x["update_time"], reverse=True)
return conv_list
@app.post('/conversations')
def create_conversation(payload: dict):
title = payload.get("title", "新对话")
user_login = payload.get("user_login")
if not user_login:
raise HTTPException(status_code=400, detail="user_login is required")
conv_id = str(uuid.uuid4())
with conv_lock:
conversations[conv_id] = {
"title": title,
"user_login": user_login,
"messages": [],
"create_time": datetime.now().isoformat(),
"update_time": datetime.now().isoformat(),
"active_stream": False
}
# Persist
save_session(conv_id)
return {"id": conv_id, "title": title}
# @app.get('/conversations/{conv_id}')
# def get_conversation(conv_id: str):
# with conv_lock:
# if conv_id not in conversations:
# raise HTTPException(status_code=404, detail="对话不存在")
# conv = conversations[conv_id]
# return {
# "id": conv_id,
# "title": conv["title"],
# "messages": conv["messages"],
# "create_time": conv["create_time"],
# "update_time": conv["update_time"],
# "active_stream": conv.get("active_stream", False)
# }
@app.get('/conversations/{user_login}/{conv_id}')
def get_conversation_by_user(user_login: str, conv_id: str):
with conv_lock:
if conv_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
conv = conversations[conv_id]
if conv.get("user_login") != user_login:
raise HTTPException(status_code=403, detail="无权访问该对话")
return {
"id": conv_id,
"title": conv["title"],
"messages": conv["messages"],
"create_time": conv["create_time"],
"update_time": conv["update_time"],
"active_stream": conv.get("active_stream", False)
}
@app.delete('/conversations/{conv_id}')
def delete_conversation(conv_id: str):
with conv_lock:
if conv_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
del conversations[conv_id]
return {"message": "删除成功"}
@app.delete('/conversations/{user_login}/{conv_id}')
def delete_conversation_by_user(user_login: str, conv_id: str):
with conv_lock:
if conv_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
conv = conversations[conv_id]
if conv.get("user_login") != user_login:
raise HTTPException(status_code=403, detail="无权删除该对话")
del conversations[conv_id]
return {"message": "删除成功"}
@app.post('/conversations/{conv_id}/messages')
async def post_message(conv_id: str, payload: dict):
if conv_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
if not isinstance(payload, dict) or "content" not in payload:
raise HTTPException(status_code=400, detail="Content required in JSON body")
user_msg = {"role": "user", "content": payload["content"], "time": datetime.now().isoformat()}
with conv_lock:
conv = conversations[conv_id]
conv["messages"].append(user_msg)
conv["update_time"] = datetime.now().isoformat()
if len(conv["messages"]) == 1:
conv["title"] = user_msg["content"][:20] + ("..." if len(user_msg["content"]) > 20 else "")
# persist after adding user message
save_session(conv_id)
# Use local RAG engine to generate response instead of external API
if rag_engine is None:
ai_content = "RAG engine not initialized"
else:
try:
history = build_prompt_from_history(conv_id, max_messages=10)
ai_content = await rag_engine.query(payload["content"], history)
except Exception as e:
compat_logger.error(f"RAG query failed: {e}")
ai_content = f"RAG错误: {e}"
ai_msg = {"role": "assistant", "content": ai_content, "time": datetime.now().isoformat()}
with conv_lock:
conversations[conv_id]["messages"].append(ai_msg)
conversations[conv_id]["update_time"] = datetime.now().isoformat()
# persist after adding assistant message
save_session(conv_id)
return ai_msg
@app.post('/conversations/{conv_id}/stream')
async def stream_message(conv_id: str, payload: dict):
if conv_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
with conv_lock:
if conversations[conv_id].get("active_stream", False):
raise HTTPException(status_code=429, detail="该对话已有活动流,请先中止当前流")
conversations[conv_id]["active_stream"] = True
if not isinstance(payload, dict) or "content" not in payload:
with conv_lock:
conversations[conv_id]["active_stream"] = False
raise HTTPException(status_code=400, detail="Content required in JSON body")
if not isinstance(payload, dict) or "user_login" not in payload:
with conv_lock:
conversations[conv_id]["active_stream"] = False
raise HTTPException(status_code=400, detail="user_login required in JSON body")
if conversations[conv_id].get("user_login") != payload["user_login"]:
with conv_lock:
conversations[conv_id]["active_stream"] = False
raise HTTPException(status_code=403, detail="用户无权操作该对话")
user_msg = {"role": "user", "content": payload["content"], "time": datetime.now().isoformat()}
with conv_lock:
conversations[conv_id]["messages"].append(user_msg)
conversations[conv_id]["update_time"] = datetime.now().isoformat()
if len(conversations[conv_id]["messages"]) == 1:
conversations[conv_id]["title"] = user_msg["content"][:20] + ("..." if len(user_msg["content"]) > 20 else "")
# persist after adding user message
save_session(conv_id)
# DEEPSEEK_API = getattr(settings, 'DEEPSEEK_API', os.getenv('DEEPSEEK_API', None))
# MODEL_ID = getattr(settings, 'DEEPSEEK_MODEL_ID', os.getenv('MODEL_ID', None))
# Use local RAG engine streaming API
async def generate_async():
full_response = ""
try:
if rag_engine is None:
yield "RAG engine not initialized"
return
chunk_counter = 0
check_interval = 5 # 每生成5个chunk检查一次
# Use rag_engine.query_stream which yields chunks asynchronously
try:
history = build_prompt_from_history(conv_id, max_messages=10)
async for chunk in rag_engine.query_stream(payload["content"], history):
# 定期检查active_stream状态
chunk_counter += 1
if chunk_counter % check_interval == 0:
with conv_lock:
if conv_id not in conversations or not conversations[conv_id].get("active_stream", True):
compat_logger.info(f"流被中止: {conv_id}")
return # 直接返回,中止生成
if chunk:
# normalize chunk to str
text_chunk = str(chunk.decode('utf-8')) if isinstance(chunk, bytes) else str(chunk)
# markdown_text = markdown2.markdown(text_chunk)
full_response += text_chunk
yield text_chunk.encode('utf-8') if isinstance(chunk, str) else text_chunk
except Exception as e:
compat_logger.error(f"RAG stream error: {e}", exc_info=True)
yield f"流式响应错误: {e}"
return
# 最终检查是否被中止
with conv_lock:
if conv_id not in conversations or not conversations[conv_id].get("active_stream", True):
compat_logger.info(f"流在完成前被中止: {conv_id}")
return
# Append final assistant message to conversation
ai_msg = {"role": "assistant", "content": full_response, "time": datetime.now().isoformat()}
with conv_lock:
if conv_id in conversations:
conversations[conv_id]["messages"].append(ai_msg)
conversations[conv_id]["active_stream"] = False
# persist after adding assistant message
save_session(conv_id)
finally:
with conv_lock:
if conv_id in conversations:
# 只在正常完成时才设为False如果被中止abort接口已经设为False了
if conversations[conv_id].get("active_stream", True):
conversations[conv_id]["active_stream"] = False
# return StreamingResponse(generate_async(), media_type='text/plain; charset=utf-8')
return StreamingResponse(
generate_async(),
media_type="text/event-stream",
headers={
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
)
@app.post('/conversations/{conv_id}/abort')
def abort_stream(conv_id: str):
with conv_lock:
if conv_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
if not conversations[conv_id].get("active_stream", False):
raise HTTPException(status_code=400, detail="该对话没有活动流")
conversations[conv_id]["active_stream"] = False
return {"message": "流式响应已中止"}
@app.post("/retrieve", response_model=RetrieveResponse)
async def retrieve(request: RetrieveRequest):
"""
Retrieve documents from ChromaDB based on query (no LLM generation)
This endpoint only performs vector similarity search and returns the retrieved
documents with their content, metadata, and similarity scores.
Args:
request: Retrieve request with query string and top_k option
Returns:
Retrieved documents with content, metadata, and scores
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store not initialized")
try:
# Get retriever (works even if collection is empty, will return empty results)
retriever = vector_store_manager.get_retriever(top_k=request.top_k or settings.TOP_K)
# Retrieve documents (run in thread pool since retriever.retrieve() is synchronous)
def retrieve_docs():
nodes = retriever.retrieve(request.query)
return nodes
nodes = await asyncio.to_thread(retrieve_docs)
# Convert nodes to response format
documents = []
for node in nodes:
# Extract similarity score if available
# LlamaIndex retriever returns NodeWithScore objects
score = None
if hasattr(node, 'score'):
score = node.score
elif hasattr(node, 'node') and hasattr(node.node, 'score'):
score = node.node.score
# Get the actual node object (NodeWithScore.node or the node itself)
actual_node = node.node if hasattr(node, 'node') else node
# Get content
content = actual_node.text if hasattr(actual_node, 'text') else str(actual_node)
# Get metadata
metadata = {}
if hasattr(actual_node, 'metadata'):
metadata = actual_node.metadata.copy() if actual_node.metadata else {}
# Get doc_id
doc_id = metadata.get('doc_id') or metadata.get('original_doc_id')
if not doc_id and hasattr(actual_node, 'node_id'):
doc_id = actual_node.node_id
documents.append(RetrievedDocument(
content=content,
score=score,
metadata=metadata,
doc_id=doc_id
))
return RetrieveResponse(
query=request.query,
documents=documents,
count=len(documents)
)
except Exception as e:
logger.error(f"Error retrieving documents: {e}")
raise HTTPException(status_code=500, detail=f"Error retrieving documents: {str(e)}")
@app.delete("/documents/source/{source_name}")
async def delete_documents_by_source(source_name: str):
"""
Delete all documents from a specific data source
Args:
source_name: Name of the data source to delete documents from
Returns:
Deletion status
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store manager not initialized")
try:
# Delete documents by source name
vector_store_manager.delete_documents_by_source(source_name)
return {"status": "success", "message": f"Deleted all documents from {source_name}"}
except Exception as e:
logger.error(f"Error deleting documents by source: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting documents: {str(e)}")
@app.post("/sync")
async def manual_sync(request: SyncRequest):
"""
Manually trigger synchronization
Args:
request: Sync request with options
Returns:
Sync status
"""
if sync_manager is None:
raise HTTPException(status_code=503, detail="Sync service not initialized")
try:
message = ""
if request.source_name:
specific_sync_service = sync_manager.get_sync_service(request.source_name)
if not specific_sync_service:
raise HTTPException(status_code=404, detail=f"Sync service not found for source: {request.source_name}")
if request.full_sync:
# Start auto sync service with recovery for this data source
asyncio.create_task(specific_sync_service.start_auto_sync_with_recovery(skip_initial_sync=False))
message = f"Started full synchronization for data source: {request.source_name}"
message += f" (Auto sync service started)"
else:
# Start auto sync service with recovery for this data source
asyncio.create_task(specific_sync_service.start_auto_sync_with_recovery(skip_initial_sync=True))
message = f"Started synchronization for data source: {request.source_name}"
message += f" (Auto sync service started)"
else:
# Sync all data sources
# This is a SyncServiceManager, it starts auto sync services which do initial sync
await sync_manager.start_all_sync_services()
message = "Started synchronization for all data sources"
return {"status": "success", "message": message}
except Exception as e:
logger.error(f"Error during sync: {e}")
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
@app.get("/stats")
async def get_stats():
"""
Get system statistics
Returns:
System statistics
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store not initialized")
try:
collection = vector_store_manager.collection
count = collection.count()
return {
"vector_store": {
"collection_name": settings.CHROMA_COLLECTION_NAME,
"document_count": count
},
"config": {
"model": settings.OLLAMA_MODEL,
"top_k": settings.TOP_K,
"chunk_size": settings.CHUNK_SIZE
}
}
except Exception as e:
logger.error(f"Error getting stats: {e}")
raise HTTPException(status_code=500, detail=f"Error getting stats: {str(e)}")
@app.post("/documents/upload", response_model=UploadResponse)
async def upload_document(
file: UploadFile = File(..., description="File to upload"),
doc_id: Optional[str] = Form(None, description="Custom document ID (optional, will use filename if not provided)"),
metadata: Optional[str] = Form(None, description="Additional metadata as JSON string (optional)")
):
"""
Upload and parse a document file
Supports various file formats:
- Text files: .txt, .md, .markdown
- PDF files: .pdf
- Word documents: .docx, .doc
- HTML files: .html, .htm
- CSV files: .csv
- JSON files: .json
File size limit: Configurable via MAX_UPLOAD_SIZE_MB in .env (default: 10MB)
Args:
file: File to upload (max size configured in MAX_UPLOAD_SIZE_MB)
doc_id: Optional custom document ID
metadata: Optional JSON string with additional metadata
Returns:
Upload response with document ID and chunk count
Raises:
HTTPException: 400 if file format is unsupported
HTTPException: 413 if file size exceeds 10MB
HTTPException: 400 if document parsing fails
"""
if file_parser is None or vector_store_manager is None:
raise HTTPException(status_code=503, detail="Services not initialized")
try:
# Check if file format is supported
if not file_parser.is_supported(file.filename):
raise HTTPException(
status_code=400,
detail=f"Unsupported file format. Supported formats: {', '.join(file_parser.SUPPORTED_EXTENSIONS)}"
)
# Read file content
content = await file.read()
# Check file size limit (from config, default: 10MB)
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 # Convert MB to bytes
file_size = len(content)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"File size ({file_size / (1024 * 1024):.2f}MB) exceeds maximum allowed size ({settings.MAX_UPLOAD_SIZE_MB}MB)"
)
# Parse metadata if provided
extra_metadata = {}
if metadata:
try:
extra_metadata = json.loads(metadata)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid metadata JSON format")
# Use custom doc_id or generate from filename
if not doc_id:
doc_id = Path(file.filename).stem
# Parse file (run in thread pool to avoid blocking event loop for large files)
logger.info(f"Parsing uploaded file: {file.filename} (doc_id: {doc_id}, size: {file_size / 1024:.2f}KB)")
try:
# 获取事件循环,用于在异步环境中运行同步函数
loop = asyncio.get_event_loop()
# 使用线程池执行器运行文件解析函数(同步函数),避免阻塞事件循环
documents = await loop.run_in_executor(
None, # 使用默认线程池
file_parser.parse_file_content, # 要执行的同步解析函数
content, # 文件内容(字节流)
file.filename, # 文件名
doc_id, # 文档ID
extra_metadata# 额外元数据
)
except Exception as parse_error:
logger.error(f"Error parsing file {file.filename}: {parse_error}", exc_info=True)
raise HTTPException(
status_code=400,
detail=f"Failed to parse document: {str(parse_error)}"
)
if not documents:
raise HTTPException(status_code=400, detail="Failed to parse document or document is empty")
logger.info(f"Successfully parsed {file.filename}: {len(documents)} document(s)")
# Chunk documents if needed
logger.info(f"Chunking {len(documents)} document(s)...")
chunked_documents = BaseSync.chunk_documents(documents)
logger.info(f"Chunked into {len(chunked_documents)} chunk(s)")
# Add to vector store (this may take time for large documents due to embedding generation)
logger.info(f"Adding {len(chunked_documents)} chunks to vector store (this may take time for large files)...")
try:
# Run in thread pool to avoid blocking event loop during embedding generation
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
vector_store_manager.add_documents,
chunked_documents,
False # skip_existing
)
logger.info(f"Successfully added {len(chunked_documents)} chunks to vector store")
except Exception as add_error:
logger.error(f"Error adding documents to vector store: {add_error}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to add documents to vector store: {str(add_error)}"
)
file_type = Path(file.filename).suffix.lstrip('.')
return UploadResponse(
doc_id=doc_id,
filename=file.filename,
file_type=file_type,
chunks=len(chunked_documents),
message=f"Document uploaded and processed successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error uploading document: {e}")
raise HTTPException(status_code=500, detail=f"Error uploading document: {str(e)}")
@app.get("/documents/{doc_id}", response_model=DocumentResponse)
async def get_document_by_id(doc_id: str):
"""
Get all content for a document by its ID
This endpoint retrieves all chunks/content that belong to a document,
even if the document was split into multiple chunks during processing.
Args:
doc_id: Document ID to retrieve
Returns:
Document response with all chunks and full text
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store not initialized")
try:
# Get all chunks for this document
chunks = vector_store_manager.get_document_by_id(doc_id)
if not chunks:
raise HTTPException(status_code=404, detail=f"Document with ID '{doc_id}' not found")
# Convert to response format
document_chunks = []
full_text_parts = []
for chunk in chunks:
document_chunks.append(DocumentChunk(
id=chunk['id'],
text=chunk['text'],
metadata=chunk.get('metadata', {}),
chunk_index=chunk.get('chunk_index')
))
full_text_parts.append(chunk['text'])
# Combine all chunks into full text
full_text = "\n\n".join(full_text_parts)
return DocumentResponse(
doc_id=doc_id,
chunks=document_chunks,
total_chunks=len(document_chunks),
full_text=full_text
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error retrieving document {doc_id}: {e}")
raise HTTPException(status_code=500, detail=f"Error retrieving document: {str(e)}")
# Configuration management endpoints
from fastapi import HTTPException
import os
import json
from typing import List, Dict, Any
# Configuration management endpoints
@app.get("/folder-configs")
async def get_folder_configs():
"""
Get all folder configurations
Returns:
List of all configurations with their IDs and details
"""
try:
# Load configs from SQLite database
DATA_DIR = Path(__file__).parent.parent / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = DATA_DIR / "sessions.db"
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 查询所有数据源配置
try:
cursor.execute('SELECT name, config, update_at FROM data_sources')
rows = cursor.fetchall()
configurations = []
for row in rows:
name, config_json, update_at = row
try:
config_data = json.loads(config_json)
configurations.append({
"id": name,
"type": config_data.get("type", "unknown"),
"config": config_data,
"update_at": update_at
})
except Exception as e:
logger.error(f"Error parsing config from database: {name}, error: {e}")
except sqlite3.OperationalError as e:
# 表不存在的情况,返回空列表
logger.warning(f"SQLite table error: {e}. Returning empty config list.")
configurations = []
# 关闭数据库连接
conn.close()
return {"configurations": configurations}
except Exception as e:
logger.error(f"Error getting folder configs: {e}")
# 返回空列表而不是错误响应
return {"configurations": []}
@app.post("/folder-configs")
async def create_config(config: Dict[str, Any]):
"""
Create a new configuration
Args:
config: Configuration data for the new config
Returns:
Created configuration with ID
"""
conn = None
try:
# Ensure config has required fields
if "type" not in config:
raise HTTPException(status_code=400, detail="Config type is required")
config_type = config["type"]
# Check for unique identifier based on config type
# 必须有数据源唯一标识才能创建配置
unique_id_parts = [config_type]
if config_type == "database":
# 数据库配置需要:主机、端口、数据库名、表名
if not config.get("mysql_host") or not config.get("mysql_port") or not config.get("database") or not config.get("table_name"):
raise HTTPException(status_code=400, detail="数据库配置必须包含主机、端口、数据库名和表名")
# 添加数据库类型到唯一标识符
db_type = config.get("db_type", "mysql").lower()
unique_id_parts.extend([
db_type,
config["mysql_host"].lower(),
str(config["mysql_port"]),
config["database"].lower(),
config["table_name"].lower()
])
elif config_type == "folder":
# 文件夹配置需要:主机、文件夹路径
if not config.get("host") or not config.get("folder_path"):
raise HTTPException(status_code=400, detail="文件夹配置必须包含主机和文件夹路径")
# 替换路径中的特殊字符为下划线
folder_path = config["folder_path"].lower().replace("/", "_").replace(":", "_").replace("\\", "_")
# 去除开头的下划线
if folder_path.startswith("_"):
folder_path = folder_path[1:]
unique_id_parts.extend([
config["host"].lower(),
folder_path
])
elif config_type == "git":
# Git配置需要仓库URL
if not config.get("git_url"):
raise HTTPException(status_code=400, detail="Git配置必须包含仓库URL")
# 添加Git仓库URL到唯一标识符
# 替换URL中的特殊字符为下划线
git_url = config["git_url"].lower().replace("/", "_").replace(":", "_").replace(".", "_")
# 截取URL的一部分作为唯一标识
git_url_part = git_url[:100] # 限制长度
unique_id_parts.extend([
git_url_part
])
else:
raise HTTPException(status_code=400, detail=f"不支持的配置类型: {config_type}")
# Generate config ID based on unique identifier
config_id = "_".join(unique_id_parts)
# Save config to SQLite database
conn, cursor = get_db_connection()
# 检查是否存在同源配置
try:
# 先尝试查询现有配置
cursor.execute('SELECT name, config FROM data_sources')
existing_configs = cursor.fetchall()
for existing_name, existing_config_json in existing_configs:
existing_config_data = json.loads(existing_config_json)
# Check if it's the same type
if existing_config_data.get('type') == config_type:
if config_type == 'database':
# For database configs, same source means same db type, host, port, and db name
if (existing_config_data.get('db_type', 'mysql') == config.get('db_type', 'mysql') and
existing_config_data.get('mysql_host') == config.get('mysql_host') and
existing_config_data.get('mysql_port') == config.get('mysql_port') and
existing_config_data.get('database') == config.get('database')):
raise HTTPException(
status_code=409,
detail=f"已存在相同源的数据库配置。如需调整,请点击配置列表中的配置并修改配置内容。"
)
elif config_type == 'folder':
# For folder configs, same source means same host and folder path
if (existing_config_data.get('host') == config.get('host') and
existing_config_data.get('folder_path') == config.get('folder_path')):
raise HTTPException(
status_code=409,
detail=f"已存在相同服务器和路径的文件夹配置。如需调整,请点击配置列表中的配置并修改配置内容。"
)
elif config_type == 'git':
# For git configs, same source means same git url
if existing_config_data.get('git_url') == config.get('git_url'):
raise HTTPException(
status_code=409,
detail=f"已存在相同Git仓库的配置。如需调整请点击配置列表中的配置并修改配置内容。"
)
except sqlite3.OperationalError as e:
# 表不存在的情况,会在后面创建表
logger.warning(f"SQLite table error: {e}. This is expected if the table doesn't exist yet.")
# Check if a config with the same name already exists
cursor.execute('SELECT name FROM data_sources WHERE name = ?', (config_id,))
existing_config = cursor.fetchone()
if existing_config:
raise HTTPException(
status_code=409,
detail=f"配置 '{config['name']}' 已存在。如需调整,请点击配置列表中的配置并修改配置内容。"
)
# Insert new config into data_sources table
config_json = json.dumps(config, ensure_ascii=False, indent=2)
cursor.execute(
'INSERT INTO data_sources (name, config) VALUES (?, ?)',
(config_id, config_json)
)
conn.commit()
# Create sync service for this new data source
global sync_manager
if sync_manager is not None:
# Create appropriate data source config object
from config import BaseDataSourceConfig, DatabaseDataSourceConfig, FolderDataSourceConfig, GitDataSourceConfig
if config_type == "database":
source_config = DatabaseDataSourceConfig(
name=config_id,
database=config.get("database"),
table_name=config.get("table_name"),
id_column=config.get("id_column"),
content_column=config.get("content_column"),
file_column=config.get("file_column"),
title_column=config.get("title_column"),
metadata_columns=config.get("metadata_columns"),
content_separator=config.get("content_separator"),
updated_at_column=config.get("updated_at_column"),
host=config.get("mysql_host"),
port=config.get("mysql_port"),
user=config.get("mysql_user"),
password=config.get("mysql_password"),
db_type=config.get("db_type", "mysql"),
file_source_type=config.get("file_source_type"),
file_system_base_path=config.get("file_system_base_path"),
scp_host=config.get("scp_host"),
scp_port=config.get("scp_port"),
scp_username=config.get("scp_username"),
scp_password=config.get("scp_password")
)
elif config_type == "folder":
source_config = FolderDataSourceConfig(
name=config_id,
folder_path=config.get("folder_path"),
host=config.get("host"),
port=config.get("port"),
username=config.get("username"),
password=config.get("password"),
recursive=config.get("recursive", True),
ignore_patterns=config.get("ignore_patterns")
)
elif config_type == "git":
source_config = GitDataSourceConfig(
name=config_id,
git_url=config.get("git_url"),
branch=config.get("branch", "main"),
protocol=config.get("protocol", "https"),
ssh_key=config.get("ssh_key"),
https_token=config.get("https_token"),
local_repo_path=config.get("local_repo_path"),
poll_interval=config.get("poll_interval", 300),
support_lang=config.get("support_lang"),
latest_commit_id=config.get("latest_commit_id"),
last_sync_time=config.get("last_sync_time")
)
else:
logger.warning(f"Unknown config type: {config_type}")
# Create a base config as fallback
source_config = BaseDataSourceConfig(name=config_id, type=config_type)
# Create the sync service
sync_manager.create_or_update_sync_service(source_config)
# Return the created config
return {
"id": config_id,
"type": config_type,
"config": config
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating config: {e}")
raise HTTPException(status_code=500, detail=f"Error creating configuration: {str(e)}")
finally:
if conn:
conn.close()
@app.post("/folder/test-connection")
async def test_folder_connection(connection_data: Dict[str, Any]):
"""
Test SSH connection for folder configuration
Args:
connection_data: Connection data including host, port, username, password
Returns:
Success message if connection is successful
"""
try:
host = connection_data.get("host")
port = connection_data.get("port", 22)
username = connection_data.get("username")
password = connection_data.get("password")
if not host or not username:
raise HTTPException(status_code=400, detail="主机地址和用户名是必填项")
# Import SSHClient here to avoid circular imports
from sync.folder_sync import SSHClient
# Test SSH connection
ssh_client = SSHClient(
host=host,
port=port,
username=username,
password=password
)
# Try to connect
success = ssh_client.test_connection()
if success:
return {"message": "SSH连接成功"}
else:
raise HTTPException(status_code=500, detail="SSH连接失败")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error testing SSH connection: {e}")
raise HTTPException(status_code=500, detail=f"SSH连接失败: {str(e)}")
@app.post("/git/test-connection")
async def test_git_connection(connection_data: Dict[str, Any]):
"""
Test Git connection for Git repository configuration
Args:
connection_data: Connection data including git_url, protocol, branch, https_token, ssh_key
Returns:
Success message if connection is successful
"""
try:
git_url = connection_data.get("git_url")
protocol = connection_data.get("protocol", "https")
branch = connection_data.get("branch", "main")
https_token = connection_data.get("https_token")
ssh_key = connection_data.get("ssh_key")
if not git_url:
raise HTTPException(status_code=400, detail="Git仓库URL是必填项")
# Import GitTool here to avoid circular imports
from utils.git_tool import GitTool
# Create a temporary GitTool instance to test connection
git_tool = GitTool(
git_url=git_url,
branch=branch,
protocol=protocol,
https_token=https_token,
ssh_key=ssh_key,
local_repo_path=None # 测试连接不需要本地路径
)
# Try to test connection
success = git_tool.test_connection()
if success:
return {"message": "Git连接成功"}
else:
raise HTTPException(status_code=500, detail="Git连接失败")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error testing Git connection: {e}")
raise HTTPException(status_code=500, detail=f"Git连接失败: {str(e)}")
@app.post("/folder-configs/remote")
async def create_remote_folder_config(config: Dict[str, Any]):
"""
Create a new remote folder configuration (deprecated, use /folder-configs instead)
Args:
config: Configuration data for the new remote folder
Returns:
Created configuration with ID
"""
# Set type to folder if not provided
if "type" not in config:
config["type"] = "folder"
# Call the generic create_config function
return await create_config(config)
@app.put("/folder-configs/{config_id}")
async def update_folder_config(config_id: str, config: Dict[str, Any]):
"""
Update an existing folder configuration
Args:
config_id: ID of the configuration to update
config: Updated configuration data
Returns:
Updated configuration
"""
conn = None
try:
conn, cursor = get_db_connection()
# Check if config exists
cursor.execute('SELECT * FROM data_sources WHERE name = ?', (config_id,))
existing_config = cursor.fetchone()
if not existing_config:
raise HTTPException(status_code=404, detail=f"Configuration with ID '{config_id}' not found")
# Ensure config has required fields
if "type" not in config:
raise HTTPException(status_code=400, detail="Configuration type is required")
# Generate new config ID based on the complete configuration information
config_type = config["type"]
new_config_id = None
# 根据不同类型的配置生成有意义的ID
if config_type == "database":
# 数据库配置使用数据库类型、数据库名和表名生成ID
if not config.get("database"):
raise HTTPException(status_code=400, detail="Database name is required for database configuration")
if not config.get("table_name"):
raise HTTPException(status_code=400, detail="Table name is required for database configuration")
db_type = config.get("db_type", "mysql").lower()
new_config_id = f"{config_type}_{db_type}_{config['database'].lower()}_{config['table_name'].lower()}"
elif config_type == "folder":
# 文件夹配置根据是否有host字段区分本地和远程
if not config.get("folder_path"):
raise HTTPException(status_code=400, detail="Folder path is required for folder configuration")
# 替换路径中的特殊字符为下划线
folder_path = config["folder_path"].lower().replace("/", "_").replace(":", "_").replace("\\", "_")
# 去除开头的下划线
if folder_path.startswith("_"):
folder_path = folder_path[1:]
# 根据是否有host字段生成不同的ID
if config.get("host") and config["host"] != "localhost":
# 远程文件夹使用主机和文件夹路径生成ID
new_config_id = f"{config_type}_{config['host'].lower()}_{folder_path}"
else:
# 本地文件夹使用文件夹路径生成ID
new_config_id = f"{config_type}_{folder_path}"
# 如果无法生成新的有意义的ID保留原来的ID
if not new_config_id:
new_config_id = config_id
# Update config in data_sources table
# Since name is primary key, we need to use INSERT OR REPLACE
config_json = json.dumps(config, ensure_ascii=False, indent=2)
# First, delete the old config if new ID is different
if new_config_id != config_id:
cursor.execute('DELETE FROM data_sources WHERE name = ?', (config_id,))
# Then insert the updated config with new ID, update_at value to null
cursor.execute(
'INSERT OR REPLACE INTO data_sources (name, config, update_at) VALUES (?, ?, ?)',
(new_config_id, config_json, None)
)
conn.commit()
# Update the sync service for this data source
global sync_manager
if sync_manager is not None:
# Create appropriate data source config object
from config import BaseDataSourceConfig, DatabaseDataSourceConfig, FolderDataSourceConfig
if config_type == "database":
source_config = DatabaseDataSourceConfig(
name=new_config_id,
database=config.get("database"),
table_name=config.get("table_name"),
id_column=config.get("id_column"),
content_column=config.get("content_column"),
file_column=config.get("file_column"),
title_column=config.get("title_column"),
metadata_columns=config.get("metadata_columns"),
content_separator=config.get("content_separator"),
updated_at_column=config.get("updated_at_column"),
host=config.get("mysql_host"),
port=config.get("mysql_port"),
user=config.get("mysql_user"),
password=config.get("mysql_password"),
db_type=config.get("db_type", "mysql"),
file_source_type=config.get("file_source_type"),
file_system_base_path=config.get("file_system_base_path"),
scp_host=config.get("scp_host"),
scp_port=config.get("scp_port"),
scp_username=config.get("scp_username"),
scp_password=config.get("scp_password")
)
elif config_type == "folder":
source_config = FolderDataSourceConfig(
name=new_config_id,
folder_path=config.get("folder_path"),
host=config.get("host"),
port=config.get("port"),
username=config.get("username"),
password=config.get("password"),
recursive=config.get("recursive", True),
ignore_patterns=config.get("ignore_patterns")
)
else:
logger.warning(f"Unknown config type: {config_type}")
# Create a base config as fallback
source_config = BaseDataSourceConfig(name=new_config_id, type=config_type)
# Update the sync service
sync_manager.create_or_update_sync_service(source_config)
# Return the updated config with new ID
return {
"id": new_config_id,
"type": config_type,
"config": config
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating folder config: {e}")
raise HTTPException(status_code=500, detail=f"Error updating configuration: {str(e)}")
finally:
if conn:
conn.close()
@app.delete("/folder-configs/{config_id}")
async def delete_folder_config(config_id: str):
"""
Delete a folder configuration
Args:
config_id: ID of the configuration to delete
Returns:
Success message
"""
conn = None
try:
conn, cursor = get_db_connection()
# Check if config exists
cursor.execute('SELECT * FROM data_sources WHERE name = ?', (config_id,))
existing_config = cursor.fetchone()
if not existing_config:
raise HTTPException(status_code=404, detail=f"Configuration with ID '{config_id}' not found")
# Delete config from data_sources table
cursor.execute('DELETE FROM data_sources WHERE name = ?', (config_id,))
conn.commit()
# Remove the sync service for this data source
global sync_manager
if sync_manager is not None:
sync_manager.remove_sync_service(config_id)
return {"status": "success", "message": f"Configuration '{config_id}' deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting folder config: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting configuration: {str(e)}")
finally:
if conn:
conn.close()
# Database schema exploration endpoints
from pydantic import BaseModel
import mysql.connector
class DatabaseConnectionParams(BaseModel):
"""Database connection parameters"""
host: str = Field(..., description="Database host")
port: int = Field(default=3306, description="Database port")
username: str = Field(..., description="Database username")
password: str = Field(..., description="Database password")
db_type: str = Field(default="mysql", description="Database type: mysql or dameng")
class DatabaseParams(BaseModel):
"""Database parameters"""
host: str = Field(..., description="Database host")
port: int = Field(default=3306, description="Database port")
username: str = Field(..., description="Database username")
password: str = Field(..., description="Database password")
database: str = Field(..., description="Database name")
db_type: str = Field(default="mysql", description="Database type: mysql or dameng")
class TableParams(BaseModel):
"""Table parameters"""
host: str = Field(..., description="Database host")
port: int = Field(default=3306, description="Database port")
username: str = Field(..., description="Database username")
password: str = Field(..., description="Database password")
database: str = Field(..., description="Database name")
table_name: str = Field(..., description="Table name")
db_type: str = Field(default="mysql", description="Database type: mysql or dameng")
@app.post("/database/databases")
async def get_databases(params: DatabaseConnectionParams):
"""
Get list of databases from database server
Args:
params: Database connection parameters
Returns:
List of available databases
"""
try:
if params.db_type == "dameng":
# DaMeng database connection
import dmPython
connection = dmPython.connect(
user=params.username,
password=params.password,
server=params.host,
port=params.port
)
cursor = connection.cursor()
# Get schemas for DaMeng (similar to databases in MySQL)
cursor.execute("SELECT NAME AS SCHEMA_NAME FROM SYSOBJECTS WHERE TYPE$ = 'SCH';")
databases = []
for (schema_name,) in cursor:
databases.append(schema_name)
cursor.close()
connection.close()
else:
# MySQL database connection
connection = mysql.connector.connect(
host=params.host,
port=params.port,
user=params.username,
password=params.password
)
cursor = connection.cursor()
cursor.execute("SHOW DATABASES")
databases = []
for (database_name,) in cursor:
# Skip system databases
if database_name not in ['information_schema', 'mysql', 'performance_schema', 'sys']:
databases.append(database_name)
cursor.close()
connection.close()
return {"databases": databases}
except Exception as e:
logger.error(f"Database connection error: {e}")
raise HTTPException(status_code=500, detail=f"Database connection failed: {str(e)}")
@app.post("/database/tables")
async def get_tables(params: DatabaseParams):
"""
Get list of tables from specified database
Args:
params: Database parameters including database name
Returns:
List of tables in the specified database
"""
try:
if params.db_type == "dameng":
# DaMeng database connection
import dmPython
connection = dmPython.connect(
user=params.username,
password=params.password,
server=params.host,
port=params.port
)
cursor = connection.cursor()
# Get tables for DaMeng
cursor.execute(f"SELECT TABLE_NAME FROM ALL_TABLES WHERE OWNER = '{params.database.upper()}'")
tables = []
for (table_name,) in cursor:
tables.append(table_name)
cursor.close()
connection.close()
else:
# MySQL database connection
connection = mysql.connector.connect(
host=params.host,
port=params.port,
user=params.username,
password=params.password,
database=params.database
)
cursor = connection.cursor()
cursor.execute("SHOW TABLES")
tables = []
for (table_name,) in cursor:
tables.append(table_name)
cursor.close()
connection.close()
return {"tables": tables}
except Exception as e:
logger.error(f"Database connection error: {e}")
raise HTTPException(status_code=500, detail=f"Database connection failed: {str(e)}")
@app.post("/database/table-structure")
async def get_table_structure(params: TableParams):
"""
Get structure of specified table
Args:
params: Table parameters including table name
Returns:
Table structure with column details
"""
try:
if params.db_type == "dameng":
# DaMeng database connection
import dmPython
connection = dmPython.connect(
user=params.username,
password=params.password,
server=params.host,
port=params.port
)
cursor = connection.cursor()
# Get table structure for DaMeng
cursor.execute(f"SELECT COLUMN_NAME, DATA_TYPE, NULLABLE, COLUMN_ID FROM ALL_TAB_COLUMNS WHERE OWNER = '{params.database.upper()}' AND TABLE_NAME = '{params.table_name.upper()}' ORDER BY COLUMN_ID")
columns = []
for (column_name, data_type, nullable, column_id) in cursor:
columns.append({
"name": column_name,
"type": data_type,
"null": "YES" if nullable == 'Y' else "NO",
"key": "", # DaMeng doesn't return key info in this query
"default": None,
"extra": ""
})
cursor.close()
connection.close()
else:
# MySQL database connection
connection = mysql.connector.connect(
host=params.host,
port=params.port,
user=params.username,
password=params.password,
database=params.database
)
cursor = connection.cursor()
cursor.execute(f"DESCRIBE {params.table_name}")
columns = []
for (field, type, null, key, default, extra) in cursor:
columns.append({
"name": field,
"type": type,
"null": null,
"key": key,
"default": default,
"extra": extra
})
cursor.close()
connection.close()
return {"columns": columns}
except Exception as e:
logger.error(f"Database connection error: {e}")
raise HTTPException(status_code=500, detail=f"Database connection failed: {str(e)}")