#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业 2 · 客服意图路由工作流 graph 定义(给草稿同步用)。
结构:用户输入 → LLM 意图识别 → 代码抽出类别 → IF/ELSE
├ 知识库问答 → 知识库检索 → 生成回答 → 结束
├ 工单报修 → HTTP 工单 API → 返回工单号 → 结束
├ 人工客服 → 转人工提示 → 结束
└ 其他 → 兜底回复 → 结束
不用 question-classifier:Qwen3.6 带 reasoning 时经常不吐 ```json```,
该节点会报 could not find json block。
"""
from __future__ import annotations
from typing import Any
import _config as C
CLS_KB = "cls_kb"
CLS_TICKET = "cls_ticket"
CLS_HUMAN = "cls_human"
CLS_OTHER = "cls_other"
N_START = "start"
N_LLM_INTENT = "llm_intent"
N_PARSE = "parse_intent"
N_IF = "route"
N_KR = "kr"
N_LLM_KB = "llm_kb"
N_HTTP = "http_ticket"
N_TPL_TICKET = "tpl_ticket"
N_TPL_HUMAN = "tpl_human"
N_TPL_OTHER = "tpl_other"
N_END_KB = "end_kb"
N_END_TICKET = "end_ticket"
N_END_HUMAN = "end_human"
N_END_OTHER = "end_other"
PARSE_CODE = r'''
import re
LABELS = ("人工客服", "工单报修", "知识库问答", "其他")
def main(text: str) -> dict:
raw = text or ""
cleaned = re.sub(r"]*>[\s\S]*?", "", raw, flags=re.I)
# 优先看清洗后的正文,避免思考链里同时出现多个类别名
for blob in (cleaned, raw):
for lab in LABELS:
if lab in blob:
return {"intent": lab}
low = (cleaned or raw).lower()
if any(k in low for k in ("投诉", "complain", "human", "转人工")):
return {"intent": "人工客服"}
if any(k in low for k in ("报修", "repair", "ticket", "工单")):
return {"intent": "工单报修"}
if any(k in low for k in ("请假", "制度", "knowledge", "知识库")):
return {"intent": "知识库问答"}
return {"intent": "其他"}
'''.lstrip()
INTENT_SYS = """你是客服意图分类器。根据用户原话,只输出下面四个标签之一,不要解释、不要思考过程、不要 Markdown。
标签:
- 人工客服:投诉、举报、找领导、要人工、转人工。例句:我要投诉
- 工单报修:报修、设备故障、开维修单。例句:帮我报修设备
- 知识库问答:制度、流程、请假、加班、打卡、福利。例句:怎么请假
- 其他:闲聊或无法归类
只输出这四个中文词之一。"""
def _model(max_tokens: int, temperature: float = 0.2) -> dict[str, Any]:
return {
"provider": C.OPENAI_PROVIDER,
"name": C.QWEN_MODEL,
"mode": "chat",
"completion_params": {
"temperature": temperature,
"max_tokens": max_tokens,
"enable_thinking": False,
},
}
def _node(nid: str, ntype: str, title: str, data: dict[str, Any], x: float, y: float, h: int = 110) -> dict[str, Any]:
payload = {
"type": ntype,
"title": title,
"desc": "",
"selected": False,
**data,
}
return {
"id": nid,
"type": "custom",
"position": {"x": x, "y": y},
"positionAbsolute": {"x": x, "y": y},
"sourcePosition": "right",
"targetPosition": "left",
"width": 244,
"height": h,
"data": payload,
}
def _edge(src: str, dst: str, src_type: str, dst_type: str, handle: str = "source") -> dict[str, Any]:
return {
"id": "%s-%s-%s" % (src, handle, dst),
"source": src,
"sourceHandle": handle,
"target": dst,
"targetHandle": "target",
"type": "custom",
"zIndex": 0,
"data": {
"sourceType": src_type,
"targetType": dst_type,
"isInIteration": False,
},
}
FEATURES: dict[str, Any] = {
"file_upload": {
"image": {
"enabled": False,
"number_limits": 3,
"transfer_methods": ["local_file", "remote_url"],
}
},
"opening_statement": "",
"retriever_resource": {"enabled": True},
"sensitive_word_avoidance": {"enabled": False},
"speech_to_text": {"enabled": False},
"suggested_questions": [],
"suggested_questions_after_answer": {"enabled": False},
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
}
def build_graph() -> dict[str, Any]:
ticket_url = C.TICKET_API_BASE.rstrip("/") + "/ticket"
body_json = '{"query": "{{#%s.query#}}", "category": "repair"}' % N_START
nodes = [
_node(
N_START,
"start",
"用户输入",
{
"variables": [
{
"variable": "query",
"label": "用户问题",
"type": "paragraph",
"required": True,
"max_length": 2000,
"options": [],
}
]
},
40,
420,
90,
),
_node(
N_LLM_INTENT,
"llm",
"意图识别",
{
"model": _model(1024, 0.0),
"prompt_template": [
{"role": "system", "text": INTENT_SYS, "edition_type": "basic"},
{
"role": "user",
"text": "用户原话:{{#%s.query#}}" % N_START,
"edition_type": "basic",
},
],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
},
330,
400,
140,
),
_node(
N_PARSE,
"code",
"抽出意图标签",
{
"variables": [
{"variable": "text", "value_selector": [N_LLM_INTENT, "text"]}
],
"code_language": "python3",
"code": PARSE_CODE,
"outputs": {"intent": {"type": "string", "children": None}},
},
620,
400,
100,
),
_node(
N_IF,
"if-else",
"意图路由",
{
"cases": [
{
"case_id": CLS_KB,
"logical_operator": "and",
"conditions": [
{
"id": "c_kb",
"variable_selector": [N_PARSE, "intent"],
"comparison_operator": "is",
"value": "知识库问答",
}
],
},
{
"case_id": CLS_TICKET,
"logical_operator": "and",
"conditions": [
{
"id": "c_ticket",
"variable_selector": [N_PARSE, "intent"],
"comparison_operator": "is",
"value": "工单报修",
}
],
},
{
"case_id": CLS_HUMAN,
"logical_operator": "and",
"conditions": [
{
"id": "c_human",
"variable_selector": [N_PARSE, "intent"],
"comparison_operator": "is",
"value": "人工客服",
}
],
},
{
"case_id": CLS_OTHER,
"logical_operator": "and",
"conditions": [
{
"id": "c_other",
"variable_selector": [N_PARSE, "intent"],
"comparison_operator": "is",
"value": "其他",
}
],
},
]
},
900,
380,
220,
),
_node(
N_KR,
"knowledge-retrieval",
"知识库检索",
{
"query_variable_selector": [N_START, "query"],
"dataset_ids": [C.DS_ID],
"retrieval_mode": "multiple",
"multiple_retrieval_config": {
"top_k": 5,
"score_threshold": None,
"reranking_enable": True,
"reranking_mode": "reranking_model",
"reranking_model": {
"provider": C.XINFERENCE_PROVIDER,
"model": C.RERANK_MODEL,
},
},
},
1180,
40,
100,
),
_node(
N_LLM_KB,
"llm",
"生成回答",
{
"model": _model(2048, 0.3),
"prompt_template": [
{
"role": "system",
"text": (
"你是播城内部行政客服。只根据知识库片段回答,不要编造。"
"若片段不足,明确说不知道并建议转人工。\n"
"使用 中的资料:\n"
"\n{{#context#}}\n"
),
"edition_type": "basic",
},
{
"role": "user",
"text": "用户问题:{{#%s.query#}}" % N_START,
"edition_type": "basic",
},
],
"context": {
"enabled": True,
"variable_selector": [N_KR, "result"],
},
"vision": {"enabled": False},
},
1480,
40,
150,
),
_node(
N_END_KB,
"end",
"结束·知识库",
{
"outputs": [
{"variable": "answer", "value_selector": [N_LLM_KB, "text"]}
]
},
1780,
40,
90,
),
_node(
N_HTTP,
"http-request",
"调用工单API",
{
"method": "post",
"url": ticket_url,
"authorization": {"type": "no-auth", "config": None},
"headers": "Content-Type:application/json",
"params": "",
"body": {
"type": "json",
"data": [{"type": "text", "key": "", "value": body_json}],
},
"timeout": {
"connect": 10,
"read": 30,
"write": 30,
"max_connect_timeout": 10,
"max_read_timeout": 60,
"max_write_timeout": 60,
},
"ssl_verify": False,
},
1180,
280,
150,
),
_node(
N_TPL_TICKET,
"template-transform",
"返回工单号",
{
"template": "已为您创建报修工单。\n{{ body }}\n请保存工单号,维修人员会联系您。",
"variables": [
{"variable": "body", "value_selector": [N_HTTP, "body"]}
],
},
1480,
280,
90,
),
_node(
N_END_TICKET,
"end",
"结束·工单",
{
"outputs": [
{"variable": "answer", "value_selector": [N_TPL_TICKET, "output"]}
]
},
1780,
280,
90,
),
_node(
N_TPL_HUMAN,
"template-transform",
"转人工提示",
{
"template": (
"正在为您转接人工客服。投诉类诉求需要人工跟进,"
"请保持在线,工作人员会尽快接入。"
),
"variables": [],
},
1180,
500,
90,
),
_node(
N_END_HUMAN,
"end",
"结束·人工",
{
"outputs": [
{"variable": "answer", "value_selector": [N_TPL_HUMAN, "output"]}
]
},
1480,
500,
90,
),
_node(
N_TPL_OTHER,
"template-transform",
"兜底回复",
{
"template": (
"我暂时无法判断您的具体诉求。您可以:\n"
"1)问制度流程(例如怎么请假);\n"
"2)报修设备;\n"
"3)明确说要投诉或转人工。"
),
"variables": [],
},
1180,
680,
90,
),
_node(
N_END_OTHER,
"end",
"结束·其他",
{
"outputs": [
{"variable": "answer", "value_selector": [N_TPL_OTHER, "output"]}
]
},
1480,
680,
90,
),
]
edges = [
_edge(N_START, N_LLM_INTENT, "start", "llm"),
_edge(N_LLM_INTENT, N_PARSE, "llm", "code"),
_edge(N_PARSE, N_IF, "code", "if-else"),
_edge(N_IF, N_KR, "if-else", "knowledge-retrieval", CLS_KB),
_edge(N_KR, N_LLM_KB, "knowledge-retrieval", "llm"),
_edge(N_LLM_KB, N_END_KB, "llm", "end"),
_edge(N_IF, N_HTTP, "if-else", "http-request", CLS_TICKET),
_edge(N_HTTP, N_TPL_TICKET, "http-request", "template-transform"),
_edge(N_TPL_TICKET, N_END_TICKET, "template-transform", "end"),
_edge(N_IF, N_TPL_HUMAN, "if-else", "template-transform", CLS_HUMAN),
_edge(N_TPL_HUMAN, N_END_HUMAN, "template-transform", "end"),
_edge(N_IF, N_TPL_OTHER, "if-else", "template-transform", CLS_OTHER),
_edge(N_TPL_OTHER, N_END_OTHER, "template-transform", "end"),
_edge(N_IF, N_TPL_OTHER, "if-else", "template-transform", "false"),
]
return {
"nodes": nodes,
"edges": edges,
"viewport": {"x": 0, "y": 0, "zoom": 0.65},
}