diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..6ea9a2d --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,35 @@ +name: Deploy Backend to Azure + +on: + push: + branches: [main] + paths: + - "backend/**" + - ".github/workflows/deploy-backend.yml" + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Create deployment package + run: | + cd backend + pip install -r requirements.txt --target=".python_packages/lib/site-packages" + zip -r ../deploy.zip . -x "*.pyc" "__pycache__/*" ".venv/*" ".env" + + - name: Deploy to Azure Web App + uses: azure/webapps-deploy@v3 + with: + app-name: soc-backend + publish-profile: ${{ secrets.AZURE_BACKEND_PUBLISH_PROFILE }} + package: deploy.zip diff --git a/CLAUDE.md b/CLAUDE.md index eb15b1f..a26bdfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,50 +4,94 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -This is a 1:1 pixel-perfect clone of the Google Gemini chat interface, built with Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4, and shadcn/ui. It was generated via [v0.app](https://v0.app/chat/lx6pc6oofZh) and auto-synced to this repo. Deployed on Vercel. +so-c-chat-clone — 企业级 Gemini 风格对话系统,前后端分离。 -## Commands +- **前端** (`frontend/`): Next.js 16 + React 19 + Tailwind CSS 4 + shadcn/ui,1:1 复刻 Google Gemini UI +- **后端** (`backend/`): LangChain + LangGraph + Litestar,基于 LangGraph 编排的对话 Agent -```bash -npm install # Install dependencies -npm run dev # Start dev server (localhost:3000) -npm run build # Build for production -npm run lint # Run ESLint -npm start # Start production server +## Project Structure + +``` +├── frontend/ # Next.js 前端(前端代码未经明确指定不允许修改) +├── backend/ # Python 后端(LangChain + LangGraph) +├── gpthd.md # 后端功能方案(18项功能,开发前必读) +├── EXTERNAL_SERVICES.md # 外部服务凭据与接入配置 +└── claudehd.md # 后端技术方案(按功能拆解) ``` -## Architecture +## Frontend -### Entry Point -- `app/page.tsx` — renders `` only; all logic lives in components +### Commands +```bash +cd frontend +npm install && npm run dev # Dev server (localhost:3000) +npm run build # Production build +npm run lint # ESLint +``` -### Core Component: `components/gemini/GeminiChat.tsx` -The root stateful component. Owns all state: sidebar open/close, active conversation, message list, typing indicator. Composes all other Gemini components. +### Architecture +- Entry: `frontend/app/page.tsx` → `` +- `GeminiChat.tsx` owns all state, composes sidebar/topbar/input/message/welcome components +- `GeminiInput.tsx` has `activeTools` (Set\) and `selectedModel` ("flash"|"pro") as local state +- `simulateAIResponse()` is the mock function to be replaced by real backend API +- All mock data (conversations, tickets) lives in `GeminiChat.tsx` +- Dark theme only, hardcoded palette (`#131314` bg, `#1e1e1e` sidebar, `#4285f4→#a855f7` gradient) -### Gemini Component Breakdown +## Backend -| Component | Purpose | -|---|---| -| `GeminiSidebar.tsx` | Left sidebar — logo, new chat button, conversation history list, bottom nav (Help/Activity/Extensions), user avatar | -| `GeminiTopbar.tsx` | Top bar — hamburger toggle, model selector dropdown (Gemini 2.0 Flash etc.), settings + avatar | -| `GeminiWelcome.tsx` | Empty state — gradient greeting, 4 suggestion cards | -| `GeminiMessage.tsx` | Single message — user bubble (right-aligned, bg pill) vs AI response (left-aligned, ◆ icon, no bubble, markdown-like rendering) | -| `GeminiInput.tsx` | Bottom input — rounded container, attachment icon, auto-resize textarea, mic + send button | -| `GeminiTypingIndicator.tsx` | Animated dots shown while AI is "responding" | -| `ExtensionsPanel.tsx` | Extensions panel UI | +### Tech Stack +- **Web**: Litestar + Uvicorn +- **Graph**: LangGraph StateGraph + create_react_agent (ReAct) +- **LLM**: LangChain AzureChatOpenAI (gpt-5.4) +- **Tools**: LangChain @tool (KB search, Jina web search, Daytona sandbox, Doc Creator, Gongdan tickets) +- **DB**: PostgreSQL + asyncpg + LangGraph AsyncPostgresSaver +- **Cache**: Redis (Azure) +- **Storage**: Azure Blob Storage +- **Async**: Azure Service Bus -### Styling -- Tailwind CSS 4 with `@tailwindcss/postcss` -- Dark theme only — hardcoded color palette: - - Page bg: `#131314`, Sidebar: `#1e1e1e`, Hover/card: `#2a2a2a`, Border: `#3a3a3a` - - Primary text: `#e3e3e3`, Muted: `#9aa0a6` - - Accent gradient: `from-[#4285f4] to-[#a855f7]` -- `components/ui/` — standard shadcn/ui primitives (do not modify directly) +### Commands +```bash +cd backend +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt # Or: pip install -e . +uvicorn app.main:app --port 8000 --reload # Dev server +``` -### Key Patterns -- All components use `"use client"` — no server components beyond the page shell -- `cn()` from `@/lib/utils` for conditional class merging -- No external API calls — all data is mock/local state in `GeminiChat.tsx` +### Core API +``` +POST /api/chat/stream # SSE streaming chat (replaces simulateAIResponse) +GET /api/conversations # List conversations +POST /api/conversations # Create conversation +GET /api/tickets # Proxy to Gongdan ticket system +GET /health # Health check +``` -## Sync Workflow -This repo is auto-synced from v0.app. Changes made on v0.app are pushed here automatically, then deployed to Vercel. Manual edits here may be overwritten on the next v0 sync. +### Development Phases (see gpthd.md for full details) +1. Basic chat graph + PostgreSQL + SSE streaming +2. Tool integration (KB Agent, tickets, ReAct routing) +3. External search (Jina Search/Reader/Rerank) + Redis cache +4. Doc generation + Sandbox + Blob Storage + Service Bus + +## External Services + +All credentials in `EXTERNAL_SERVICES.md`. Key services: + +| Service | Purpose | +|---------|---------| +| Azure OpenAI (gpt-5.4) | LLM generation | +| KB Agent (Azure AI Search) | Internal knowledge retrieval | +| Jina AI (Search/Reader/Rerank) | External web search | +| Daytona | Sandboxed code execution | +| Doc Creator Agent | Word/PPT/Excel generation | +| Gongdan API | Ticket system (read-only) | +| PostgreSQL (Azure) | Persistence | +| Redis (Azure) | Caching | +| Azure Blob Storage | File storage | +| Azure Service Bus | Async task queue | + +## Constraints + +- **Frontend code is read-only** unless explicitly authorized. Only approved change: extending `onSubmit` to pass `tools[]` and `model` to backend. +- **Azure resources** must stay within `AuthData` and `Operation` resource groups only. +- **CI/CD** is managed by the user, not by agents. +- **Tool invocation policy**: User-selected tools are passed to the LangGraph ReAct Agent as available tools. The Agent decides whether to actually use them. If it decides not to, it must explain why in its response. diff --git a/EXTERNAL_SERVICES.md b/EXTERNAL_SERVICES.md index a26b689..94cbb4c 100644 --- a/EXTERNAL_SERVICES.md +++ b/EXTERNAL_SERVICES.md @@ -8,7 +8,7 @@ --- -## 1. LLM 大语言模型 ✅ 已接入 +## 1. LLM 大语言模型 > 当前使用 Azure OpenAI,已在后端 graph.py / main.py 中集成。 @@ -33,7 +33,7 @@ curl -X POST "${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYM --- -## 2. 内部知识库检索 ✅ 已接入 +## 2. 内部知识库检索 > 当前通过 agnetdoc Function App 调用 Azure AI Search。 @@ -77,18 +77,18 @@ curl -X POST "${KB_AGENT_URL}/api/v1/search" \ --- -## 3. 外部 AI 搜索 ✅ 已接入 +## 3. 外部 AI 搜索 目前外部搜索采用https://mcp.jina.ai/sse 或者 /v1 可优先测试 jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI 要求使用搜索和读取两个工具,并且要结合重排模型使用。 满足企业级的搜索准确度,包括不限于图片和视频 按照深度和快速来定义搜索内容和搜索的质量,还需要满足前端的展示。 - +支持MCP --- -## 4. 沙盒代码执行 ✅ 已接入 +## 4. 沙盒代码执行 沙盒采用现成的解决方案。https://docs.langchain.com/oss/python/integrations/sandboxes/daytona https://app.daytona.io/api @@ -96,13 +96,13 @@ dtn_066b83f57f0337c96fae2ef1f5c8456477a39dfbd5fc615456263fd4947108c2 依然要满足前端输出要求。 -## 5. 文档生成 Agent ✅ 已接入 +## 5. 文档生成 Agent http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com sk-t5R8jkEp6IA7_ghJ6Hy1rQ http://agnetdoc.taijiaicloud.com/node/019cd223-9d13-7566-a2ea-52ee67645463 -## 6. 工单系统 ✅ 已接入 +## 6. 工单系统 > gongdan 工单系统,只读集成。 @@ -125,27 +125,28 @@ curl -X GET "${GONGDAN_API_BASE}/api/tickets/{ticketId}" \ --- -## 7. 数据库 ✅ 已接入(可选) - -> 当前代码支持 `DATABASE_URL` 持久化;未配置时会回退到内存模式。 - -### 当前代码侧现状 -- `persistence.py` 已支持 PostgreSQL -- 线程 / 分支等数据可持久化 -- 文档 workspace 元数据、sandbox 运行记录等也有数据库侧支持 -- 若未配置 `DATABASE_URL`,系统仍可运行,但持久化能力会受限 - -### 当前示例 +## 7. Pgsql数据库 ``` DATABASE_URL=postgresql://USER:PASSWORD@:5432/yydn?sslmode=require ``` +``` dataope.postgres.database.azure.com azuredb:h13nYoFJX6QrfLzB8bdipEUCjsZq2P7W - -### 说明 -- 如果后续要迁移数据库主机,请单独更新部署环境变量与运维文档 - - - +``` --- +### 8.Redis +``` +oper.redis.cache.windows.net:6380,password=bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=,ssl=True,abortConnect=False +``` +--- +### 9.存储账户 +``` +DefaultEndpointsProtocol=https;AccountName=authdatablol;AccountKey=sm3ysR0zAmS9OLtiHVau3Wj122YWQJTuMHAyHO4ReIrpe6+3r1K7oGfFLGCZSZh+1n72gbK1q/+C+AStgrZ7fw==;EndpointSuffix=core.windows.net +``` +--- +### 10.service bus +``` +Endpoint=sb://databus.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=+b7+0KMW1UQt5mbJEkA7uRxds4h0h4VNK+ASbOH5q3E= +``` +--- diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..aa02902 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual env +.venv/ +venv/ +ENV/ + +# Environment +.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..d3c0abd --- /dev/null +++ b/backend/app/api/chat.py @@ -0,0 +1,136 @@ +"""SSE streaming chat endpoint.""" + +from __future__ import annotations + +import json +import uuid +from collections.abc import AsyncIterator + +from langchain_core.messages import HumanMessage +from litestar import post +from litestar.response import Stream + +from app.graph.builder import get_chat_graph +from app.schemas import ChatRequest +from app.store.postgres import Conversation, Message, async_session_factory +from app.tools import resolve_tools + + +async def _ensure_conversation(conversation_id: str, first_message: str) -> None: + """Create conversation and persist the user message.""" + async with async_session_factory() as session: + existing = await session.get(Conversation, conversation_id) + if existing is None: + # Use first ~50 chars of message as title + title = first_message[:50].strip() or "New conversation" + conv = Conversation(id=conversation_id, title=title) + session.add(conv) + # Persist user message + msg = Message( + conversation_id=conversation_id, + role="human", + content=first_message, + ) + session.add(msg) + await session.commit() + + +async def _persist_ai_message(conversation_id: str, content: str) -> None: + """Persist the AI response message.""" + async with async_session_factory() as session: + msg = Message( + conversation_id=conversation_id, + role="ai", + content=content, + ) + session.add(msg) + await session.commit() + + +async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]: + """Stream LLM response tokens via SSE.""" + # Ensure conversation exists and persist user message + await _ensure_conversation(request.conversation_id, request.message) + + # Resolve tools from frontend tool keys + active_tools = resolve_tools(request.tools) + + graph = await get_chat_graph(model=request.model, tools=active_tools) + + config = { + "configurable": {"thread_id": request.conversation_id}, + } + + # When using ReAct agent (with tools), input is just messages. + # When using plain graph (no tools), input includes model key. + if active_tools: + input_data = {"messages": [HumanMessage(content=request.message)]} + else: + input_data = { + "messages": [HumanMessage(content=request.message)], + "model": request.model, + } + + full_content: list[str] = [] + + async for event in graph.astream_events( + input_data, + config=config, + version="v2", + ): + kind = event.get("event", "") + + if kind == "on_chat_model_stream": + chunk = event.get("data", {}).get("chunk") + if chunk and hasattr(chunk, "content") and chunk.content: + # Only stream text content, skip tool call chunks + if isinstance(chunk.content, str): + full_content.append(chunk.content) + sse_data = json.dumps( + {"type": "token", "content": chunk.content}, + ensure_ascii=False, + ) + yield f"data: {sse_data}\n\n".encode("utf-8") + + elif kind == "on_tool_start": + # Notify frontend that a tool is being called + tool_name = event.get("name", "unknown") + sse_data = json.dumps( + {"type": "tool_start", "tool": tool_name}, + ensure_ascii=False, + ) + yield f"data: {sse_data}\n\n".encode("utf-8") + + elif kind == "on_tool_end": + tool_name = event.get("name", "unknown") + sse_data = json.dumps( + {"type": "tool_end", "tool": tool_name}, + ensure_ascii=False, + ) + yield f"data: {sse_data}\n\n".encode("utf-8") + + # Persist AI response + ai_content = "".join(full_content) + if ai_content: + await _persist_ai_message(request.conversation_id, ai_content) + + # Send done signal + done_data = json.dumps({"type": "done"}) + yield f"data: {done_data}\n\n".encode("utf-8") + + +@post("/api/chat/stream") +async def stream_chat(data: ChatRequest) -> Stream: + """POST /api/chat/stream - SSE streaming chat endpoint.""" + if not data.conversation_id: + data.conversation_id = str(uuid.uuid4()) + + return Stream( + _stream_response(data), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py new file mode 100644 index 0000000..74cbec4 --- /dev/null +++ b/backend/app/api/conversations.py @@ -0,0 +1,113 @@ +"""Conversation CRUD endpoints backed by PostgreSQL.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from litestar import delete, get, patch, post +from litestar.exceptions import NotFoundException +from sqlalchemy import select + +from app.schemas import ( + ConversationCreate, + ConversationDetail, + ConversationOut, + ConversationUpdate, + MessageOut, +) +from app.store.postgres import Conversation, Message, async_session_factory + + +def _conv_to_out(conv: Conversation) -> ConversationOut: + """Convert a Conversation ORM object to the API response model.""" + return ConversationOut( + id=conv.id, + title=conv.title, + created_at=conv.created_at.isoformat(), + updated_at=conv.updated_at.isoformat(), + ) + + +@get("/api/conversations") +async def list_conversations() -> list[ConversationOut]: + """GET /api/conversations - List all conversations.""" + async with async_session_factory() as session: + stmt = select(Conversation).order_by(Conversation.updated_at.desc()) + result = await session.execute(stmt) + convs = result.scalars().all() + return [_conv_to_out(c) for c in convs] + + +@get("/api/conversations/{conversation_id:str}") +async def get_conversation(conversation_id: str) -> ConversationDetail: + """GET /api/conversations/:id - Get a single conversation with messages.""" + async with async_session_factory() as session: + conv = await session.get(Conversation, conversation_id) + if conv is None: + raise NotFoundException(detail=f"Conversation {conversation_id} not found") + # Eagerly load messages + stmt = select(Message).where( + Message.conversation_id == conversation_id + ).order_by(Message.created_at) + result = await session.execute(stmt) + msgs = result.scalars().all() + return ConversationDetail( + id=conv.id, + title=conv.title, + created_at=conv.created_at.isoformat(), + updated_at=conv.updated_at.isoformat(), + messages=[ + MessageOut( + id=m.id, + role=m.role, + content=m.content, + created_at=m.created_at.isoformat(), + ) + for m in msgs + ], + ) + + +@post("/api/conversations") +async def create_conversation(data: ConversationCreate) -> ConversationOut: + """POST /api/conversations - Create a new conversation.""" + conv = Conversation( + id=str(uuid.uuid4()), + title=data.title, + ) + async with async_session_factory() as session: + session.add(conv) + await session.commit() + await session.refresh(conv) + return _conv_to_out(conv) + + +@patch("/api/conversations/{conversation_id:str}") +async def update_conversation( + conversation_id: str, + data: ConversationUpdate, +) -> ConversationOut: + """PATCH /api/conversations/:id - Update conversation title.""" + async with async_session_factory() as session: + conv = await session.get(Conversation, conversation_id) + if conv is None: + raise NotFoundException( + detail=f"Conversation {conversation_id} not found" + ) + conv.title = data.title + conv.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(conv) + return _conv_to_out(conv) + + +@delete("/api/conversations/{conversation_id:str}", status_code=200) +async def delete_conversation(conversation_id: str) -> dict: + """DELETE /api/conversations/:id - Delete a conversation.""" + async with async_session_factory() as session: + conv = await session.get(Conversation, conversation_id) + if conv is not None: + await session.delete(conv) + await session.commit() + return {"deleted": True} diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..90e8934 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,10 @@ +"""Health check endpoint.""" + +from __future__ import annotations + +from litestar import get + + +@get("/health") +async def health_check() -> dict: + return {"status": "ok"} diff --git a/backend/app/api/tickets.py b/backend/app/api/tickets.py new file mode 100644 index 0000000..5308b1e --- /dev/null +++ b/backend/app/api/tickets.py @@ -0,0 +1,80 @@ +"""Ticket API endpoints — proxy to Gongdan system. + +Returns data in a format aligned with the frontend TicketData interface: + { id, title, status, priority, createdAt } +""" + +from __future__ import annotations + +import httpx +from litestar import get +from litestar.exceptions import NotFoundException + +from app.config import settings + + +def _gongdan_headers() -> dict[str, str]: + return {"X-Api-Key": settings.gongdan_api_key} + + +def _map_status(raw: str) -> str: + mapping = { + "OPEN": "pending", + "ASSIGNED": "processing", + "IN_PROGRESS": "processing", + "PENDING_CUSTOMER": "processing", + "RESOLVED": "resolved", + "CLOSED": "resolved", + } + return mapping.get(raw, "pending") + + +def _map_priority(raw: str) -> str: + mapping = { + "URGENT": "P0", + "PRIORITY": "P1", + "NORMAL": "P2", + "LOW": "P3", + } + return mapping.get(raw, "P2") + + +def _transform_ticket(t: dict) -> dict: + """Transform a Gongdan ticket to the frontend TicketData shape.""" + return { + "id": t.get("ticketNumber", t.get("id", "")), + "title": t.get("description", "")[:120] or "No description", + "status": _map_status(t.get("status", "")), + "priority": _map_priority(t.get("priority", "")), + "createdAt": t.get("createdAt", ""), + } + + +@get("/api/tickets") +async def list_tickets(page: int = 1, page_size: int = 20) -> list[dict]: + """GET /api/tickets — List tickets from Gongdan, formatted for frontend.""" + url = f"{settings.gongdan_api_base}/api/tickets" + params = {"page": page, "pageSize": page_size} + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params, headers=_gongdan_headers()) + resp.raise_for_status() + data = resp.json() + + tickets = data.get("tickets", []) + return [_transform_ticket(t) for t in tickets] + + +@get("/api/tickets/{ticket_id:str}") +async def get_ticket(ticket_id: str) -> dict: + """GET /api/tickets/:id — Get a single ticket detail.""" + url = f"{settings.gongdan_api_base}/api/tickets/{ticket_id}" + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, headers=_gongdan_headers()) + if resp.status_code == 404: + raise NotFoundException(detail=f"Ticket {ticket_id} not found") + resp.raise_for_status() + t = resp.json() + + return _transform_ticket(t) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..4eb548d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,50 @@ +"""Application configuration via pydantic-settings.""" + +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Azure OpenAI + azure_openai_endpoint: str = "" + azure_openai_api_key: str = "" + azure_openai_api_version: str = "2025-04-01-preview" + azure_openai_deployment: str = "gpt-5.4" + + # PostgreSQL + database_url: str = "postgresql+asyncpg://azuredb:h13nYoFJX6QrfLzB8bdipEUCjsZq2P7W@dataope.postgres.database.azure.com:5432/soc?ssl=require" + + # LangGraph checkpointer uses psycopg (not asyncpg) connection string + @property + def database_url_psycopg(self) -> str: + """Return psycopg-compatible connection string for LangGraph checkpointer.""" + url = self.database_url.replace("postgresql+asyncpg://", "postgresql://") + # psycopg uses sslmode=require, not ssl=require + url = url.replace("?ssl=require", "?sslmode=require") + url = url.replace("&ssl=require", "&sslmode=require") + return url + + # KB Agent + kb_agent_url: str = "https://agnetdoc-cve0guf5h8eggmej.southeastasia-01.azurewebsites.net" + kb_agent_api_key: str = "" + kb_agent_search_path: str = "/api/v1/search" + kb_agent_search_timeout_sec: int = 15 + + # Gongdan (ticket system) + gongdan_api_base: str = "https://gongdan-b5fzbtgteqd5gzfb.eastasia-01.azurewebsites.net" + gongdan_api_key: str = "" + + # Server + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False + + +settings = Settings() diff --git a/backend/app/graph/__init__.py b/backend/app/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/graph/builder.py b/backend/app/graph/builder.py new file mode 100644 index 0000000..ae12dd1 --- /dev/null +++ b/backend/app/graph/builder.py @@ -0,0 +1,89 @@ +"""Build and compile the LangGraph agent. + +Phase 1 used a simple single-node StateGraph. +Phase 2 upgrades to create_react_agent (ReAct pattern) with dynamic tool binding. + +When no tools are requested, we fall back to a plain single-node graph so the +agent does not produce unnecessary tool-call reasoning. +""" + +from __future__ import annotations + +from langchain_openai import AzureChatOpenAI +from langgraph.graph import StateGraph +from langgraph.prebuilt import create_react_agent + +from app.config import settings +from app.graph.nodes import call_model +from app.graph.state import ChatState +from app.store.memory import get_checkpointer + +# Model parameter presets +MODEL_PARAMS: dict[str, dict] = { + "flash": {"max_tokens": 500, "temperature": 0.2}, + "pro": {"max_tokens": 4096, "temperature": 0.3}, +} + +# System prompt that instructs the ReAct agent +SYSTEM_PROMPT = ( + "You are SOC Assistant, an enterprise AI assistant. " + "You help users with knowledge base queries, ticket management, " + "and general questions. " + "When the user has enabled specific tools, you may use them if relevant. " + "If you decide not to use an available tool, briefly explain why. " + "Always respond in the same language the user uses. " + "Be concise, accurate, and helpful." +) + +# Cache compiled graphs to avoid re-creation on every request. +# Key: (model, frozenset(tool_names)) +_graph_cache: dict[tuple, object] = {} + + +def _get_llm(model: str) -> AzureChatOpenAI: + """Create an AzureChatOpenAI instance with preset parameters.""" + params = MODEL_PARAMS.get(model, MODEL_PARAMS["flash"]) + return AzureChatOpenAI( + azure_endpoint=settings.azure_openai_endpoint, + api_key=settings.azure_openai_api_key, + api_version=settings.azure_openai_api_version, + azure_deployment=settings.azure_openai_deployment, + max_tokens=params["max_tokens"], + temperature=params["temperature"], + streaming=True, + ) + + +async def get_chat_graph(model: str = "flash", tools: list | None = None): + """Get or create a compiled graph for the given model and tool set. + + When tools are provided, creates a ReAct agent that can call tools. + When no tools, falls back to a simple single-node graph. + """ + tools = tools or [] + cache_key = (model, frozenset(t.name for t in tools)) + + if cache_key in _graph_cache: + return _graph_cache[cache_key] + + checkpointer = await get_checkpointer() + llm = _get_llm(model) + + if tools: + # ReAct agent with tool calling + graph = create_react_agent( + llm, + tools=tools, + checkpointer=checkpointer, + prompt=SYSTEM_PROMPT, + ) + else: + # Simple graph without tools (Phase 1 style) + builder = StateGraph(ChatState) + builder.add_node("agent", call_model) + builder.set_entry_point("agent") + builder.set_finish_point("agent") + graph = builder.compile(checkpointer=checkpointer) + + _graph_cache[cache_key] = graph + return graph diff --git a/backend/app/graph/nodes.py b/backend/app/graph/nodes.py new file mode 100644 index 0000000..389b7f7 --- /dev/null +++ b/backend/app/graph/nodes.py @@ -0,0 +1,36 @@ +"""LangGraph node functions.""" + +from __future__ import annotations + +from langchain_openai import AzureChatOpenAI + +from app.config import settings +from app.graph.state import ChatState + +# Model parameter presets +MODEL_PARAMS: dict[str, dict] = { + "flash": {"max_tokens": 500, "temperature": 0.2}, + "pro": {"max_tokens": 4096, "temperature": 0.3}, +} + + +def _get_llm(model: str) -> AzureChatOpenAI: + """Create an AzureChatOpenAI instance with preset parameters.""" + params = MODEL_PARAMS.get(model, MODEL_PARAMS["flash"]) + return AzureChatOpenAI( + azure_endpoint=settings.azure_openai_endpoint, + api_key=settings.azure_openai_api_key, + api_version=settings.azure_openai_api_version, + azure_deployment=settings.azure_openai_deployment, + max_tokens=params["max_tokens"], + temperature=params["temperature"], + streaming=True, + ) + + +async def call_model(state: ChatState) -> dict: + """Invoke the LLM with the current message history.""" + model = state.get("model", "flash") + llm = _get_llm(model) + response = await llm.ainvoke(state["messages"]) + return {"messages": [response]} diff --git a/backend/app/graph/state.py b/backend/app/graph/state.py new file mode 100644 index 0000000..485786c --- /dev/null +++ b/backend/app/graph/state.py @@ -0,0 +1,11 @@ +"""LangGraph state definition.""" + +from __future__ import annotations + +from langgraph.graph import MessagesState + + +class ChatState(MessagesState): + """Extends MessagesState with model selection.""" + + model: str # "flash" or "pro" diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..cb1d9fd --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,61 @@ +"""Litestar application entry point.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator + +from dotenv import load_dotenv + +# Load .env before anything else reads settings +load_dotenv() + +from litestar import Litestar +from litestar.config.cors import CORSConfig + +from app.api.chat import stream_chat +from app.api.conversations import ( + create_conversation, + delete_conversation, + get_conversation, + list_conversations, + update_conversation, +) +from app.api.health import health_check +from app.api.tickets import get_ticket, list_tickets +from app.store.memory import close_checkpointer +from app.store.postgres import create_tables, dispose_engine + +cors_config = CORSConfig( + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=False, +) + + +@asynccontextmanager +async def lifespan(app: Litestar) -> AsyncGenerator[None, None]: + """Application lifespan: create tables on startup, dispose engine on shutdown.""" + await create_tables() + yield + await close_checkpointer() + await dispose_engine() + + +app = Litestar( + route_handlers=[ + health_check, + stream_chat, + list_conversations, + get_conversation, + create_conversation, + update_conversation, + delete_conversation, + list_tickets, + get_ticket, + ], + cors_config=cors_config, + lifespan=[lifespan], + debug=False, +) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..6150232 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,45 @@ +"""Request / Response Pydantic models.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ChatRequest(BaseModel): + message: str = Field(..., min_length=1) + conversation_id: str = Field(..., min_length=1) + tools: list[str] = Field(default_factory=list) + model: str = Field(default="flash", pattern="^(flash|pro)$") + + +class ChatResponse(BaseModel): + """Non-streaming chat response (for reference; SSE is primary).""" + conversation_id: str + content: str + + +class ConversationCreate(BaseModel): + title: str = Field(default="New conversation") + + +class ConversationUpdate(BaseModel): + title: str + + +class MessageOut(BaseModel): + id: str + role: str + content: str + created_at: str + + +class ConversationOut(BaseModel): + id: str + title: str + created_at: str + updated_at: str + + +class ConversationDetail(ConversationOut): + """Conversation with messages, returned by GET /api/conversations/{id}.""" + messages: list[MessageOut] = Field(default_factory=list) diff --git a/backend/app/store/__init__.py b/backend/app/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/store/memory.py b/backend/app/store/memory.py new file mode 100644 index 0000000..0d76df0 --- /dev/null +++ b/backend/app/store/memory.py @@ -0,0 +1,40 @@ +"""LangGraph checkpointer backed by PostgreSQL. + +Uses psycopg async driver for the LangGraph checkpoint tables, +while the rest of the app uses asyncpg via SQLAlchemy async. +""" + +from __future__ import annotations + +from psycopg import AsyncConnection +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + +from app.config import settings + +_checkpointer: AsyncPostgresSaver | None = None +_conn: AsyncConnection | None = None + + +async def get_checkpointer() -> AsyncPostgresSaver: + """Return a singleton AsyncPostgresSaver instance. + + Creates an async psycopg connection and sets up checkpoint tables. + """ + global _checkpointer, _conn + if _checkpointer is None: + _conn = await AsyncConnection.connect( + settings.database_url_psycopg, + autocommit=True, + ) + _checkpointer = AsyncPostgresSaver(conn=_conn) + await _checkpointer.setup() + return _checkpointer + + +async def close_checkpointer() -> None: + """Close the checkpointer connection (for clean shutdown).""" + global _checkpointer, _conn + if _conn is not None: + await _conn.close() + _conn = None + _checkpointer = None diff --git a/backend/app/store/postgres.py b/backend/app/store/postgres.py new file mode 100644 index 0000000..ff0c5f4 --- /dev/null +++ b/backend/app/store/postgres.py @@ -0,0 +1,98 @@ +"""PostgreSQL models, engine, and session management.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from app.config import settings + +# --------------------------------------------------------------------------- +# Engine & session factory +# --------------------------------------------------------------------------- + +engine = create_async_engine( + settings.database_url, + echo=False, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncSession: + """Yield a new async session (for use in route handlers).""" + async with async_session_factory() as session: + yield session + + +# --------------------------------------------------------------------------- +# ORM base and models +# --------------------------------------------------------------------------- + +class Base(DeclarativeBase): + pass + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Conversation(Base): + __tablename__ = "conversations" + + id: Mapped[str] = mapped_column( + String(64), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + title: Mapped[str] = mapped_column(String(512), default="New conversation") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, server_default=func.now() + ) + + messages: Mapped[list[Message]] = relationship( + back_populates="conversation", + cascade="all, delete-orphan", + order_by="Message.created_at", + ) + + +class Message(Base): + __tablename__ = "messages" + + id: Mapped[str] = mapped_column( + String(64), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + conversation_id: Mapped[str] = mapped_column( + String(64), ForeignKey("conversations.id", ondelete="CASCADE"), index=True + ) + role: Mapped[str] = mapped_column(String(32)) # "human", "ai", "system" + content: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, server_default=func.now() + ) + + conversation: Mapped[Conversation] = relationship(back_populates="messages") + + +# --------------------------------------------------------------------------- +# Table creation helper +# --------------------------------------------------------------------------- + +async def create_tables() -> None: + """Create all tables if they don't exist.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def dispose_engine() -> None: + """Dispose the engine (for clean shutdown).""" + await engine.dispose() diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py new file mode 100644 index 0000000..cdf0487 --- /dev/null +++ b/backend/app/tools/__init__.py @@ -0,0 +1,21 @@ +"""LangChain tool definitions for the ReAct agent.""" + +from app.tools.kb import kb_search +from app.tools.tickets import ticket_list, ticket_detail + +# Mapping from frontend tool names to LangChain tool objects. +# The frontend sends a list of tool *keys* (e.g. ["knowledge", "tickets"]); +# the backend resolves them here and binds them to the ReAct agent. +ALL_TOOLS: dict[str, list] = { + "knowledge": [kb_search], + "tickets": [ticket_list, ticket_detail], +} + + +def resolve_tools(tool_keys: list[str]) -> list: + """Return a flat list of LangChain tools for the given frontend keys.""" + tools = [] + for key in tool_keys: + if key in ALL_TOOLS: + tools.extend(ALL_TOOLS[key]) + return tools diff --git a/backend/app/tools/kb.py b/backend/app/tools/kb.py new file mode 100644 index 0000000..d16342b --- /dev/null +++ b/backend/app/tools/kb.py @@ -0,0 +1,56 @@ +"""Knowledge base search tool — calls KB Agent (Azure AI Search).""" + +from __future__ import annotations + +import httpx +from langchain_core.tools import tool + +from app.config import settings + + +@tool +async def kb_search(query: str) -> str: + """Search the internal knowledge base for documents related to a query. + + Use this tool when the user asks about internal products, technical + documentation, project plans, or anything that might be covered by + the company knowledge base. + + Args: + query: The search query in natural language. + """ + url = f"{settings.kb_agent_url}{settings.kb_agent_search_path}" + headers = { + "Content-Type": "application/json", + "api-key": settings.kb_agent_api_key, + } + payload = { + "query": query, + "top": 5, + "search_mode": "hybrid", + } + + async with httpx.AsyncClient(timeout=settings.kb_agent_search_timeout_sec) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", []) + if not results: + return "No relevant documents found in the knowledge base." + + parts: list[str] = [] + for r in results: + title = r.get("title", "Untitled") + content = r.get("content", "") + category = r.get("category", "") + score = r.get("_score", 0) + # Truncate very long content to keep context manageable + if len(content) > 1500: + content = content[:1500] + "..." + header = f"[{title}]" + if category: + header += f" ({category})" + parts.append(f"{header}\n{content}") + + return "\n\n---\n\n".join(parts) diff --git a/backend/app/tools/tickets.py b/backend/app/tools/tickets.py new file mode 100644 index 0000000..0af88e5 --- /dev/null +++ b/backend/app/tools/tickets.py @@ -0,0 +1,117 @@ +"""Ticket system tools — proxy to Gongdan API (read-only).""" + +from __future__ import annotations + +import httpx +from langchain_core.tools import tool + +from app.config import settings + + +def _gongdan_headers() -> dict[str, str]: + return {"X-Api-Key": settings.gongdan_api_key} + + +def _map_status(raw: str) -> str: + """Map Gongdan status values to frontend-friendly values.""" + mapping = { + "OPEN": "pending", + "ASSIGNED": "processing", + "IN_PROGRESS": "processing", + "PENDING_CUSTOMER": "processing", + "RESOLVED": "resolved", + "CLOSED": "resolved", + } + return mapping.get(raw, "pending") + + +def _map_priority(raw: str) -> str: + """Map Gongdan priority to P0-P3.""" + mapping = { + "URGENT": "P0", + "PRIORITY": "P1", + "NORMAL": "P2", + "LOW": "P3", + } + return mapping.get(raw, "P2") + + +@tool +async def ticket_list(page: int = 1, page_size: int = 20) -> str: + """List tickets from the ticket system. + + Use this tool when the user asks about tickets, work orders, issues, + or wants to see a summary of current support requests. + + Args: + page: Page number (default 1). + page_size: Number of tickets per page (default 20). + """ + url = f"{settings.gongdan_api_base}/api/tickets" + params = {"page": page, "pageSize": page_size} + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, params=params, headers=_gongdan_headers()) + resp.raise_for_status() + data = resp.json() + + tickets = data.get("tickets", []) + if not tickets: + return "No tickets found." + + lines: list[str] = [] + for t in tickets: + ticket_id = t.get("ticketNumber", t.get("id", "?")) + title = t.get("description", "")[:80] + status = _map_status(t.get("status", "")) + priority = _map_priority(t.get("priority", "")) + created = t.get("createdAt", "")[:10] + customer = t.get("customer", {}).get("name", "Unknown") + lines.append( + f"- [{ticket_id}] {title} | status={status} priority={priority} " + f"customer={customer} created={created}" + ) + + return f"Found {len(tickets)} tickets:\n" + "\n".join(lines) + + +@tool +async def ticket_detail(ticket_id: str) -> str: + """Get detailed information about a specific ticket. + + Use this tool when the user asks for details on a particular ticket + or work order, providing its ID. + + Args: + ticket_id: The ticket UUID or ticket number. + """ + url = f"{settings.gongdan_api_base}/api/tickets/{ticket_id}" + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, headers=_gongdan_headers()) + resp.raise_for_status() + t = resp.json() + + ticket_number = t.get("ticketNumber", t.get("id", "?")) + description = t.get("description", "N/A") + status = _map_status(t.get("status", "")) + priority = _map_priority(t.get("priority", "")) + platform = t.get("platform", "N/A") + model_used = t.get("modelUsed", "N/A") + account = t.get("accountInfo", "N/A") + request_example = t.get("requestExample", "") + customer_name = t.get("customer", {}).get("name", "Unknown") + engineer = t.get("assignedEngineer", {}).get("username", "Unassigned") + created = t.get("createdAt", "") + sla = t.get("slaDeadline", "") + + return ( + f"Ticket: {ticket_number}\n" + f"Status: {status} | Priority: {priority}\n" + f"Platform: {platform} | Model: {model_used}\n" + f"Customer: {customer_name} | Account: {account}\n" + f"Engineer: {engineer}\n" + f"Created: {created} | SLA: {sla}\n" + f"Description: {description}\n" + f"Request Example: {request_example}" + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..33c6cb4 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "soc-backend" +version = "0.1.0" +description = "SOC Chat Backend - LangGraph + Litestar" +requires-python = ">=3.11" +dependencies = [ + "litestar[standard]>=2.15.0", + "uvicorn[standard]>=0.34.0", + "langchain>=0.3.0", + "langchain-openai>=0.3.0", + "langgraph>=0.3.0", + "langgraph-checkpoint>=2.0.0", + "pydantic-settings>=2.7.0", + "python-dotenv>=1.0.0", + "httpx>=0.28.0", +] + +[project.optional-dependencies] +dev = ["ruff", "pytest", "pytest-asyncio"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4f1349c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +litestar[standard]>=2.15.0 +uvicorn[standard]>=0.34.0 +langchain>=0.3.0 +langchain-openai>=0.3.0 +langgraph>=0.3.0 +langgraph-checkpoint>=2.0.0 +langgraph-checkpoint-postgres>=2.0.0 +pydantic-settings>=2.7.0 +python-dotenv>=1.0.0 +httpx>=0.28.0 +asyncpg>=0.30.0 +sqlalchemy[asyncio]>=2.0.0 +psycopg[binary]>=3.1.0 +gunicorn>=22.0.0 diff --git a/claudehd.md b/claudehd.md deleted file mode 100644 index cc869c0..0000000 --- a/claudehd.md +++ /dev/null @@ -1,106 +0,0 @@ -# claudehd.md — so-c-chat-clone 后端功能方案 - -**前端代码未经明确指定不允许修改。** - ---- - -## 功能一:基础对话 - -**做什么:** 用户发送消息,后端调用 LLM 生成回复,返回 Markdown 文本给前端渲染。 - -**用什么:** -- **FastAPI**(Python)— 提供 `POST /chat` 接口,接收 `message` + `thread_id` + `model` -- **Azure OpenAI SDK(异步)** — 调用 gpt-5.4 部署,返回文本内容 -- **内存字典** — 按 `thread_id` 存储多轮对话历史,拼入每次请求的 messages 数组实现上下文连续 - -**模型行为:** -- `model=flash` → `max_tokens=500`,`temperature=0.2`,快速简洁 -- `model=pro` → `max_tokens=4096`,`temperature=0.3`,深度详细 - ---- - -## 功能二:内部知识库检索 - -**做什么:** 用户在输入框激活"内部知识库"工具后,发送消息前先检索企业知识库,将相关文档片段注入 LLM prompt,让回复基于内部知识。 - -**用什么:** -- **httpx(异步)** — 调用 KB Agent REST API(Azure AI Search 代理) -- 检索参数:`search_mode=hybrid`,`top=5` -- 检索结果格式化为背景材料追加到 system prompt,LLM 基于此生成回复 - ---- - -## 功能三:外部 AI 搜索 - -**做什么:** 用户激活"搜索"工具后,后端联网检索实时信息(含图片、视频),经重排后注入 LLM,回复引用真实来源。 - -**用什么:** -- **Jina Search API** (`https://s.jina.ai/`) — 搜索网页,返回标题+摘要+URL -- **Jina Reader API** (`https://r.jina.ai/{url}`) — 读取搜索结果全文 -- **Jina Rerank API** (`jina-reranker-v2-base-multilingual`) — 对结果按相关性重排,提升准确度 -- **httpx(异步)** — 并发调用以上三个接口 - -**按模型深度区分:** -- `flash` → 搜索 top=3,timeout=8s,跳过重排,追求速度 -- `pro` → 搜索 top=10,timeout=20s,Rerank 取 top=5,追求准确 - ---- - -## 功能四:沙盒代码执行 - -**做什么:** 用户激活"沙盒"工具并提出编程需求时,后端在隔离环境中执行代码,将 stdout/stderr 格式化为 Markdown 代码块注入回复。 - -**用什么:** -- **Daytona API** (`https://app.daytona.io/api`) — 创建隔离 workspace → 上传代码 → 执行 → 获取输出 → 销毁 workspace -- **httpx(异步)** — 调用 Daytona REST API -- 执行结果以 Markdown 代码块形式追加到 LLM 最终回复 - ---- - -## 功能五:文档生成 - -**做什么:** 用户激活"文档生成"工具并描述需求时,后端调用 Doc Creator Agent 生成 Word/PPT/表格文件,将下载链接追加到回复末尾。 - -**用什么:** -- **Doc Creator Agent** (`http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com`) — 传入 prompt,返回生成文件的 URL -- **httpx(异步)** — 调用 Agent REST API -- 输出类型自动识别:含 ppt/slides → PPT;含 table/excel → 表格;其余 → Word - ---- - -## 功能六:工单数据接入 - -**做什么:** 前端 ExtensionsPanel 连接工单系统后,展示真实工单列表(P0-P3 优先级、状态)。后端作为代理拉取 Gongdan 工单数据。 - -**用什么:** -- **FastAPI** — 提供 `GET /tickets` 接口,支持 `page` / `pageSize` 分页参数 -- **httpx(异步)** — 代理调用 Gongdan API,透传工单数据 -- 返回字段严格对齐前端 `TicketData` 类型:`id / title / status / priority / createdAt` - ---- - -## 功能七:多轮对话持久化 - -**做什么:** 对话历史在服务重启后不丢失,支持恢复历史对话上下文。 - -**用什么:** -- **PostgreSQL**(Azure,`dataope.postgres.database.azure.com`)— 存储 thread 和 message 记录 -- **asyncpg** — 异步数据库驱动,不阻塞事件循环 -- 未配置 `DATABASE_URL` 时自动降级为内存字典(开发模式) - ---- - -## 技术栈总览 - -| 层 | 技术 | -|----|------| -| Web 框架 | FastAPI + Uvicorn | -| LLM | Azure OpenAI SDK (AsyncAzureOpenAI) | -| HTTP 客户端 | httpx(全异步) | -| 外部搜索 | Jina Search / Reader / Rerank | -| 知识库 | KB Agent (Azure AI Search 代理) | -| 沙盒 | Daytona API | -| 文档生成 | Doc Creator Agent | -| 工单 | Gongdan API(只读代理) | -| 数据库 | PostgreSQL / asyncpg(可选) | -| 部署 | Azure Web App (Python 3.11) | diff --git a/config/mcporter.json b/config/mcporter.json new file mode 100644 index 0000000..8ec2bee --- /dev/null +++ b/config/mcporter.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "cursor-project-memory": { + "baseUrl": "http://172.188.219.174:3101/mcp" + } + }, + "imports": [] +} diff --git a/gpthd.md b/gpthd.md index 4fdb024..c6771b5 100644 --- a/gpthd.md +++ b/gpthd.md @@ -1,7 +1,7 @@ -# SOC 项目后端功能方案(按功能拆解) +# SOC 项目后端功能方案(基于 LangChain,按功能拆解) -> 约束:在未获得明确允许前,不修改前端交互,只补后端能力、接口和数据层。 -> 目标:严格围绕当前 `~/go/soc` 这个 Gemini 风格前端,为每一个功能明确说明“用什么技术,完成什么功能,怎么落地”。 +> 约束:在未获得明确允许前,不修改前端交互,只补后端能力、编排链路和数据层。 +> 目标:严格围绕当前 `~/go/soc` 这个 Gemini 风格前端,按“每个功能用什么技术完成什么功能”来写,核心框架改为 **LangChain / LangGraph**,不再以 FastAPI 作为方案重点。 --- @@ -10,27 +10,31 @@ ### 要完成什么功能 - 用户在当前聊天输入框发送消息 - 后端实时返回回答内容 -- 支持“思考中 / 检索中 / 生成中”的流式状态 +- 支持“思考中 / 检索中 / 生成中”的状态 - 不改变现有前端交互,只替换当前前端 `simulateAIResponse()` ### 用什么技术 -- **FastAPI**:提供聊天接口 -- **SSE(Server-Sent Events)**:把大模型回答流式推给前端 +- **LangChain**:负责组织提示词、消息上下文、模型调用 +- **LangGraph**:负责整个对话节点编排与状态流转 - **Azure OpenAI**:生成最终回答 -- **PostgreSQL**:保存消息记录和会话记录 +- **SSE**:把 LangChain/LangGraph 执行过程和回答流式推给前端 +- **PostgreSQL**:保存会话和消息记录 ### 怎么落地 -- 新增接口:`POST /api/chat/stream` -- 前端发送用户消息到后端 -- 后端先写入用户消息 -- 再调用 Azure OpenAI 流式生成 -- 将生成过程通过 SSE 持续返回给前端 -- 最终把 assistant 回复保存入库 +- 以 `LangGraph StateGraph` 建立一个对话图: + - `receive_message` + - `load_history` + - `route_tools` + - `call_llm` + - `persist_message` +- 前端发送消息后,后端触发 graph 执行 +- Azure OpenAI 通过 LangChain chat model 调用 +- 生成的 token 和中间状态通过 SSE 返回给前端 ### 输出结果 -- 前端保持当前 Gemini 风格交互不变 -- 用户发送后能看到真实流式回答 -- 替换掉前端 mock 返回逻辑 +- 前端仍然保持当前 Gemini 风格聊天交互 +- 从 mock 回复升级为真实流式 AI 回复 +- 后续所有工具调用都能接到同一个 graph 里 --- @@ -38,289 +42,754 @@ ### 要完成什么功能 - 左侧历史会话列表从真实数据读取 -- 支持新建会话 -- 支持切换会话 -- 支持删除会话 -- 支持自动生成会话标题 +- 支持新建、切换、删除会话 +- 支持自动生成标题 +- 会话上下文可在 LangChain 中继续使用 ### 用什么技术 -- **FastAPI**:提供 REST API - **PostgreSQL**:保存 conversation 和 message -- **SQLAlchemy / SQLModel**:管理数据表和查询 +- **LangChain Memory / Message History 抽象**:管理历史消息上下文 +- **SQLAlchemy / SQLModel**:管理数据表 ### 怎么落地 -- 新增接口: - - `GET /api/conversations` - - `POST /api/conversations` - - `GET /api/conversations/{id}` - - `PATCH /api/conversations/{id}` - - `DELETE /api/conversations/{id}` -- 用户首次发送消息时自动创建会话 -- 默认用首条消息前 20~30 字生成标题 -- 前端左侧栏改为读取真实会话数据 +- conversations/messages 数据存入 PostgreSQL +- 在 LangChain 层使用 `BaseChatMessageHistory` 风格封装数据库消息 +- 每次进入 graph 时先加载历史消息 +- 标题生成可以由 LLM 在首轮消息后自动归纳 ### 输出结果 -- 当前左侧 mock 会话列表替换成数据库真实数据 -- 用户聊天记录可恢复 +- 当前左侧 mock 会话可以替换为真实会话记录 +- 后端真正具备多轮上下文记忆能力 --- ## 3. 内部知识库检索 ### 要完成什么功能 -- 当用户提问产品、方案、配置、内部资料时 -- 后端优先查询内部知识库 -- 再把知识库结果交给大模型总结回答 -- 回答中带来源信息 +- 当用户问产品、方案、配置、内部文档时 +- 自动检索内部知识库 +- 再让模型基于检索结果生成回答 +- 回答中附带引用来源 ### 用什么技术 -- **KB_AGENT 接口**:调用内部知识库搜索 -- **FastAPI service layer**:封装知识库调用 -- **Azure OpenAI**:对检索结果总结与生成回答 +- **LangChain Tool**:把 KB_AGENT 封装成知识库工具 +- **KB_AGENT 接口**:作为实际搜索源 +- **Azure OpenAI**:总结检索结果并生成回答 +- **LangGraph**:决定何时调用知识库节点 ### 怎么落地 -- 新增服务模块:`kb_agent.py` -- 根据 `EXTERNAL_SERVICES.md` 中的: - - `KB_AGENT_URL` - - `KB_AGENT_API_KEY` - - `KB_AGENT_SEARCH_PATH` -- 后端在识别为内部知识问题时: - 1. 请求知识库搜索 - 2. 获取 top-k 文档片段 - 3. 做摘要裁剪 - 4. 将结果作为上下文交给 Azure OpenAI -- 前端不改交互,只在回答里附带引用来源块 +- 编写 `kb_search_tool` +- 工具内部调用 `KB_AGENT_URL + KB_AGENT_SEARCH_PATH` +- 返回统一的文档列表结构 +- graph 中当识别为内部知识类问题时,先进入 `kb_search` 节点,再进入 `llm_answer` ### 输出结果 -- 当前产品类问题不再纯靠大模型空想 -- 回答能基于内部知识库 -- 更适合售前、售后、研发支持场景 +- 产品知识问答不再靠模型空想 +- 回答可基于真实内部资料 +- 更适合售前、售后、研发支持 --- ## 4. 外部 AI 搜索 ### 要完成什么功能 -- 当用户问实时互联网信息、行业动态、外部资料时 -- 后端自动执行外部搜索 -- 支持网页搜索、内容读取、结果重排 -- 满足快速模式和深度模式 -- 后续支持图片和视频结果展示 +- 处理实时互联网问题、行业动态、外部资料调研 +- 支持搜索、网页读取、重排 +- 支持 fast / deep / auto 三种搜索质量 +- 后续支持图片和视频检索结果 ### 用什么技术 -- **Jina Search API**:做外部搜索 -- **Jina Reader**:读取网页正文 -- **Rerank 模型**:对结果重排 -- **Azure OpenAI**:基于外部资料生成答案 +- **LangChain Tool**:封装外部搜索工具链 +- **Jina Search API**:外部搜索 +- **Jina Reader**:网页正文读取 +- **Rerank 模型**:重排结果 +- **LangGraph**:编排 Search -> Read -> Rerank -> Answer +- **Azure OpenAI**:生成最终总结回答 ### 怎么落地 -- 新增模块: - - `jina_search.py` - - `jina_reader.py` - - `reranker.py` -- 固定流程: - - Search -> Read -> Rerank -> LLM -- 提供三种模式: - - `fast`:低延迟,少量搜索 - - `deep`:高质量,多轮检索 - - `auto`:后端自动判断 -- 前端仍保持原有聊天区交互,只增加来源区块展示 +- 分成三个 tool: + - `web_search_tool` + - `web_read_tool` + - `rerank_tool` +- 在 graph 中建立外部搜索链路: + - 搜索候选 + - 读取正文 + - 重排结果 + - 将高质量上下文交给 LLM +- `fast/deep/auto` 可作为 graph state 中的参数 ### 输出结果 -- 外部问题可获得更准确结果 -- 不再只依赖大模型参数知识 -- 满足企业级搜索准确度要求 +- 外部信息回答准确度显著提升 +- 满足文档要求里的企业级外部搜索能力 +- 为后续图片、视频结果展示预留结构 --- ## 5. 工单系统只读接入 ### 要完成什么功能 -- 支持查询工单列表 -- 支持查询工单详情 -- 支持汇总最近高优先级工单 -- 支持在聊天中回答“最近有什么 P0/P1 工单”“某类问题集中在哪里” -- 支持首页/聊天区展示工单摘要数据 +- 查询工单列表 +- 查询工单详情 +- 汇总 P0/P1 工单 +- 支持聊天中分析工单趋势、共性问题、故障重点 +- 替换当前前端 mock 工单摘要 ### 用什么技术 -- **Gongdan HTTP API**:工单数据来源 -- **FastAPI**:对前端提供统一工单接口 -- **PostgreSQL(可选缓存)**:保存摘要缓存或查询记录 -- **Azure OpenAI**:对工单数据做归纳总结 +- **LangChain Tool**:把 gongdan API 封装为工单工具 +- **Gongdan HTTP API**:工单实际数据源 +- **Azure OpenAI**:对工单结果做总结和归纳 +- **PostgreSQL(可选缓存)**:保存查询结果和摘要缓存 ### 怎么落地 -- 新增模块:`gongdan_client.py` -- 新增接口: - - `GET /api/tickets/summary` - - `GET /api/tickets` - - `GET /api/tickets/{id}` -- 对话场景下: - - 用户问工单问题 - - 后端查询 gongdan - - 将结果整理后交给大模型总结 -- 非对话场景下: - - 前端通过 summary 接口读取摘要 +- 编写工具: + - `ticket_list_tool` + - `ticket_detail_tool` + - `ticket_summary_tool` +- 当用户问题涉及工单时,graph 路由到 ticket 节点 +- 工具取回结果后,再由 LLM 进行总结 +- 当前前端的 TicketSummary 数据以后改成读取真实接口结果,但不改交互样式 ### 输出结果 -- 当前前端 mock 工单摘要可替换为真实工单数据 -- 用户能直接在聊天里分析工单问题 +- 工单分析能力可直接在聊天里使用 +- 首页/聊天区的工单摘要可从 mock 变成真实数据 --- ## 6. 文档生成 ### 要完成什么功能 -- 用户在聊天中要求生成方案、汇报、纪要、总结 -- 后端把需求提交给文档生成 Agent -- 返回任务状态 -- 文档完成后可返回下载地址或结果卡片 +- 用户要求生成方案、汇报、纪要、总结文档时 +- 后端自动进入文档生成流程 +- 返回任务状态和结果 +- 生成正式文档链接或结果卡片 ### 用什么技术 -- **Doc Creator Agent HTTP API**:生成正式文档 -- **FastAPI BackgroundTasks / 异步任务机制**:管理任务状态 +- **LangChain Tool / Runnable**:封装文档生成能力 +- **Doc Creator Agent HTTP API**:实际生成正式文档 +- **LangGraph**:把“文档生成”作为 graph 的分支节点 - **PostgreSQL**:保存文档任务记录 -- **Azure OpenAI**:前置整理文档提纲或结构 +- **Azure OpenAI**:先整理文档结构或提纲 ### 怎么落地 -- 新增接口: - - `POST /api/documents/generate` - - `GET /api/documents/{task_id}` -- 对话中识别“生成文档”类意图 -- 后端创建任务记录 -- 调用 doc creator agent -- 前端保持当前聊天交互,后续只在消息中增加文档结果卡片 +- graph 中识别“生成文档”类意图 +- 先用 LLM 生成结构化文档提纲 +- 再调用 doc creator agent +- 把任务状态写入数据库 +- 前端依旧保持聊天式入口,只在消息中显示结果卡片 ### 输出结果 -- 用户可从聊天直接发起正式文档生成 -- 满足销售、售前、汇报场景 +- 销售、售前、汇报场景可以直接从聊天进入正式文档输出 +- 文档生成成为对话系统中的标准能力节点 --- ## 7. 沙盒代码执行 ### 要完成什么功能 -- 处理表格、JSON、日志、数据分析类任务 -- 允许后端在安全沙盒中执行代码 -- 返回执行结果、图表、文件 -- 不直接改前端交互,只把结果作为消息内容或附件返回 +- 分析 CSV、JSON、日志、结构化数据 +- 在安全环境中执行代码 +- 返回分析结果、图表和文件 +- 不改变前端交互,只把结果塞回当前聊天流里 ### 用什么技术 +- **LangChain Tool**:把沙盒能力封装为可调用工具 - **Daytona Sandbox**:安全执行环境 -- **FastAPI**:封装沙盒执行入口 -- **Python 工具链**:pandas / matplotlib / json / csv 等 -- **PostgreSQL**:记录执行任务 +- **Python 数据工具链**:pandas、matplotlib、json、csv +- **LangGraph**:按意图路由到 sandbox 节点 +- **PostgreSQL**:保存执行记录 ### 怎么落地 -- 新增接口:`POST /api/sandbox/run` - 第一阶段不开放任意代码执行 -- 先封装几类固定能力: - - CSV 汇总 - - JSON 转换 - - 数据统计 - - 图表生成 -- 对话编排层按意图决定是否调用 sandbox +- 只先封装几个固定工具: + - `csv_summary_tool` + - `json_transform_tool` + - `data_analysis_tool` + - `chart_generate_tool` +- graph 根据问题和附件类型决定是否调用 sandbox ### 输出结果 -- 数据分析、表格处理能力可真正执行 -- 后端具备“算”的能力,而不只是“说”的能力 +- 后端不仅能“回答”,还能“执行”和“计算” +- 数据类问题能返回真正算出来的结果 --- ## 8. 附件上传与解析 ### 要完成什么功能 -- 用户上传文件后,后端能接收附件 +- 接收用户上传的附件 - 保存附件元数据 -- 提取文本内容供知识理解、搜索或分析使用 -- 后续支持文档总结、数据分析、代码执行 +- 提取文本内容进入上下文 +- 为知识问答、文档生成、沙盒分析提供输入 ### 用什么技术 -- **FastAPI UploadFile**:接收文件 -- **对象存储/本地文件存储**:保存附件 -- **文本解析库**:PDF、DOCX、TXT、CSV 解析 +- **对象存储/本地存储**:保存附件 +- **LangChain Document Loader**:解析 PDF、DOCX、TXT、CSV 等文件 - **PostgreSQL**:保存附件元数据 +- **LangGraph**:把附件解析结果接入 graph state ### 怎么落地 -- 新增接口: - - `POST /api/attachments` - - `GET /api/attachments/{id}` -- 后端保存文件路径与文件类型 -- 针对不同格式做解析 -- 将解析文本挂到对应消息上下文里 +- 上传后先保存附件和元数据 +- 再用 LangChain loader 抽取文本 +- 将解析结果挂到当前会话 state 中 +- 当用户继续提问时,graph 可以把附件内容作为上下文输入 LLM 或工具 ### 输出结果 -- 后续聊天可真正支持“基于附件分析” -- 为文档生成、沙盒分析、知识问答提供基础能力 +- 未来可以真正支持“基于附件提问”和“基于附件分析” +- 为文档生成和沙盒执行提供输入材料 --- ## 9. 工具编排层 ### 要完成什么功能 -- 判断用户问题该调用哪个能力 -- 决定先查 KB、还是先查工单、还是先外部搜索 -- 决定是否进入文档生成或沙盒执行 -- 把多个工具结果统一整理成大模型上下文 +- 判断用户当前问题到底需要哪种能力 +- 决定先查 KB、先查工单、还是先查外部搜索 +- 决定是否触发文档生成或沙盒分析 +- 把多个工具结果统一整理给模型 ### 用什么技术 -- **Python Orchestrator**:自定义编排逻辑 -- **Azure OpenAI**:辅助做工具选择与结果总结 -- **FastAPI service layer**:承接 API 和工具层 +- **LangGraph**:整个系统的核心编排框架 +- **LangChain Tools**:封装 KB、Search、Tickets、Docs、Sandbox +- **Azure OpenAI**:辅助做意图判断、结果总结 ### 怎么落地 -- 新增模块: - - `planner.py` - - `chat_orchestrator.py` - - `context_builder.py` -- 第一阶段可以先做规则驱动: - - 问产品/项目 -> KB - - 问实时外部信息 -> Search - - 问工单 -> Tickets - - 问生成汇报 -> Documents - - 问数据处理 -> Sandbox -- 第二阶段再逐步引入 LLM 辅助路由 +- graph 中至少有这些节点: + - `router` + - `kb_search` + - `web_search` + - `ticket_query` + - `doc_generate` + - `sandbox_run` + - `llm_answer` + - `persist` +- 第一阶段可以先“规则路由 + LLM总结” +- 第二阶段再升级为“LLM路由 + 工具调用决策” ### 输出结果 -- 后端从“多个孤立接口”升级成“统一智能后端” -- 前端仍然只需要一个对话入口 +- 后端不再是散乱接口集合,而是统一 Agent 编排系统 +- 前端只保留一个 Gemini 风格聊天入口即可 --- -## 10. 数据持久化 +## 10. 数据持久化与基础设施 ### 要完成什么功能 - 保存历史会话 -- 保存聊天消息 +- 保存消息记录 - 保存工具调用记录 - 保存附件记录 - 保存文档任务记录 +- 保存 graph 执行状态和日志 +- 提升缓存能力、异步任务能力和文件持久化能力 ### 用什么技术 -- **PostgreSQL**:主数据库 -- **SQLAlchemy / SQLModel**:ORM -- **Alembic**:数据库迁移 +- **PostgreSQL**:主数据库,保存会话、消息、任务、工具记录 +- **LangGraph Checkpointer / State Persistence**:保存 graph 执行状态 +- **Redis**:缓存热点结果、会话临时状态、短期上下文、速率控制 +- **Azure Storage Account**:保存附件、图表、导出文件、文档产物 +- **Azure Service Bus**:承载异步任务与解耦长链路处理 ### 怎么落地 -- 至少建立以下表: +- PostgreSQL 中至少建立以下表: - `conversations` - `messages` - `tool_runs` - `attachments` - `document_tasks` -- 以后如需多租户,再增加: - - `users` - - `organizations` - - `memberships` - - `audit_logs` + - `graph_runs` +- Redis 用于: + - 外部搜索结果缓存 + - KB 搜索缓存 + - 工单摘要缓存 + - 正在运行的 graph/session 临时状态 + - SSE 会话短状态同步 +- Azure Storage Account 用于: + - 用户上传附件原始文件 + - Sandbox 输出文件 + - 图表与中间产物 + - 文档生成结果文件 +- Azure Service Bus 用于: + - 文档生成异步任务派发 + - Sandbox 长任务调度 + - 外部搜索深度模式异步并发编排 + - 后续告警/通知类事件扩展 ### 输出结果 -- 数据不丢失 -- 会话可追溯 -- 工具调用过程可排查 +- 对话、工具、任务都有追踪记录 +- graph 执行链路具备可恢复能力 +- 系统具备缓存、异步任务和文件持久化基础设施 --- -## 11. 接口层总表 +## 11. Redis 缓存层 + +### 要完成什么功能 +- 降低外部接口重复调用成本 +- 提升对话链路响应速度 +- 处理短期状态、热点数据和限流控制 + +### 用什么技术 +- **Azure Redis**:缓存层 +- **LangChain / LangGraph 外围状态管理**:结合缓存保存中间态 + +### 怎么落地 +- 缓存这些内容: + - 相同 query 的 KB 搜索结果 + - 相同 query 的外部搜索与重排结果 + - 工单摘要结果 + - 文档生成任务短状态 + - 会话级短期上下文摘要 +- 为外部搜索和知识库增加 TTL +- 为 Service Bus 异步任务增加状态缓存 + +### 输出结果 +- 系统速度更稳定 +- 外部服务成本更低 +- 可支撑更高并发下的会话请求 + +--- + +## 12. 存储账户(文件与产物存储) + +### 要完成什么功能 +- 持久化用户上传附件 +- 保存文档生成结果 +- 保存 Sandbox 执行生成的图表/文件 +- 为前端提供附件与结果文件访问地址 + +### 用什么技术 +- **Azure Blob Storage**:统一文件对象存储 +- **LangChain Document Loader**:结合存储文件做解析 + +### 怎么落地 +- 上传文件后先保存到 Blob Storage +- 数据库中记录 blob URL、文件类型、所属消息/会话 +- 文档生成与 Sandbox 产物统一落到 Blob Storage +- 前端保持现有交互,仅在消息中附带文件结果卡片或链接 + +### 输出结果 +- 所有附件和中间产物有统一落盘位置 +- 后续分析、下载、追踪都更方便 + +--- + +## 13. Service Bus 异步任务层 + +### 要完成什么功能 +- 处理长耗时任务 +- 解耦即时对话链路和后台异步处理链路 +- 支持重试、失败恢复、延后处理 + +### 用什么技术 +- **Azure Service Bus**:消息队列 / 异步任务总线 +- **LangGraph**:消费任务后继续执行长链路节点 + +### 怎么落地 +- 把这些任务异步化: + - 文档生成 + - Sandbox 长任务 + - 深度外部搜索 + - 未来的大批量分析任务 +- 聊天主链路先返回“任务已受理”状态 +- Worker 从 Service Bus 拉取任务继续执行 +- 执行结果写数据库和存储账户,再回推前端 + +### 输出结果 +- 避免主对话链路阻塞 +- 长任务处理更稳定 +- 适合企业级系统扩展 + +--- + +## 14. MCP 方式接入外部搜索 + +### 要完成什么功能 +- 利用 `https://mcp.jina.ai/sse` 这一类能力,以 MCP 方式接入外部搜索 +- 让外部搜索不只是普通 HTTP API,而是可作为标准工具节点接入 LangChain / LangGraph + +### 用什么技术 +- **MCP(Model Context Protocol)**:统一工具协议 +- **Jina MCP SSE / v1**:外部搜索与读取能力来源 +- **LangChain Tool 封装层**:把 MCP 调用转换成 graph 可调用工具 + +### 怎么落地 +- 优先测试 Jina 提供的 `/sse` 和 `/v1` 两种入口 +- 将 Search 和 Read 分别封装成两个 tool +- 在外部搜索节点中统一走 MCP 接入层,保留将来替换搜索供应商的可能 +- 重排仍保留单独节点,以便保障搜索质量控制 + +### 输出结果 +- 外部搜索链路更标准化 +- 更容易扩展到更多 MCP 服务 +- 对 LangChain / LangGraph 编排更友好 + +--- + +## 15. 基于当前前端代码补充的后端缺口与完善方案 + +> 这一章专门对应当前前端已经存在、但此前后端方案没有完整覆盖的功能点。不含认证和权限,只补业务后端能力。 + +### 15.1 消息反馈(赞 / 踩) + +#### 要完成什么 +- 用户对 assistant 消息进行点赞或点踩 +- 后端记录反馈结果 +- 后续可用于回答质量分析、提示词优化和问题回溯 + +#### 用什么技术 +- **PostgreSQL**:保存反馈记录 +- **LangGraph 旁路记录**:反馈不进入主对话 graph +- **Redis(可选)**:做短期统计缓存 + +#### 怎么落地 +- 新增表:`message_feedback` + - `id` + - `message_id` + - `conversation_id` + - `feedback_type` (`up` / `down`) + - `reason`(可空,后续扩展) + - `created_at` +- 新增接口: + - `POST /api/messages/{id}/feedback` +- 前端点击赞/踩后直接调用该接口 +- 第一阶段先只记录 `up/down`,不做复杂原因分类 + +--- + +### 15.2 模型切换映射 + +#### 要完成什么 +- 前端已有 Flash / Pro 与顶部模型选择入口 +- 第一阶段后端先统一固定使用 **GPT-5.4** +- 但保留字段和映射结构,后续再扩展多模型、多链路 + +#### 用什么技术 +- **LangChain model wrapper**:模型封装 +- **LangGraph state**:保存 `model_profile` +- **PostgreSQL conversation metadata**:记录选择结果 + +#### 怎么落地 +- 前端若传模型字段,第一阶段统一映射为: + - `model_provider = azure_openai` + - `model_name = gpt-5.4` +- 保留 metadata 字段: + - `selected_model` + - `selected_mode` +- 当前只做字段记录与透传,不做真正多模型切换 +- 第二阶段再扩为 flash/pro 对应不同 graph 策略 + +--- + +### 15.3 工具显式开关控制 + +#### 要完成什么 +- 前端工具 chips: + - 搜索 + - 内部知识库 + - 沙盒 + - 文档生成 +- 用户手动启用哪些工具,后端就只允许调用这些工具 +- 用户未选择时,后端才走自动路由 + +#### 用什么技术 +- **LangGraph state**:保存当前消息工具选择 +- **LangChain tools registry**:统一工具注册 +- **tool allowlist / denylist**:工具调用控制 + +#### 怎么落地 +- 前端发消息时附带: +```json +{ + "enabled_tools": ["search", "knowledge"] +} +``` +- graph state 增加: + - `enabled_tools` + - `tool_selection_mode` (`auto` / `manual`) +- router 节点规则: + - `manual` 模式:只能从 allowlist 中路由 + - `auto` 模式:按规则或模型自由决策 +- 工具执行前统一做可用性校验 + +--- + +### 15.4 多文件上传与消息绑定 + +#### 要完成什么 +- 一次上传多个文件 +- 每个文件单独保存 +- 文件和某条消息绑定 +- 文件可参与知识问答、搜索、Sandbox 分析和文档生成 + +#### 用什么技术 +- **Azure Blob Storage**:存储文件 +- **PostgreSQL**:存储附件元数据 +- **LangChain Document Loaders**:解析附件内容 +- **消息-附件关联机制**:支撑多文件场景 + +#### 怎么落地 +- 新增表:`attachments` + - `id` + - `conversation_id` + - `message_id`(允许先空,待消息发送后再绑定) + - `file_name` + - `content_type` + - `storage_url` + - `parse_status` + - `parsed_text` + - `created_at` +- 新增接口: + - `POST /api/attachments` + - `POST /api/messages/{id}/attachments/bind` +- 推荐流程: + 1. 前端先上传多个文件 + 2. 后端返回 attachment ids + 3. 前端发消息时附带 attachment ids + 4. 后端完成消息与附件绑定 +- 解析流程异步化,避免阻塞主聊天链路 + +--- + +### 15.5 扩展程序连接管理 + +#### 要完成什么 +- 支持扩展程序的连接、断开、修改 key、查看状态 +- 页面刷新后仍保留扩展连接状态 +- 扩展状态可被后端 graph 感知 + +#### 用什么技术 +- **PostgreSQL**:保存扩展配置与状态 +- **加密存储机制**:保存敏感配置 +- **extension registry**:统一扩展管理 +- **LangChain tool 注册机制**:根据扩展状态暴露工具 + +#### 怎么落地 +- 新增表:`extensions` + - `id` + - `extension_type` (`ticket` / `sales` / `cloud`) + - `display_name` + - `status` + - `config_encrypted` + - `last_check_at` + - `last_check_status` +- 新增接口: + - `GET /api/extensions` + - `POST /api/extensions/{type}/connect` + - `POST /api/extensions/{type}/disconnect` + - `POST /api/extensions/{type}/validate` +- 第一阶段先完成工单系统全链路,销售和云管先保留扩展框架 + +--- + +### 15.6 销售系统 / 云管系统预留 + +#### 要完成什么 +- 虽然当前两套系统还在开发,但后端要预留统一扩展接入结构 +- 避免未来工单、销售、云管三套系统接入方式不一致 + +#### 用什么技术 +- **统一 extension schema** +- **summary provider 接口** +- **tool provider 接口** +- **connection config schema** + +#### 怎么落地 +- 一期不要求真实接入销售/云管 API +- 但必须预留: + - 扩展类型定义 + - tool 注册入口 + - summary 注册入口 + - 状态位和配置结构 +- 后续新增业务系统时不需要推翻现有后端结构 + +--- + +### 15.7 通用扩展摘要机制 + +#### 要完成什么 +- 不只是工单系统,未来销售、云管系统接入后,也能输出首页/对话页摘要卡片 +- 后端统一提供摘要机制 + +#### 用什么技术 +- **summary provider registry**:每个扩展实现自己的摘要提供者 +- **Redis**:缓存摘要结果 +- **PostgreSQL**:记录摘要生成时间与状态 +- **LangChain summarizer(可选)**:对原始数据做摘要 + +#### 怎么落地 +- 新增统一摘要接口: + - `GET /api/extensions/summaries` +- 返回结构示例: +```json +[ + { + "extension_type": "ticket", + "status": "connected", + "summary_type": "ticket_summary", + "data": {} + } +] +``` +- 第一阶段先实现 ticket summary provider +- 但接口设计按多扩展统一返回 + +--- + +### 15.8 结构化消息块协议 + +#### 要完成什么 +- 后端不能只返回纯文本 +- 需要支持: + - 文本 + - 引用来源 + - 摘要卡片 + - 文件结果 + - 工具状态 + - 错误块 + +#### 用什么技术 +- **LangGraph 标准化事件输出** +- **message block schema** +- **前后端统一 JSON 协议** + +#### 怎么落地 +- 定义统一 block 结构: +```json +{ + "type": "text | citation | summary_card | artifact | tool_status | error", + "payload": {} +} +``` +- assistant message 最终存储结构: +```json +{ + "id": "...", + "blocks": [] +} +``` +- SSE 中间态也复用 block/event 体系 +- 第一阶段前端至少支持: + - `text` + - `tool_status` + - `citation` + - `summary_card` + +--- + +### 15.9 长任务状态回传 + +#### 要完成什么 +- 文档生成、Sandbox 数据分析、深度搜索等任务可能耗时较长 +- 前端需要看到任务状态,而不是一直假 loading + +#### 用什么技术 +- **Azure Service Bus**:异步任务投递 +- **PostgreSQL**:任务状态持久化 +- **Redis**:缓存短状态 +- **SSE / 轮询**:状态回传给前端 + +#### 怎么落地 +- 新增表:`async_tasks` + - `id` + - `task_type` + - `conversation_id` + - `message_id` + - `status` + - `progress_text` + - `result_payload` + - `created_at` + - `updated_at` +- 新增接口: + - `GET /api/tasks/{id}` +- 第一阶段先采用“数据库状态 + 前端轮询” +- 后续再增强为 SSE 任务事件推送 + +--- + +### 15.10 扩展连接状态注入 graph + +#### 要完成什么 +- 某个扩展是否已连接,必须直接决定 graph 中哪些工具可用 +- 未连接扩展不能被调用 +- 已连接扩展才能参与 agent 路由 + +#### 用什么技术 +- **extension registry** +- **LangGraph state injection** +- **tool availability resolver** + +#### 怎么落地 +- graph 执行前先加载当前扩展连接状态 +- 注入 state: +```json +{ + "available_extensions": ["ticket"] +} +``` +- router 节点判断: + - 工单问题 + ticket 已连接 -> 允许调用 + - 工单问题 + ticket 未连接 -> 返回“扩展未连接” +- 销售 / 云管未来直接复用该机制 + +--- + +### 15.11 会话重命名 / 置顶等预留 + +#### 要完成什么 +- 为左侧会话更多操作菜单预留后端能力 +- 支持未来扩展: + - 重命名 + - 置顶 + - 自定义排序 + +#### 用什么技术 +- **PostgreSQL conversation metadata** +- **排序字段 / pinned 字段** + +#### 怎么落地 +- conversations 表补充字段: + - `custom_title` + - `pinned` + - `sort_order` +- 接口统一走: + - `PATCH /api/conversations/{id}` +- 即使前端暂未开放置顶,也建议先预留字段 + +--- + +### 15.12 会话级偏好元数据 + +#### 要完成什么 +- 记录会话偏好信息,例如: + - 当前选中的模型 + - 当前启用工具 + - 默认搜索模式 + - 当前关联扩展 +- 会话恢复时自动延续这些设置 + +#### 用什么技术 +- **PostgreSQL JSON metadata** +- **LangGraph state hydration** + +#### 怎么落地 +- conversations 表增加: + - `metadata_json` +- 典型结构示例: +```json +{ + "selected_model": "gpt-5.4", + "selected_mode": "pro", + "enabled_tools": ["knowledge", "search"], + "preferred_search_mode": "deep" +} +``` +- 会话恢复时把 metadata 注入 graph 初始 state + +--- + +## 16. 接口层总表 + +> 虽然本方案不以 FastAPI 为重点,但前端要接入,仍然需要有 HTTP/SSE 出口。这里把它视为“接入层”,不是方案核心。 ### 第一阶段建议建设的接口 @@ -359,75 +828,91 @@ --- -## 12. 推荐技术组合总结 +## 16. 推荐技术组合总结 -### 基础后端框架 -- **FastAPI**:API 服务 -- **Uvicorn / Gunicorn**:服务运行 +### 核心框架 +- **LangChain**:模型调用、Prompt 组织、Tool 封装、Memory 适配 +- **LangGraph**:对话状态机、工具路由、任务编排、长链路执行 ### 数据层 -- **PostgreSQL**:数据持久化 +- **PostgreSQL**:会话/消息/工具调用/任务持久化 +- **Redis**:缓存、短状态、限流 +- **Azure Blob Storage**:附件、产物、文档存储 +- **Azure Service Bus**:异步任务编排 - **SQLAlchemy / SQLModel**:ORM - **Alembic**:迁移管理 ### AI 与搜索 - **Azure OpenAI**:LLM 生成与总结 - **KB_AGENT**:内部知识库检索 -- **Jina Search / Reader / Rerank**:外部搜索链路 +- **Jina MCP SSE / v1 + Search / Reader / Rerank**:外部搜索链路 ### 外部业务系统 - **Gongdan API**:工单只读 - **Doc Creator Agent**:文档生成 - **Daytona Sandbox**:受控代码执行 -### 交互协议 -- **SSE**:流式输出 -- **REST API**:管理类接口 +### 协议与接入 +- **SSE**:流式输出到前端 +- **HTTP API**:前端接入层 --- -## 13. 第一阶段开发顺序 +## 17. 第一阶段开发顺序 ### 第一步 先完成: -- FastAPI 基础框架 +- LangChain + LangGraph 基础工程 - PostgreSQL 接入 - conversations/messages 表 +- 基础聊天 graph - `/api/chat/stream` - `/api/conversations` ### 第二步 接入: - Azure OpenAI -- KB_AGENT -- 工单系统 +- KB_AGENT tool +- 工单 tools ### 第三步 接入: -- Jina 外部搜索 +- Jina MCP SSE / v1 搜索链路 +- Search / Reader / Rerank tool chain - 来源引用 -- 工具状态流式事件 +- graph 中间状态流式事件 +- Redis 缓存 ### 第四步 接入: -- 文档生成任务 -- 附件解析 -- Sandbox +- 文档生成 tool +- 附件解析 loader +- sandbox tools +- Azure Blob Storage +- Azure Service Bus +- graph 持久化和恢复 --- -## 14. 最终结论 +## 18. 最终结论 -这个项目当前最合适的后端建设方式,不是泛泛而谈“做一个 AI 平台后端”,而是严格按功能拆: +这个项目当前最合适的后端方案,如果明确要求基于 LangChain 框架来做,那就应该是: -- 用 **FastAPI + SSE** 完成真实聊天流式回复 -- 用 **PostgreSQL** 完成会话和消息持久化 -- 用 **Azure OpenAI** 完成回答生成 -- 用 **KB_AGENT** 完成内部知识检索 -- 用 **Jina Search/Reader/Rerank** 完成外部搜索 -- 用 **Gongdan API** 完成工单只读分析 -- 用 **Doc Creator Agent** 完成正式文档生成 -- 用 **Daytona Sandbox** 完成安全数据处理与代码执行 +- 用 **LangChain + LangGraph** 做整个后端核心 +- 用 **Azure OpenAI** 做模型生成和总结 +- 用 **KB_AGENT** 做内部知识检索工具 +- 用 **Jina Search/Reader/Rerank** 做外部搜索工具链 +- 用 **Gongdan API** 做工单查询工具 +- 用 **Doc Creator Agent** 做正式文档生成工具 +- 用 **Daytona Sandbox** 做受控执行工具 +- 用 **PostgreSQL** 做会话、消息、任务和 graph 状态持久化 +- 用 **Redis** 做缓存和短状态管理 +- 用 **Azure Blob Storage** 做附件与产物存储 +- 用 **Azure Service Bus** 做长任务异步编排 +- 用 **MCP 方式** 标准化接入 Jina 外部搜索 + +整个系统本质上是: +**一个基于 LangGraph 编排、具备缓存/存储/异步任务能力的企业级对话 Agent 后端。** 而且整个过程中: **前端交互不改,只替换数据来源和后端能力。**