#!/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}, }