baodan/scripts/setup_workflow.py

776 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BaoDan Workflow 自动化配置脚本
功能:
1. 登录 BaoDan 后台获取 token
2. 创建 Workflow 应用(产品推荐方案生成)
3. 配置 6 个节点的 Workflow 图
用法:
python scripts/setup_workflow.py
前置条件:
1. BaoDan 服务已启动docker compose up -d
2. 已创建管理员账号默认taiyi@baodan.com / taiyi1224
3. 已配置 LLM 模型(在 BaoDan 后台 设置 -> 模型供应商)
"""
import base64
import json
import sys
import time
import requests
from typing import Any
# 修复 Windows 控制台编码
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
# ===== 配置 =====
BAODAN_URL = "http://localhost:5001"
ADMIN_EMAIL = "taiyi@baodan.com"
ADMIN_PASSWORD = "taiyi1224"
# Workflow 节点 ID固定值用于连线
NODE_IDS = {
"start": "start",
"validate": "validate_params",
"generate_queries": "generate_queries",
"knowledge_retrieval": "knowledge_retrieval",
"llm_generate": "llm_generate",
"error_handler": "error_handler",
"format_output": "format_output",
"end": "end",
}
class BaoDanWorkflowSetup:
"""BaoDan Workflow 自动化配置器。"""
def __init__(self, base_url: str):
self.base_url = base_url
self.session = requests.Session()
self.token = None
self.csrf_token = None
self.tenant_id = None
self.app_id = None
def login(self, email: str, password: str) -> bool:
"""登录获取 token。"""
print(f"[1/6] 登录 BaoDan ({email})...")
# Dify 要求密码 Base64 编码
encoded_password = base64.b64encode(password.encode('utf-8')).decode('utf-8')
resp = self.session.post(
f"{self.base_url}/console/api/login",
json={"email": email, "password": encoded_password},
)
if resp.status_code != 200:
print(f" ❌ 登录失败: {resp.text}")
return False
data = resp.json()
if data.get("result") != "success":
print(f" ❌ 登录失败: {data}")
return False
# Dify 将 token 设置到 cookie 中
# 从 cookie 中提取 access_token
cookies = resp.cookies
self.token = cookies.get("access_token")
if not self.token:
# 尝试从 set-cookie 头中提取
for cookie in resp.headers.get("set-cookie", "").split(";"):
if "access_token" in cookie:
parts = cookie.split("=")
if len(parts) == 2:
self.token = parts[1].strip()
break
if not self.token:
print(f" ❌ 未获取到 tokencookies: {dict(cookies)}")
return False
# 提取 CSRF token
self.csrf_token = cookies.get("csrf_token")
if not self.csrf_token:
for cookie in resp.headers.get("set-cookie", "").split(";"):
if "csrf_token" in cookie:
parts = cookie.split("=")
if len(parts) == 2:
self.csrf_token = parts[1].strip()
break
self.session.headers.update({
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
})
# 设置 CSRF token 到请求头
if self.csrf_token:
self.session.headers.update({
"X-CSRF-Token": self.csrf_token,
})
print(f" ✅ 登录成功")
return True
def find_existing_app(self) -> str | None:
"""查找已存在的同名 Workflow 应用。"""
page = 1
while True:
resp = self.session.get(
f"{self.base_url}/console/api/apps",
params={"page": page, "limit": 100}
)
if resp.status_code != 200:
break
data = resp.json()
apps = data.get("data", [])
if not apps:
break
for app in apps:
if app.get("name") == "产品推荐方案生成" and app.get("mode") == "workflow":
return app.get("id")
# 检查是否还有更多页
if len(apps) < 100:
break
page += 1
return None
def create_workflow_app(self) -> bool:
"""创建 Workflow 类型应用(如果不存在则复用)。"""
print("[2/6] 检查/创建 Workflow 应用...")
# 先检查是否已存在同名应用
existing_app_id = self.find_existing_app()
if existing_app_id:
self.app_id = existing_app_id
print(f" ♻️ 复用已存在的应用App ID: {self.app_id}")
return True
# 不存在则创建新应用
resp = self.session.post(
f"{self.base_url}/console/api/apps",
json={
"name": "产品推荐方案生成",
"description": "根据客户信息和险种需求,调用知识库检索并生成保险产品推荐方案",
"mode": "workflow",
},
)
if resp.status_code not in (200, 201):
print(f" ❌ 创建应用失败: {resp.text}")
return False
data = resp.json()
self.app_id = data.get("id")
if not self.app_id:
print(f" ❌ 未获取到 App ID: {data}")
return False
print(f" ✅ 应用创建成功App ID: {self.app_id}")
return True
def get_workflow_id(self) -> str | None:
"""获取当前 draft workflow ID。"""
resp = self.session.get(
f"{self.base_url}/console/api/apps/{self.app_id}/workflows/publish"
)
if resp.status_code == 200:
data = resp.json()
if data:
return data.get("workflow", {}).get("id")
return None
def build_workflow_graph(self) -> dict:
"""构建 Workflow 节点图。"""
return {
"nodes": self._build_nodes(),
"edges": self._build_edges(),
}
def _build_nodes(self) -> list[dict]:
"""构建所有节点配置。"""
return [
# Start 节点
{
"id": NODE_IDS["start"],
"type": "custom",
"data": {
"type": "start",
"title": "开始",
"desc": "",
"variables": self._get_start_variables(),
},
"position": {"x": 80, "y": 282},
},
# 节点1参数校验
{
"id": NODE_IDS["validate"],
"type": "custom",
"data": {
"type": "code",
"title": "参数校验",
"desc": "校验客户年龄、性别、职业、预算等参数",
"variables": [],
"code": self._get_validate_code(),
"code_language": "python3",
"outputs": {
"validated_params": {"type": "object", "children": None},
"error_msg": {"type": "string", "children": None},
"is_valid": {"type": "boolean", "children": None},
},
},
"position": {"x": 320, "y": 282},
},
# 节点2检索策略生成
{
"id": NODE_IDS["generate_queries"],
"type": "custom",
"data": {
"type": "code",
"title": "检索策略生成",
"desc": "为每个险种生成检索词",
"variables": [],
"code": self._get_generate_queries_code(),
"code_language": "python3",
"outputs": {
"search_queries": {"type": "array[object]", "children": None},
},
},
"position": {"x": 560, "y": 282},
},
# 节点3知识库检索
{
"id": NODE_IDS["knowledge_retrieval"],
"type": "custom",
"data": {
"type": "knowledge-retrieval",
"title": "知识库检索",
"desc": "从保险知识库中检索相关产品条款",
"query_variable_selector": [NODE_IDS["generate_queries"], "search_queries"],
"dataset_ids": [], # 需要手动关联知识库
"retrieval_mode": "multiple",
"multiple_retrieval_config": {
"top_k": 5,
"score_threshold": 0.6,
"reranking_model": None,
},
},
"position": {"x": 800, "y": 282},
},
# 节点4LLM 方案生成
{
"id": NODE_IDS["llm_generate"],
"type": "custom",
"data": {
"type": "llm",
"title": "LLM 方案生成",
"desc": "调用 LLM 生成三套保险推荐方案",
"model": {
"provider": "deepseek",
"name": "deepseek-chat",
"mode": "chat",
"completion_params": {
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 8192,
},
},
"prompt_template": self._get_llm_prompt(),
"memory": None,
},
"position": {"x": 1040, "y": 282},
},
# 节点5异常处理
{
"id": NODE_IDS["error_handler"],
"type": "custom",
"data": {
"type": "code",
"title": "异常处理",
"desc": "处理校验失败或生成失败的情况",
"variables": [],
"code": self._get_error_handler_code(),
"code_language": "python3",
"outputs": {
"final_output": {"type": "string", "children": None},
"is_success": {"type": "boolean", "children": None},
},
},
"position": {"x": 1040, "y": 520},
},
# 节点6格式化输出
{
"id": NODE_IDS["format_output"],
"type": "custom",
"data": {
"type": "code",
"title": "格式化输出",
"desc": "去除多余空行和空白",
"variables": [],
"code": self._get_format_output_code(),
"code_language": "python3",
"outputs": {
"recommendation": {"type": "string", "children": None},
"is_success": {"type": "boolean", "children": None},
},
},
"position": {"x": 1280, "y": 282},
},
# End 节点
{
"id": NODE_IDS["end"],
"type": "custom",
"data": {
"type": "end",
"title": "结束",
"desc": "",
"outputs": [
{
"variable_selector": [NODE_IDS["format_output"], "recommendation"],
"value_selector": [NODE_IDS["format_output"], "recommendation"],
},
{
"variable_selector": [NODE_IDS["format_output"], "is_success"],
"value_selector": [NODE_IDS["format_output"], "is_success"],
},
],
},
"position": {"x": 1520, "y": 282},
},
]
def _build_edges(self) -> list[dict]:
"""构建节点连线。"""
return [
# Start → 参数校验
{"source": NODE_IDS["start"], "target": NODE_IDS["validate"]},
# 参数校验 → is_valid=True → 检索策略生成
{
"source": NODE_IDS["validate"],
"target": NODE_IDS["generate_queries"],
"sourceHandle": "is_valid",
},
# 参数校验 → is_valid=False → 异常处理
{
"source": NODE_IDS["validate"],
"target": NODE_IDS["error_handler"],
"sourceHandle": "is_invalid",
},
# 检索策略生成 → 知识库检索
{"source": NODE_IDS["generate_queries"], "target": NODE_IDS["knowledge_retrieval"]},
# 知识库检索 → LLM 方案生成
{"source": NODE_IDS["knowledge_retrieval"], "target": NODE_IDS["llm_generate"]},
# LLM 方案生成 → 格式化输出
{"source": NODE_IDS["llm_generate"], "target": NODE_IDS["format_output"]},
# 异常处理 → 格式化输出
{"source": NODE_IDS["error_handler"], "target": NODE_IDS["format_output"]},
# 格式化输出 → 结束
{"source": NODE_IDS["format_output"], "target": NODE_IDS["end"]},
]
def _get_start_variables(self) -> list[dict]:
"""获取开始节点的输入变量定义。"""
return [
{"variable": "age", "label": "年龄", "type": "string", "required": True},
{"variable": "gender", "label": "性别", "type": "string", "required": True},
{"variable": "occupation", "label": "职业", "type": "string", "required": True},
{"variable": "annual_income", "label": "年收入(万)", "type": "string", "required": True},
{"variable": "monthly_budget", "label": "月预算(元)", "type": "string", "required": True},
{"variable": "insurance_types", "label": "险种", "type": "string", "required": True},
{"variable": "coverage_amount", "label": "保额(元)", "type": "string", "required": True},
{"variable": "coverage_period", "label": "保障期限", "type": "string", "required": True},
]
def _get_validate_code(self) -> str:
"""节点1参数校验代码。"""
return '''def main(age: str, gender: str, occupation: str, annual_income: str,
monthly_budget: str, insurance_types: str, coverage_amount: str,
coverage_period: str) -> dict:
"""校验客户参数。"""
errors = []
# 校验年龄
try:
age_int = int(age)
if age_int < 1 or age_int > 150:
errors.append("年龄必须在 1-150 之间")
except ValueError:
errors.append("年龄必须为整数")
# 校验性别
if gender not in ("male", "female"):
errors.append("性别必须为 male 或 female")
# 校验职业
if not occupation or len(occupation) < 1:
errors.append("职业不能为空")
# 校验月预算
try:
budget = float(monthly_budget)
if budget <= 0:
errors.append("月预算必须大于 0")
except ValueError:
errors.append("月预算必须为数字")
# 校验保额
try:
amount = float(coverage_amount)
if amount < 10000:
errors.append("保额不能低于 10000 元")
except ValueError:
errors.append("保额必须为数字")
# 校验险种
valid_types = ["重疾险", "医疗险", "意外险", "寿险", "年金险", "财产险"]
types_list = [t.strip() for t in insurance_types.split(",") if t.strip()]
if not types_list:
errors.append("至少选择一种险种")
else:
for t in types_list:
if t not in valid_types:
errors.append(f"不支持的险种: {t}")
# 校验保障期限
valid_periods = ["10年", "20年", "30年", "至60岁", "至70岁", "终身"]
if coverage_period not in valid_periods:
errors.append(f"不支持的保障期限: {coverage_period}")
if errors:
return {
"validated_params": {},
"error_msg": "; ".join(errors),
"is_valid": False,
}
return {
"validated_params": {
"age": age,
"gender": gender,
"occupation": occupation,
"annual_income": annual_income,
"monthly_budget": monthly_budget,
"insurance_types": insurance_types,
"coverage_amount": coverage_amount,
"coverage_period": coverage_period,
},
"error_msg": "",
"is_valid": True,
}
'''
def _get_generate_queries_code(self) -> str:
"""节点2检索策略生成代码。"""
return '''def main(validated_params: dict) -> dict:
"""为每个险种生成检索词。"""
insurance_types = validated_params.get("insurance_types", "")
age = validated_params.get("age", "")
occupation = validated_params.get("occupation", "")
types_list = [t.strip() for t in insurance_types.split(",") if t.strip()]
search_queries = []
for ins_type in types_list:
query = f"{ins_type} 产品条款 保额 费率 {age}{occupation}"
search_queries.append({
"insurance_type": ins_type,
"query": query,
})
return {"search_queries": search_queries}
'''
def _get_llm_prompt(self) -> list[dict]:
"""节点4LLM Prompt 模板。"""
return [
{
"role": "system",
"text": """你是一位专业的保险顾问,擅长根据客户需求生成保险产品推荐方案。
重要规则:
1. 保费数据必须来自检索结果,绝对不能编造
2. 推荐方案的总保费不能超过客户月预算 × 12
3. 每套方案必须包含:产品名称、保险公司、险种、保额、年保费、推荐理由
4. 最终输出必须包含免责声明"""
},
{
"role": "user",
"text": """请根据以下客户信息和检索到的保险产品资料,生成三套保险推荐方案。
## 客户信息
- 年龄:{{#start.age#}} 岁
- 性别:{{#start.gender#}}
- 职业:{{#start.occupation#}}
- 年收入:{{#start.annual_income#}} 万元
- 月预算:{{#start.monthly_budget#}} 元
- 需要险种:{{#start.insurance_types#}}
- 期望保额:{{#start.coverage_amount#}} 元
- 保障期限:{{#start.coverage_period#}}
## 检索到的保险产品资料
{{#knowledge_retrieval.result#}}
## 输出要求
请生成以下三套方案,使用 Markdown 表格格式:
### 基础方案(年保费约 XXXX 元)
| 产品名称 | 所属保险公司 | 险种 | 保额 | 年保费 | 推荐理由 |
|---------|------------|------|------|-------|---------|
| ... | ... | ... | ... | ... | ... |
方案总结:...
### 均衡方案(年保费约 XXXX 元)
(格式同上)
### 全面方案(年保费约 XXXX 元)
(格式同上)
## 免责声明
本推荐方案仅供参考,具体保障内容以保险合同为准。建议在投保前仔细阅读保险条款。"""
}
]
def _get_error_handler_code(self) -> str:
"""节点5异常处理代码。"""
return '''def main(is_valid: bool, error_msg: str, recommendation: str = "") -> dict:
"""处理异常情况。"""
if not is_valid:
return {
"final_output": f"参数校验失败:{error_msg}",
"is_success": False,
}
if not recommendation:
return {
"final_output": "未能生成推荐方案,请稍后重试",
"is_success": False,
}
return {
"final_output": recommendation,
"is_success": True,
}
'''
def _get_format_output_code(self) -> str:
"""节点6格式化输出代码。"""
return '''def main(recommendation: str, is_success: bool) -> dict:
"""格式化输出,去除多余空白。"""
if not is_success:
return {
"recommendation": recommendation,
"is_success": False,
}
# 去除多余空行(连续空行合并为一个)
import re
formatted = re.sub(r"\\n{3,}", "\\n\\n", recommendation)
# 去除首尾空白
formatted = formatted.strip()
return {
"recommendation": formatted,
"is_success": True,
}
'''
def sync_workflow(self) -> bool:
"""同步 Workflow 配置到 BaoDan。"""
print("[3/6] 同步 Workflow 配置...")
graph = self.build_workflow_graph()
# 获取 workflow ID
workflow_id = self.get_workflow_id()
if not workflow_id:
print(" ⚠️ 未找到 workflow尝试直接同步...")
resp = self.session.post(
f"{self.base_url}/console/api/apps/{self.app_id}/workflows/draft",
json={
"graph": graph,
"features": {},
"hash": "",
},
)
if resp.status_code != 200:
print(f" ❌ 同步失败: {resp.text}")
return False
print(" ✅ Workflow 配置同步成功")
return True
def get_api_key(self) -> str | None:
"""获取应用的 API Key。"""
print("[4/6] 获取 API Key...")
# 先尝试获取已有的 API Key
resp = self.session.get(
f"{self.base_url}/console/api/apps/{self.app_id}/api-keys"
)
if resp.status_code == 200:
keys = resp.json().get("data", [])
if keys:
api_key = keys[0].get("token")
print(f" ✅ API Key: {api_key}")
return api_key
# 创建新的 API Key
resp = self.session.post(
f"{self.base_url}/console/api/apps/{self.app_id}/api-keys",
json={"name": "推荐服务"},
)
if resp.status_code in (200, 201):
data = resp.json()
api_key = data.get("token")
if api_key:
print(f" ✅ 新建 API Key: {api_key}")
return api_key
# 可能返回的是完整对象
api_key = data.get("id") # 有些版本返回 id 作为 token
if api_key:
print(f" ✅ 新建 API Key: {api_key}")
return api_key
print(f" ❌ 创建 API Key 失败: {resp.text}")
return None
def publish_workflow(self) -> bool:
"""发布 Workflow。"""
print("[5/6] 发布 Workflow...")
# 先获取 draft workflow
draft_resp = self.session.get(
f"{self.base_url}/console/api/apps/{self.app_id}/workflows/draft"
)
if draft_resp.status_code == 200:
draft_data = draft_resp.json()
workflow_id = draft_data.get("id")
if workflow_id:
print(f" 📝 Draft Workflow ID: {workflow_id}")
# 发布
resp = self.session.post(
f"{self.base_url}/console/api/apps/{self.app_id}/workflows/publish",
json={},
)
if resp.status_code in (200, 201):
print(" ✅ Workflow 已发布")
return True
# 尝试不带 body 发布
resp = self.session.post(
f"{self.base_url}/console/api/apps/{self.app_id}/workflows/publish"
)
if resp.status_code in (200, 201):
print(" ✅ Workflow 已发布")
return True
print(f" ⚠️ 发布失败(可手动在后台发布): {resp.text}")
return False
def update_env_config(self, api_key: str) -> bool:
"""更新 .env 配置文件。"""
print("[6/6] 更新 .env 配置...")
env_content = f"""# BaoDan Workflow API Key自动生成于 {time.strftime('%Y-%m-%d %H:%M:%S')}
BAODAN_WORKFLOW_API_KEY={api_key}
BAODAN_API_URL={self.base_url}
"""
# 写入到 docker-compose 环境变量提示
print(f"""
✅ 请将以下配置添加到 docker-compose.dify.yml 的 baodan-api 环境变量:
BAODAN_WORKFLOW_API_KEY: "{api_key}"
或者创建 .env 文件:
{env_content}
""")
return True
def main():
"""主函数。"""
print("=" * 60)
print("BaoDan Workflow 自动化配置脚本")
print("=" * 60)
print()
setup = BaoDanWorkflowSetup(BAODAN_URL)
# 1. 登录
if not setup.login(ADMIN_EMAIL, ADMIN_PASSWORD):
print("\n❌ 登录失败,请检查:")
print(" 1. BaoDan 服务是否已启动")
print(" 2. 管理员账号密码是否正确")
sys.exit(1)
# 2. 创建 Workflow 应用
if not setup.create_workflow_app():
print("\n❌ 创建应用失败")
sys.exit(1)
# 3. 同步 Workflow 配置
if not setup.sync_workflow():
print("\n❌ 同步 Workflow 失败")
sys.exit(1)
# 4. 获取 API Key
api_key = setup.get_api_key()
if not api_key:
print("\n⚠️ 获取 API Key 失败,请手动在后台创建")
# 5. 发布 Workflow
setup.publish_workflow()
# 6. 更新配置
if api_key:
setup.update_env_config(api_key)
print("\n" + "=" * 60)
print("✅ Workflow 配置完成!")
print("=" * 60)
print(f"""
后续步骤:
1. 访问 http://localhost:3000/workflow/{setup.app_id} 查看 Workflow
2. 在「知识库检索」节点关联已创建的知识库
3. 在「模型配置」中确认 DeepSeek 模型已配置
4. 测试 Workflow点击「运行」按钮测试
5. 更新 docker-compose.dify.yml 中的 BAODAN_WORKFLOW_API_KEY
API 调用示例:
curl -X POST http://localhost:5001/v1/workflows/run \\
-H "Authorization: Bearer {api_key or 'YOUR_API_KEY'}" \\
-H "Content-Type: application/json" \\
-d '{{
"inputs": {{
"age": "35",
"gender": "male",
"occupation": "工程师",
"annual_income": "30",
"monthly_budget": "2000",
"insurance_types": "重疾险,医疗险",
"coverage_amount": "500000",
"coverage_period": "终身"
}},
"response_mode": "blocking",
"user": "test_user"
}}'
""")
if __name__ == "__main__":
main()