跳转到内容

9.3 API 服务层与前端界面 ​

在上一节中我们实现了智能客服工单系统的核心图逻辑——所有节点函数、状态定义、路由规则和图拓扑。但一个完整的应用不能只有后端逻辑,还需要 API 层来接收外部请求、前端界面让用户能直观地与系统交互。这一节我们会用 FastAPI 构建 RESTful API 服务层,并实现一个简洁的 Web 前端界面。

FastAPI 服务层设计 ​

python
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
import uuid
import json

app_fastapi = FastAPI(
    title="智能客服工单系统 API",
    version="1.0.0",
    description="基于 LangGraph 的智能客服工单处理系统"
)

# 全局存储(生产环境应替换为数据库)
ticket_store: dict[str, dict] = {}
checkpointer_store: dict = {}  # 模拟 checkpoint 存储

class CreateTicketRequest(BaseModel):
    channel: str = Field(..., description="渠道: web/mobile/api/email")
    message: str = Field(..., description="用户消息内容")
    user_id: str = Field(..., description="用户ID")
    user_name: str = Field("匿名用户", description="用户名")
    user_tier: str = Field("normal", description="用户等级")

class TicketResponse(BaseModel):
    ticket_id: str
    status: str
    category: str
    urgency: str
    priority: int
    auto_resolved: bool
    reply: Optional[str]
    assigned_agent: Optional[str]
    requires_human: bool
    current_step: Optional[int]

class HumanActionRequest(BaseModel):
    ticket_id: str
    action: str = Field(..., description: continue/escalate/resolve/approve/reject)
    comment: Optional[str] = None

@app_fastapi.post("/api/tickets", response_model=TicketResponse, status_code=201)
async def create_ticket(req: CreateTicketRequest):
    ticket_id = f"TKT-{uuid.uuid4().hex[:8].upper()}"
    
    initial_state = {
        "ticket_id": ticket_id,
        "channel": req.channel,
        "raw_message": req.message,
        "sender_info": {"user_id": req.user_id, "name": req.user_name, "tier": req.user_tier},
        "attachments": [],
        "final_reply": "",
        "intent_category": "", "intent_confidence": 0.0,
        "urgency_level": "medium", "priority_score": 0,
        "kb_match_result": None, "auto_resolved": False,
        "assigned_agent_id": "", "assigned_team": "",
        "resolution_steps": [], "current_step": 0,
        "execution_log": [], "human_decision": "", "decision_type": "",
        "interrupt_point": None,
        "conversation_history": [], "user_satisfaction": None,
        "follow_up_status": "pending", "follow_up_sent_at": None
    }
    
    result = app.invoke(initial_state)
    
    ticket_store[ticket_id] = {
        "created_at": datetime.now().isoformat(),
        "state": result,
        "config": {"thread_id": f"ticket-{ticket_id}"}
    }
    
    return TicketResponse(
        ticket_id=ticket_id,
        status="completed" if result.get("auto_resolved") else "processing",
        category=result.get("intent_category"),
        urgency=result.get("urgency_level"),
        priority=result.get("priority_score", 0),
        auto_resolved=result.get("auto_resolved", False),
        reply=result.get("final_reply") if result.get("auto_resolved") else None,
        assigned_agent=result.get("assigned_agent_id"),
        requires_human=True if not result.get("auto_resolved") and 
                       result.get("interrupt_point") else False,
        current_step=result.get("current_step")
    )

@app_fastapi.post("/api/tickets/{ticket_id}/action", response_model=TicketResponse)
async def submit_action(req: HumanActionRequest):
    ticket_id = ticket_id
    stored = ticket_store.get(ticket_id)
    
    if not stored:
        raise HTTPException(status_code=404, detail=f"工单 {ticket_id} 不存在")
    
    config = stored["config"]
    state = stored["state"]
    
    if req.action == "continue":
        resume_value = f"{req.action} {req.comment or ''}"
    elif req.action in ("approve", "reject", "escalate", "resolve"):
        resume_value = f"{req.action} {req.comment or ''}"
    else:
        raise HTTPException(status_code=400, detail=f"无效操作: {req.action}")
    
    updated = app.invoke(Command(resume=resume_value), config=config)
    stored["state"] = updated
    
    return TicketResponse(
        ticket_id=ticket_id,
        status="completed" if updated.get("follow_up_status") == "confirmed" else "processing",
        category=updated.get("intent_category"),
        urgency=updated.get("urgency_level"),
        priority=updated.get("priority_score", 0),
        auto_resolved=updated.get("auto_resolved", False),
        reply=updated.get("final_reply"),
        assigned_agent=updated.get("assigned_agent_id"),
        requires_human=False,
        current_step=updated.get("current_step")
    )

