feat: add backend service and Azure deployment workflow

- Add complete Python backend (Litestar + LangGraph) with chat, conversations, tickets APIs
- Add GitHub Actions workflow for auto-deploying backend to Azure Web App (soc-backend)
- Add gunicorn to requirements.txt for production serving
- Update CLAUDE.md and EXTERNAL_SERVICES.md with latest config
- Remove obsolete claudehd.md (merged into gpthd.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-08 13:31:00 +08:00
co-authored by Claude Opus 4.6
parent 04d8fbb740
commit efb3c53623
28 changed files with 1868 additions and 374 deletions
+35
View File
@@ -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
+80 -36
View File
@@ -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 `<GeminiChat />` 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 />`
- `GeminiChat.tsx` owns all state, composes sidebar/topbar/input/message/welcome components
- `GeminiInput.tsx` has `activeTools` (Set\<string\>) 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.
+25 -24
View File
@@ -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@<host>: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=
```
---
+27
View File
@@ -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
View File
View File
+136
View File
@@ -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",
},
)
+113
View File
@@ -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}
+10
View File
@@ -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"}
+80
View File
@@ -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)
+50
View File
@@ -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()
View File
+89
View File
@@ -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
+36
View File
@@ -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]}
+11
View File
@@ -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"
+61
View File
@@ -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,
)
+45
View File
@@ -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)
View File
+40
View File
@@ -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
+98
View File
@@ -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()
+21
View File
@@ -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
+56
View File
@@ -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)
+117
View File
@@ -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}"
)
+23
View File
@@ -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"]
+14
View File
@@ -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
-106
View File
@@ -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) |
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"cursor-project-memory": {
"baseUrl": "http://172.188.219.174:3101/mcp"
}
},
"imports": []
}
+693 -208
View File
File diff suppressed because it is too large Load Diff