@app_fastapi.get("/api/tickets/{ticket_id}", response_model=TicketResponse)
async def get_ticket(ticket_id: str):
    stored = ticket_store.get(ticket_id)
    if not stored:
        raise HTTPException(status_code=404, detail=f"工单 {ticket_id} 不存在")
    
    state = stored["state"]
    return TicketResponse(
        ticket_id=ticket_id,
        status="completed" if state.get("follow_up_status") == "confirmed"
                else ("waiting_input" if state.get("interrupt_point") else "processing"),
        category=state.get("intent_category"),
        urgency=state.get("urgency_level"),
        priority=state.get("priority_score", 0),
        auto_resolved=state.get("auto_resolved", False),
        reply=state.get("final_reply"),
        assigned_agent=state.get("assigned_agent_id"),
        requires_human=True if state.get("interrupt_point") else False,
        current_step=state.get("current_step")
    )

@app_fastapi.get("/api/tickets", response_model=list)
async def list_tickets():
    return [
        {
            "ticket_id": tid,
            "status": data["state"].get("follow_up_status", "processing"),
            "category": data["state"].get("intent_category"),
            "urgency": data["state"].get("urgency_level"),
            "created_at": data["created_at"]
        }
        for tid, data in ticket_store.items()
    ]

@app_fastapi.get("/api/dashboard/stats")
async def dashboard_stats():
    total = len(ticket_store)
    by_status = {}
    by_category = {}
    by_urgency = {}
    avg_steps = []
    
    for tid, data in ticket_store.items():
        s = data["state"]
        by_status[s.get("follow_up_status", "unknown")] = by_status.get(s.get("follow_up_status", "unknown"), 0) + 1
        by_category[s.get("intent_category", "unknown")] = by_category.get(s.get("intent_category", "unknown"), 0) + 1
        by_urgency[s.get("urgency_level", "unknown")] = by_urgency.get(s.get("urgency_level", "unknown"), 0) + 1
        if s.get("current_step"):
            avg_steps.append(s["current_step"])
    
    return {
        "total_tickets": total,
        "by_status": by_status,
        "by_category": by_category,
        "by_urgency": by_urgency,
        "avg_processing_steps": sum(avg_steps) / len(avg_steps) if avg_steps else 0,
        "active_tickets": sum(1 for s in [d["state"] for d in ticket_list.values() 
                        if d.get("follow_up_status") in ("pending", "waiting_input"))
    }

from langgraph.types import Command

前端界面 ​

下面是一个精简版的前端页面,展示核心的交互功能:

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>智能客服工单系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacFont, 'Segoe UI', sans-serif;
       background: #f5f7fa; color: #333; padding: 20px; }
.container { max-width: 900px; margin: 0 auto; }
.header { text-align: center; padding: 20px 0; border-bottom: 2px solid #4a90e2; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 20px 0; }
.stat-card { background: white; padding: 16px; border-radius: 8px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.stat-num { font-size: 24px; font-weight: bold; color: #4a90e2; }
.stat-label { font-size: 12px; color: #888; margin-top: 4px; }
.ticket-list { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.ticket-item { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; align-items: center; cursor: pointer; transition: background .2s; }
.ticket-item:hover { background: #f8f9fa; }
.ticket-id { font-family: monospace; font-size: 13px; min-width: 100px; }
.ticket-meta { flex: 1; margin-left: 12px; }
.ticket-category { display: inline-block; padding: 2px 8px; border-radius: 4px; 
               font-size: 12px; font-weight: 600; }
.critical { background: #fee2e2; color: #c00; }
.high { background: #fff3cd; color: #856404; }
.medium { background: #ffeaa7; color: #856404; }
.low { background: #e8f5e9; color: #388e3c; }
.btn { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-primary { background: #4a90e2; color: white; }
.btn-outline { background: white; color: #4a90e2; border: 1px solid #4a90e2; }
.form-area { width: 100%; min-height: 120px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; resize: vertical; font-family: inherit; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
    <div class="header">
        <h1>🎧 智能客服工单系统</h1>
        <p>基于 LangGraph 的多级路由 · 自动分类 · 知识库匹配 · 人机协作</p>
    </div>

    <!-- 统计仪表盘 -->
    <div id="stats-panel"></div>
    <div style="margin-top:20px;">
        <textarea id="msg-input" class="form-area" placeholder="描述您的问题..."></textarea>
        <div style="margin-top:12px; display:flex; gap:10px;">
            <button class="btn btn-primary" onclick="submitTicket()">提交工单</button>
            <button class="btn btn-outline" onclick="loadTickets()">刷新列表</button>
        </div>
    </div>

    <div id="tickets-panel" class="ticket-list"></div>
</div>

<script>
const API_BASE = "";

async function loadStats() {
    const resp = await fetch(`${API_BASE}/api/dashboard/stats`);
    const data = await resp.json();
    
    document.getElementById('stats-panel').innerHTML = `
        <div class="stats-grid">
            <div class="stat-card"><div class="stat-num">${data.total_tickets}</div><div class="stat-label">总工单</div></div>
            <div class="stat-card"><div class="stat-num">${data.by_status.get('confirmed', 0)}</div><div class="stat-label">已完成</div></div>
            <div class="stat-card"><div class="stat-num">${data.by_status.get('pending', 0) + data.by_status.get('waiting_input', 0)}</div><div class="stat-label">待处理</div></div>
            <div class="stat-card"><div class="stat-num">${data.by_urgency.get('critical', 0)}</div><div class="stat-label">紧急</div></div>
            <div class="stat-card"><div class="stat-num">${data.avg_processing_steps.toFixed(1)}</div><div class="stat-label">平均步骤</div></div>
        </div>
    `;
}

async function submitTicket() {
    const msg = document.getElementById('msg-input').value.trim();
    if (!msg) return alert("请输入问题内容");
    
    const resp = await fetch(`${API_BASE}/api/tickets`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
            channel: "web",
            message: msg,
            user_id: "u-" + Date.now().toString(36),
            user_name: "访客用户",
            user_tier: "normal"
        })
    });
    
    const ticket = await resp.json();
    showTicketDetail(ticket);
    loadTickets();
    loadStats();
}

async function loadTickets() {
    const resp = await fetch(`${API_BASE}/api/tickets`);
    const tickets = await resp.json();
    
    document.getElementById('tickets-panel').innerHTML = tickets.length === 0 
        ? '<p style="text-align:center;color:#999;padding:40px;">暂无工单</p>'
        : tickets.map(t => `
            <div class="ticket-item" onclick="showTicketDetail(${JSON.stringify(t.ticket_id).replace(/"/g,'')})">
                <span class="ticket-id">${t.ticket_id}</span>
                <div class="ticket-meta">
                    <span class="ticket-category ${t.urgency}">${t.category || '...'}</span>
                    ${t.auto_resolved ? '<span style="color:#16a34a;font-weight:600;">✅ 自动解决</span>' : 
                     t.requires_human ? '<span style="color:#e67e22;font-weight:600;">⏳ 待人工</span>' :
                     '<span>处理中</span>'}
                    <span style="margin-left:auto;color:#888;font-size:12px;">${t.assigned_agent || '-'}</span>
                </div>
            </div>
        `;
}

let currentTicketId = null;

async function showTicketDetail(ticket) {
    if (typeof ticket === 'string') ticket = JSON.parse(ticket);
    currentTicketId = ticket.ticket_id;
    
    const resp = await fetch(`${API_BASE}/api/tickets/${ticket.ticket_id}`);
    const detail = await resp.json();
    
    const panel = document.getElementById('tickets-panel');
    panel.innerHTML = `
        <div style="padding:20px;background:white;border-radius:8px;margin-top:12px;box-shadow:0 2px 8px rgba(0,0,0,.1);">
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
                <h3 style="margin:0">📋 工单 ${detail.ticket_id}</h3>
                <button class="btn btn-outline" onclick="loadTickets()" style="font-size:12px;padding:6px 12px;">← 返回列表</button>
            </div>
            
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
                <div style="background:#f8f9fa;padding:12px;border-radius:6px;">
                    <strong>分类</strong><br>${detail.category || '-'}
                </div>
                <div style="background:#f8f9fa;padding:12px;border-radius:6px;">
                    <strong>紧急度</strong><br>${detail.urgency || '-'} | 优先分: ${detail.priority || '-'}
                </div>
            </div>
            
            <div style="background:#fef9e7;padding:16px;border-radius:6px;margin-bottom:16px;border-left:3px solid #f59e0b;">
                <strong>💬 原始消息</strong><br>
                <pre style="white-space:pre-wrap;font-size:13px;margin:8px 0;">${detail.reply || '(等待处理...)'}</pre>
            </div>
            
            ${detail.requires_human ? `
            <div style="margin-top:16px;">
                <strong>🔔 需要您操作</strong>
                <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;">
                    <button class="btn btn-primary" onclick="sendAction('continue')">继续处理</button>
                    <button class="btn btn-outline" onclick="sendAction('escalate')">升级处理</button>
                    <button class="btn btn-outline" onclick="sendAction('resolve')">直接关闭</button>
                </div>
                <input id="action-comment" placeholder="可选备注..." style="flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:6px;">
            </div>` : ''}
            
            <div style="margin-top:12px;font-size:12px;color:#888;text-align:center;">
                步骤详情: ${detail.current_step || 0} | 
                负责人: ${detail.assigned_agent || '-'}
            </div>
        </div>
    `;
}

async function sendAction(action) {
    const comment = document.getElementById('action-comment').value;
    const resp = await fetch(`${API_BASE}/api/tickets/${currentTicketId}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ticket_id: currentTicketId, action, comment})
    });
    
    const detail = await resp.json();
    showTicketDetail(detail);
    loadStats();
}
</script>
</body>
</html>

这个前端实现了以下关键功能:

  1. 统计仪表盘:实时显示总工单数、已完成数、待处理数、紧急工单数、平均处理步骤
  2. 工单创建:文本框输入 → 提交按钮 → 后端自动处理 → 展示结果或等待人工操作
  3. 工单列表:展示所有工单,点击可查看详情
  4. 工单详情页:显示完整的分类、消息、回复;如果需要人工操作则显示操作按钮
  5. 操作按钮:继续/升级/关闭,附带可选备注

API 测试流程 ​

启动服务后可以通过以下方式测试完整流程:

bash
# 启动 API 服务
uvicorn main:app --host 0.0.0.0 --port 8000

# 测试1:提交普通咨询(预期:知识库自动回答)
curl -X POST http://localhost:8000/api/tickets \
  -H "Content-Type: application/json" \
  -d '{"channel":"web","message":"怎么重置密码?","user_id":"u-001","user_name":"测试用户","user_tier":"normal"}'

# 测试2:提交紧急问题(预期:升级路由+需要人工)
curl -X POST http://localhost:8000/api/tickets \
  - H "Content-Type: application/json" \
  -d '{"channel":"web","message":"系统崩溃了!赶紧处理!","user_id":"u-002","user_name":"VIP用户","user_tier":"vip"}'

# 测试3:查看工单列表
curl http://localhost:8000/api/tickets

# 测试4:对需人工操作的工单执行操作
curl -X POST http://localhost:8000/api/tickets/TKT-XXXX/action \
  - H "Content-Type: application/json" \
  -d '{"ticket_id":"TKT-XXXX","action":"resolve","comment":"已解决"}'

基于 MIT 许可发布