union-app-chat-stream 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.gitignore +16 -0
  2. package/PROJECT_OVERVIEW.md +187 -0
  3. package/app/.env +63 -0
  4. package/app/.env.dev +63 -0
  5. package/app/.env.prod.bj11 +63 -0
  6. package/app/.env.prod.sh20 +63 -0
  7. package/app/.env.prod.sz31 +63 -0
  8. package/app/.env.test.bj12 +63 -0
  9. package/app/__init__.py +42 -0
  10. package/app/__pycache__/__init__.cpython-312.pyc +0 -0
  11. package/app/__pycache__/authenticated_user.cpython-312.pyc +0 -0
  12. package/app/__pycache__/extensions.cpython-312.pyc +0 -0
  13. package/app/__pycache__/wsgi.cpython-312.pyc +0 -0
  14. package/app/authenticated_user.py +77 -0
  15. package/app/config/__pycache__/config_loader.cpython-312.pyc +0 -0
  16. package/app/config/__pycache__/env_config.cpython-312.pyc +0 -0
  17. package/app/config/__pycache__/logger_config.cpython-312.pyc +0 -0
  18. package/app/config/env_config.py +96 -0
  19. package/app/config/logger_config.py +46 -0
  20. package/app/manager/__init__.py +4 -0
  21. package/app/manager/__pycache__/__init__.cpython-312.pyc +0 -0
  22. package/app/manager/__pycache__/chatstream_manager.cpython-312.pyc +0 -0
  23. package/app/manager/__pycache__/prompts.cpython-312.pyc +0 -0
  24. package/app/manager/__pycache__/runtime_manager.cpython-312.pyc +0 -0
  25. package/app/manager/__pycache__/toolcall_manager.cpython-312.pyc +0 -0
  26. package/app/manager/chatstream_manager.py +90 -0
  27. package/app/manager/prompts.py +62 -0
  28. package/app/manager/runtime_manager.py +552 -0
  29. package/app/models/__pycache__/schemas.cpython-312.pyc +0 -0
  30. package/app/models/schemas.py +30 -0
  31. package/app/service/__init__.py +4 -0
  32. package/app/service/__pycache__/__init__.cpython-312.pyc +0 -0
  33. package/app/service/__pycache__/chat_service.cpython-312.pyc +0 -0
  34. package/app/service/__pycache__/llm_service.cpython-312.pyc +0 -0
  35. package/app/service/__pycache__/rag_service.cpython-312.pyc +0 -0
  36. package/app/service/__pycache__/tool_call_service.cpython-312.pyc +0 -0
  37. package/app/service/__pycache__/union_service.cpython-312.pyc +0 -0
  38. package/app/service/chat_service.py +228 -0
  39. package/app/service/llm_service.py +214 -0
  40. package/app/service/rag_service.py +866 -0
  41. package/app/service/union_service.py +201 -0
  42. package/app/utils/__init__.py +5 -0
  43. package/app/utils/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/app/utils/__pycache__/common_utils.cpython-312.pyc +0 -0
  45. package/app/utils/__pycache__/debug_context.cpython-312.pyc +0 -0
  46. package/app/utils/__pycache__/function_utils.cpython-312.pyc +0 -0
  47. package/app/utils/__pycache__/jwt_utils.cpython-312.pyc +0 -0
  48. package/app/utils/common_utils.py +169 -0
  49. package/app/utils/debug_context.py +16 -0
  50. package/app/utils/function_utils.py +274 -0
  51. package/app/utils/jwt_utils.py +39 -0
  52. package/app/views/__init__.py +6 -0
  53. package/app/views/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/app/views/__pycache__/view_chatstream.cpython-312.pyc +0 -0
  55. package/app/views/__pycache__/view_healthcheck.cpython-312.pyc +0 -0
  56. package/app/views/__pycache__/view_runtime.cpython-312.pyc +0 -0
  57. package/app/views/view_chatstream.py +53 -0
  58. package/app/views/view_healthcheck.py +14 -0
  59. package/app/views/view_runtime.py +72 -0
  60. package/app/wsgi.py +37 -0
  61. package/ci.yml +14 -0
  62. package/deploy/autoconf/templates/env.j2 +25 -0
  63. package/deploy/autoconf.yml +15 -0
  64. package/deploy/scripts/healthcheck.sh +0 -0
  65. package/deploy/scripts/requirements.txt +53 -0
  66. package/deploy/scripts/start.sh +75 -0
  67. package/deploy/scripts/stop.sh +31 -0
  68. package/knowledge/.gitkeep +0 -0
  69. package/knowledge/000001-biz-offline-85b99bd43b-v1.md +88 -0
  70. package/knowledge/000002-biz-offline-717e8d823e-v1.md +90 -0
  71. package/knowledge/000003-biz-offline-c963227cc8-v1.md +84 -0
  72. package/knowledge/000004-biz-offline-2a5868e7da-v1.md +92 -0
  73. package/knowledge/000005-biz-offline-f9d9cf1a88-v1.md +79 -0
  74. package/knowledge/000006-biz-offline-c4fa2df3bd-v1.md +77 -0
  75. package/knowledge/000007-biz-offline-78304b70ca-v1.md +76 -0
  76. package/knowledge/000008-biz-offline-987ae67b35-v1.md +75 -0
  77. package/knowledge/000009-biz-offline-4d656bcea3-v1.md +85 -0
  78. package/knowledge/000010-sop-offline-a9e1050719-v1.md +100 -0
  79. package/knowledge/000011-biz-offline-5de0624891-v1.md +86 -0
  80. package/knowledge/000012-biz-offline-7dfacccba3-v1.md +82 -0
  81. package/knowledge/000013-biz-offline-5e1d29d2ed-v1.md +81 -0
  82. package/knowledge/000014-biz-offline-1d0ed8b841-v1.md +68 -0
  83. package/knowledge/000015-biz-offline-8a1376ee3e-v1.md +78 -0
  84. package/knowledge/000016-biz-offline-c8bfc2aa08-v1.md +99 -0
  85. package/knowledge/000017-biz-offline-9dffb28032-v1.md +88 -0
  86. package/knowledge/000018-biz-offline-f935bc9a6a-v1.md +80 -0
  87. package/knowledge/000019-biz-offline-858b3ecd89-v1.md +86 -0
  88. package/knowledge/000020-biz-offline-65cb5c4f40-v1.md +113 -0
  89. package/knowledge/000021-biz-offline-1bf211639c-v1.md +148 -0
  90. package/knowledge/000022-biz-offline-8c5a637879-v1.md +140 -0
  91. package/knowledge/000023-biz-offline-fe872b8712-v1.md +188 -0
  92. package/knowledge/000024-biz-offline-a85010c500-v1.md +133 -0
  93. package/knowledge/000025-biz-offline-8af58a3638-v1.md +136 -0
  94. package/knowledge/000026-biz-offline-6754102e93-v1.md +142 -0
  95. package/knowledge/000027-biz-offline-ea2e5ca5f9-v1.md +150 -0
  96. package/knowledge/000028-scenario-offline-dab45cebb4-v1.md +136 -0
  97. package/knowledge/000029-scenario-offline-5b8ae5ea9f-v1.md +143 -0
  98. package/knowledge/000030-scenario-offline-9a82d42f3f-v1.md +136 -0
  99. package/knowledge/000031-scenario-offline-cc2edc0197-v1.md +122 -0
  100. package/knowledge/000032-scenario-offline-e5f6e5cbfa-v1.md +122 -0
  101. package/knowledge/000033-scenario-offline-e1955849aa-v1.md +135 -0
  102. package/knowledge/000034-scenario-offline-3a13d49a3a-v1.md +138 -0
  103. package/knowledge/000035-scenario-offline-fd5560211f-v1.md +147 -0
  104. package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
  105. package/package.json +18 -0
  106. package/requirements.txt +53 -0
  107. package/tools/prompts.yaml +10 -0
  108. package/tools/tool_definitions.yaml +303 -0
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+ import os
3
+ from typing import Optional, Dict, Any, List, Tuple
4
+ from loguru import logger
5
+ from app.utils import common_utils
6
+
7
+
8
+
9
+
10
+ class UnionService:
11
+ """Union服务类,提供各类数据查询的通用接口"""
12
+
13
+ # 常量定义
14
+ API_MAX_RETRIES = 10 # API最大重试次数
15
+ BIGDATA_INTERFACE_FULL_LINK = "running_cnt.full_link_monthly"
16
+ BIGDATA_INTERFACE_BANK_MONTHLY = "running_cnt.bank_monthly"
17
+
18
+ def __init__(self):
19
+ """初始化服务实例"""
20
+ self._jira_url: Optional[str] = None
21
+ self._org_url: Optional[str] = None
22
+ self._bigdata_url: Optional[str] = None
23
+ self._union_base_url: Optional[str] = None
24
+ self._jira_url_token: Optional[str] = None
25
+ self._org_url_token: Optional[str] = None
26
+
27
+ def initialize(self, config) -> None:
28
+ """初始化服务配置"""
29
+ self._jira_url = config["GET_JIRA_INFO_URL"]
30
+ self._org_url = config["GET_ORG_INFO_URL"]
31
+ self._bigdata_url = config["GET_BIGDATA_URL"]
32
+ self._union_base_url = config.get("GET_UNION_BASE_URL")
33
+ self._jira_url_token = config["GET_JIRA_INFO_URL_TOKEN"]
34
+ self._org_url_token = config["GET_ORG_INFO_URL_TOKEN"]
35
+ logger.info("Union服务初始化完成")
36
+
37
+ def _get_jira_headers(self) -> Dict[str, str]:
38
+ """构建Jira API请求头"""
39
+ return {'token': str(self._jira_url_token)}
40
+
41
+ def _get_org_headers(self) -> Dict[str, str]:
42
+ """构建Org API请求头"""
43
+ return {'token': str(self._org_url_token)}
44
+
45
+ def _get_bigdata_headers(self, jsessionid: str) -> Dict[str, str]:
46
+ """构建BigData API请求头"""
47
+ return {'Cookie': f'CASSESSIONID={jsessionid}'}
48
+
49
+ def _get_union_headers(self, jsessionid: str) -> Dict[str, str]:
50
+ """构建联合运维 API 请求头"""
51
+ return {'Cookie': f'CASSESSIONID={jsessionid}'}
52
+
53
+ @staticmethod
54
+ def _build_union_url(base_url: Optional[str], path: str) -> Optional[str]:
55
+ if not base_url:
56
+ return None
57
+ normalized_base = base_url.rstrip("/") + "/"
58
+ normalized_path = str(path or "").lstrip("/")
59
+ return f"{normalized_base}{normalized_path}"
60
+
61
+ @staticmethod
62
+ def _extract_response_data(response: Dict[str, Any]) -> Any:
63
+ data = response.get("data") if isinstance(response, dict) else None
64
+ if isinstance(data, dict) and "data" in data:
65
+ return data["data"]
66
+ return data
67
+
68
+ def query_union(
69
+ self,
70
+ payload: Dict[str, Any],
71
+ jsessionid: str,
72
+ description: str = "查询联合运维数据",
73
+ path: str = "",
74
+ ) -> Tuple[Optional[Any], str]:
75
+ """按 tool definition 中配置的 path 和 JSON 参数查询联合运维 API。"""
76
+ url = self._build_union_url(self._union_base_url, path)
77
+ if not url:
78
+ return None, f"{description}失败: 接口URL未配置"
79
+ if not jsessionid:
80
+ return None, f"{description}失败: 登录态ID为空"
81
+
82
+ try:
83
+ response = common_utils.call_https_api(
84
+ url=url,
85
+ headers=self._get_union_headers(jsessionid),
86
+ json_data=payload,
87
+ max_retries=self.API_MAX_RETRIES,
88
+ )
89
+ logger.info(f"{description}成功")
90
+ return self._extract_response_data(response), "success"
91
+ except Exception as e:
92
+ logger.error(f"{description}失败: {e}")
93
+ return None, str(e)
94
+
95
+ def query_jirainfo(self, filtered_time_arrays: List[str]) -> Optional[List[Dict[str, Any]]]:
96
+ """
97
+ 查询Jira故障信息
98
+
99
+ Args:
100
+ filtered_time_arrays: 过滤后的时间数组
101
+
102
+ Returns:
103
+ Jira故障信息列表,失败返回None
104
+ """
105
+ try:
106
+ response = common_utils.call_https_api(
107
+ url=self._jira_url,
108
+ headers=self._get_jira_headers(),
109
+ json_data=filtered_time_arrays
110
+ )
111
+ return response['data']['data']
112
+ except Exception as e:
113
+ logger.error(f"jirainfo调用失败: {e.args}")
114
+ return None
115
+
116
+ def query_orginfo(self, org_codes: List[str]) -> Optional[Dict[str, str]]:
117
+ """
118
+ 查询机构信息
119
+
120
+ Args:
121
+ org_codes: 机构代码列表
122
+
123
+ Returns:
124
+ 机构代码到名称的映射字典,失败返回None
125
+ """
126
+ try:
127
+ response = common_utils.call_https_api(
128
+ url=self._org_url,
129
+ headers=self._get_org_headers(),
130
+ json_data=org_codes
131
+ )
132
+ return {item['orgCode']: item['orgName'] for item in response['data']['data']}
133
+ except Exception as e:
134
+ logger.error(f"orginfo调用失败: {e.args}")
135
+ return None
136
+
137
+ def query_bigdata(
138
+ self,
139
+ interface_name: str,
140
+ params: Dict[str, Any],
141
+ jsessionid: str,
142
+ description: str = "大数据查询"
143
+ ) -> Tuple[Optional[List[Dict[str, Any]]], str]:
144
+ """
145
+ 通用大数据查询方法
146
+
147
+ Args:
148
+ interface_name: 接口名称
149
+ params: 请求参数字典
150
+ jsessionid: 登录态ID,用于外部接口鉴权
151
+ description: 查询描述(用于日志)
152
+
153
+ Returns:
154
+ (数据列表, 消息)元组
155
+ """
156
+ logger.info(f"{description}: {params}")
157
+
158
+ data = {
159
+ "interfaceName": interface_name,
160
+ "params": str(params)
161
+ }
162
+
163
+ message = "success"
164
+ filtered_data = None
165
+
166
+ try:
167
+ response = common_utils.call_https_api(
168
+ url=self._bigdata_url,
169
+ headers=self._get_bigdata_headers(jsessionid),
170
+ data=data,
171
+ max_retries=self.API_MAX_RETRIES
172
+ )
173
+ filtered_data = response['data']['data']
174
+ logger.info(f"{description}成功,共 {len(filtered_data)} 条记录")
175
+
176
+ except Exception as e:
177
+ message = str(e)
178
+ logger.error(f"{description}失败: {e}")
179
+
180
+ return filtered_data, message
181
+
182
+ def parse_time_array(self, time_array_str: str) -> List[int]:
183
+ """
184
+ 解析时间字符串数组为整数数组
185
+
186
+ Args:
187
+ time_array_str: 逗号分隔的时间字符串(格式:yyyy-MM)
188
+
189
+ Returns:
190
+ 整数时间数组(格式:yyyyMM)
191
+ """
192
+ try:
193
+ time_array = time_array_str.split(",")
194
+ return [common_utils.parse_month(item_time) for item_time in time_array]
195
+ except Exception as e:
196
+ logger.error(f"解析时间数组失败: {e}")
197
+ return []
198
+
199
+
200
+ # 全局单例实例
201
+ union_service = UnionService()
@@ -0,0 +1,5 @@
1
+ from . import common_utils
2
+ from . import jwt_utils
3
+ from . import debug_context
4
+
5
+ __all__ = ["common_utils", "jwt_utils", "debug_context"]
@@ -0,0 +1,169 @@
1
+ import requests
2
+ from typing import Optional, Dict, Any
3
+ from requests.exceptions import RequestException, Timeout, ConnectionError
4
+ import json
5
+ import time
6
+ from loguru import logger
7
+ from flask import request as flask_request
8
+ import calendar
9
+ import os.path
10
+ from datetime import datetime
11
+ import re
12
+ import curlify
13
+
14
+ def get_client_ip():
15
+ x_forwarded = flask_request.headers.get('X-Forwarded-For')
16
+ if x_forwarded:
17
+ ip = x_forwarded.split(',')[0].strip()
18
+ return ip
19
+ x_real_ip = flask_request.headers.get('X_Real_Ip')
20
+ if x_real_ip:
21
+ return x_real_ip.strip()
22
+ remote = flask_request.remote_addr
23
+ return remote if remote else 'no_remote_ip'
24
+
25
+
26
+ def call_https_api(
27
+ url: str,
28
+ method: str = 'POST',
29
+ params: Optional[Dict[str, Any]] = None,
30
+ data: Optional[Dict[str, Any]] = None,
31
+ json_data: Optional[Dict[str, Any]] = None,
32
+ headers: Optional[Dict[str, str]] = None,
33
+ timeout: int = 30,
34
+ verify_ssl: bool = False,
35
+ auth: Optional[tuple] = None,
36
+ proxies: Optional[Dict[str, Any]] = None,
37
+ max_retries: int = 0,
38
+ retry_delay: float = 1.0
39
+
40
+ ) -> Dict[str, Any]:
41
+ default_headers = {
42
+ 'User-Agent': 'Python-HTTPS-Client/1.0'
43
+ }
44
+ if headers:
45
+ default_headers.update(headers)
46
+ requests_kwargs = {
47
+ 'url': url,
48
+ 'method': method.upper(),
49
+ 'params': params,
50
+ 'headers': default_headers,
51
+ 'timeout': timeout,
52
+ 'verify': verify_ssl
53
+ }
54
+ if method:
55
+ requests_kwargs['method'] = method
56
+ if data:
57
+ requests_kwargs['data'] = data
58
+ if json_data:
59
+ requests_kwargs['json'] = json_data
60
+ if auth:
61
+ requests_kwargs['auth'] = auth
62
+ if proxies:
63
+ requests_kwargs['proxies'] = proxies
64
+ for attempt in range(max_retries + 1):
65
+ try:
66
+ logger.info(f"请求{url},第{attempt + 1}次尝试,入参是:{data}|{json_data},method:{method}")
67
+ response = requests.request(**requests_kwargs)
68
+ try:
69
+ logger.info(f"请求curl命令:{curlify.to_curl(response.request)}")
70
+ except Exception as e:
71
+ logger.error(f"curlify报错:{e}")
72
+ try:
73
+ json_response = response.json()
74
+ except json.JSONDecodeError:
75
+ json_response = {'raw_response': response.text}
76
+ result = {
77
+ 'success': True,
78
+ 'status_code': response.status_code,
79
+ 'data': json_response
80
+ }
81
+ logger.info(f"请求成功:{response.status_code},返回值:{response.text}")
82
+ return result
83
+ except ConnectionError as e:
84
+ logger.error(f"连接失败:{e}")
85
+ except Timeout as e:
86
+ logger.error(f"请求超时:{e}")
87
+ except RequestException as e:
88
+ logger.error(f"请求异常:{e}")
89
+ except Exception as e:
90
+ logger.error(f"未知错误:{e}")
91
+ if attempt < max_retries:
92
+ time.sleep(retry_delay)
93
+ else:
94
+ break
95
+ return {
96
+ 'success': False,
97
+ 'status_code': response.status_code,
98
+ 'error_msg': '请求失败'
99
+ }
100
+
101
+
102
+ DATA_DIR = '/data/appLogs/'
103
+
104
+
105
+ def read_data_from_txt(file_path):
106
+ full_path = os.path.join(DATA_DIR, file_path)
107
+ if not os.path.relpath(full_path).startswith(os.path.relpath(DATA_DIR)):
108
+ raise ValueError("非法文件路径,禁止访问")
109
+ if not os.path.exists(full_path):
110
+ raise FileNotFoundError(f"数据文件{file_path}不存在")
111
+ with open(full_path, 'r', encoding='utf-8') as f:
112
+ content = f.read().strip()
113
+ if not content:
114
+ return []
115
+ try:
116
+ data = json.loads(content)
117
+ if not isinstance(data, list):
118
+ raise ValueError("JSON 数据必须是一个数组 list")
119
+ return data
120
+ except json.JSONDecodeError as e:
121
+ logger.error(f"JSON 解析失败{str(e)}")
122
+ raise ValueError(f"文件内容不是合法的JSON格式:{str(e)}")
123
+
124
+
125
+ def parse_month(month_str):
126
+ if not month_str or not isinstance(month_str, str):
127
+ raise ValueError("时间参数不能为空")
128
+ parts = month_str.strip().split('-')
129
+ if len(parts) == 1:
130
+ year_str = parts[0]
131
+ month_str = "1"
132
+ elif len(parts) == 2:
133
+ year_str, month_str = parts
134
+ elif len(parts) == 3:
135
+ year_str, month_str, day_str = parts
136
+ else:
137
+ raise ValueError(f"无效月份格式:{month_str},应为yyyy-mm")
138
+ try:
139
+ year = int(year_str)
140
+ month = int(month_str)
141
+ except ValueError:
142
+ raise ValueError(f"年份或月份必须是数字:{month_str}")
143
+ if not (1 <= month <= 12):
144
+ raise ValueError(f"月份必须是数字1-12之间:{month_str}")
145
+ month_padded = f"{month:02d}"
146
+ return int(f"{year}{month_padded}")
147
+
148
+
149
+ def get_days_in_month(yyyymm):
150
+ year = int(yyyymm[:4])
151
+ month = int(yyyymm[4:6])
152
+ _, num_days = calendar.monthrange(year, month)
153
+ return num_days
154
+
155
+
156
+ # current_time_format_by('%Y-%m-%d')
157
+ def current_time_format_by(format_str):
158
+ current_time = datetime.now().strftime(format_str)
159
+ return current_time
160
+
161
+
162
+ def remove_think_tag(llm_response):
163
+ # 匹配并移除 <think> 标签及其内容
164
+ result = re.sub(r'.*?</think>', '', llm_response, flags=re.DOTALL)
165
+ result = re.sub(r'^\s*```(?:json)?\s*', '', result, flags=re.IGNORECASE)
166
+ result = re.sub(r'\s*```\s*$', '', result)
167
+ # 去除多余的空白字符
168
+ result = re.sub(r'\s+', ' ', result).strip()
169
+ return result
@@ -0,0 +1,16 @@
1
+ import traceback
2
+ from flask import request
3
+
4
+
5
+ def check_request_context():
6
+ try:
7
+ _ = request.method
8
+ return True
9
+ except RuntimeError as e:
10
+ print(f"runtime error:{e}")
11
+ traceback.print_stack()
12
+ return False
13
+ except Exception as e:
14
+ print(f"exception:{e}")
15
+ traceback.print_stack()
16
+ return False
@@ -0,0 +1,274 @@
1
+ """Tool registry and dispatcher for model function calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+ import yaml
14
+
15
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
16
+ DEFAULT_TOOL_DEFINITIONS_PATH = PROJECT_ROOT / "tools" / "tool_definitions.yaml"
17
+
18
+
19
+ @dataclass
20
+ class ToolContext:
21
+ union_service: Optional[Any] = None
22
+ rag_service: Optional[Any] = None
23
+ jsessionid: str = ""
24
+
25
+
26
+ ToolFunction = Callable[[Dict[str, Any], ToolContext], Dict[str, Any]]
27
+
28
+ ALLOWED_BACKEND_METHODS = {
29
+ "union_service": {"query_union"},
30
+ "rag_service": {"knowledge_search"},
31
+ }
32
+
33
+
34
+ class _MissingOptional:
35
+ pass
36
+
37
+
38
+ MISSING_OPTIONAL = _MissingOptional()
39
+
40
+
41
+ def _load_yaml(path: Path) -> Dict[str, Any]:
42
+ if not path.exists():
43
+ return {}
44
+ with path.open("r", encoding="utf-8") as f:
45
+ data = yaml.safe_load(f) or {}
46
+ return data if isinstance(data, dict) else {}
47
+
48
+
49
+ @lru_cache(maxsize=1)
50
+ def _tool_definition_config() -> Dict[str, Any]:
51
+ path = Path(os.environ.get("TOOL_DEFINITIONS_PATH", DEFAULT_TOOL_DEFINITIONS_PATH))
52
+ return _load_yaml(path)
53
+
54
+
55
+ def reload_tool_configs():
56
+ _tool_definition_config.cache_clear()
57
+
58
+
59
+ def _raw_tool_definitions() -> List[Dict[str, Any]]:
60
+ tools_config = _tool_definition_config().get("tools", [])
61
+ if not isinstance(tools_config, list):
62
+ return []
63
+ return [
64
+ item
65
+ for item in tools_config
66
+ if isinstance(item, dict) and str(item.get("name", "")).strip()
67
+ ]
68
+
69
+
70
+ def _schema_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
71
+ properties = schema.get("properties")
72
+ return properties if isinstance(properties, dict) else {}
73
+
74
+
75
+ def _output_schema_summary(schema: Dict[str, Any]) -> str:
76
+ if not schema:
77
+ return ""
78
+
79
+ lines = []
80
+ schema_type = schema.get("type")
81
+ description = str(schema.get("description") or "").strip()
82
+ if schema_type or description:
83
+ header = f"返回结果结构: {schema_type or 'object'}"
84
+ if description:
85
+ header = f"{header},{description}"
86
+ lines.append(header)
87
+
88
+ properties = _schema_properties(schema)
89
+ if schema.get("type") == "array" and isinstance(schema.get("items"), dict):
90
+ properties = _schema_properties(schema["items"])
91
+
92
+ for name, prop in properties.items():
93
+ if not isinstance(prop, dict):
94
+ continue
95
+ prop_type = str(prop.get("type") or "any")
96
+ prop_desc = str(prop.get("description") or "").strip()
97
+ lines.append(f"- {name} ({prop_type}): {prop_desc}")
98
+
99
+ return "\n".join(lines)
100
+
101
+
102
+ def _to_model_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
103
+ input_schema = spec.get("input_schema") if isinstance(spec.get("input_schema"), dict) else {}
104
+ output_schema = spec.get("output_schema") if isinstance(spec.get("output_schema"), dict) else {}
105
+ description_parts = [
106
+ str(spec.get("description") or "").strip(),
107
+ str(spec.get("trigger") or "").strip(),
108
+ _output_schema_summary(output_schema),
109
+ ]
110
+ description = "\n".join(part for part in description_parts if part)
111
+ parameters = {
112
+ "type": "object",
113
+ "properties": _schema_properties(input_schema),
114
+ }
115
+ if isinstance(input_schema.get("required"), list):
116
+ parameters["required"] = input_schema["required"]
117
+
118
+ return {
119
+ "type": "function",
120
+ "function": {
121
+ "name": spec["name"],
122
+ "description": description or str(spec.get("display_name") or spec["name"]),
123
+ "parameters": parameters,
124
+ },
125
+ }
126
+
127
+
128
+ def _parse_arguments(arguments: str) -> Dict[str, Any]:
129
+ if not arguments:
130
+ return {}
131
+ parsed = json.loads(arguments)
132
+ return parsed if isinstance(parsed, dict) else {}
133
+
134
+
135
+ def _validate_required_args(spec: Dict[str, Any], args: Dict[str, Any]) -> None:
136
+ input_schema = spec.get("input_schema") if isinstance(spec.get("input_schema"), dict) else {}
137
+ required = input_schema.get("required")
138
+ if not isinstance(required, list):
139
+ return
140
+ missing = [name for name in required if args.get(name) in (None, "")]
141
+ if missing:
142
+ raise ValueError(f"缺少必填工具参数: {', '.join(str(name) for name in missing)}")
143
+
144
+
145
+ def _render_value(value: Any, args: Dict[str, Any], *, allow_missing: bool = False) -> Any:
146
+ if isinstance(value, str):
147
+ placeholder_match = re.fullmatch(r"\{([A-Za-z_][A-Za-z0-9_]*)}", value)
148
+ if placeholder_match:
149
+ key = placeholder_match.group(1)
150
+ if key in args:
151
+ return args[key]
152
+ if allow_missing:
153
+ return MISSING_OPTIONAL
154
+ raise ValueError(f"缺少工具参数: {key}")
155
+ try:
156
+ return value.format(**args)
157
+ except KeyError as exc:
158
+ if allow_missing:
159
+ return MISSING_OPTIONAL
160
+ missing = exc.args[0]
161
+ raise ValueError(f"缺少工具参数: {missing}") from exc
162
+ if isinstance(value, dict):
163
+ rendered = {}
164
+ for key, item in value.items():
165
+ rendered_item = _render_value(item, args, allow_missing=allow_missing)
166
+ if rendered_item is MISSING_OPTIONAL:
167
+ continue
168
+ rendered[key] = rendered_item
169
+ return rendered
170
+ if isinstance(value, list):
171
+ rendered_items = []
172
+ for item in value:
173
+ rendered_item = _render_value(item, args, allow_missing=allow_missing)
174
+ if rendered_item is not MISSING_OPTIONAL:
175
+ rendered_items.append(rendered_item)
176
+ return rendered_items
177
+ return value
178
+
179
+
180
+ def _base_result(spec: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]:
181
+ result = {
182
+ "tool_name": spec["name"],
183
+ "display_name": spec.get("display_name", spec["name"]),
184
+ "arguments": args,
185
+ }
186
+ output_schema = spec.get("output_schema")
187
+ if isinstance(output_schema, dict):
188
+ result["output_schema"] = output_schema
189
+ return result
190
+
191
+
192
+ def _run_backend_tool(
193
+ spec: Dict[str, Any],
194
+ args: Dict[str, Any],
195
+ context: ToolContext,
196
+ ) -> Dict[str, Any]:
197
+ _validate_required_args(spec, args)
198
+ backend = spec.get("backend") if isinstance(spec.get("backend"), dict) else {}
199
+ service_name = str(backend.get("service") or "").strip()
200
+ method_name = str(backend.get("method") or "").strip()
201
+
202
+ allowed_methods = ALLOWED_BACKEND_METHODS.get(service_name, set())
203
+ if method_name not in allowed_methods:
204
+ raise ValueError(f"工具 {spec['name']} 后端方法未在白名单中: {service_name}.{method_name}")
205
+ raw_params = backend.get("params") if isinstance(backend.get("params"), dict) else {}
206
+ payload = _render_value(raw_params, args, allow_missing=True)
207
+ raw_path = backend.get("path")
208
+ path = _render_value(raw_path, args, allow_missing=True) if raw_path is not None else ""
209
+ if path is MISSING_OPTIONAL:
210
+ path = ""
211
+ description = str(backend.get("description") or spec.get("display_name") or spec["name"])
212
+ if service_name == "union_service":
213
+ if context.union_service is None:
214
+ raise ValueError("Union服务未初始化")
215
+ method = getattr(context.union_service, method_name)
216
+ data, message = method(payload, context.jsessionid, description=description, path=path)
217
+ elif service_name == "rag_service":
218
+ if context.rag_service is None:
219
+ raise ValueError("知识库检索服务未初始化")
220
+ method = getattr(context.rag_service, method_name)
221
+ data, message = method(payload.get("query", ""), top_k=payload.get("topK"))
222
+ else:
223
+ raise ValueError(f"工具 {spec['name']} 后端服务未支持: {service_name}")
224
+
225
+ result = _base_result(spec, args)
226
+ result.update({
227
+ "status": "success" if message == "success" else "api_failed",
228
+ "backend": {
229
+ "service": service_name,
230
+ "method": method_name,
231
+ "description": description,
232
+ },
233
+ "payload": payload,
234
+ "data": data,
235
+ "message": message,
236
+ })
237
+ if path:
238
+ result["backend"]["path"] = path
239
+ return result
240
+
241
+
242
+ def _make_tool_function(spec: Dict[str, Any]) -> ToolFunction:
243
+ if isinstance(spec.get("backend"), dict):
244
+ return lambda args, context: _run_backend_tool(spec, args, context)
245
+ raise ValueError(f"工具 {spec.get('name')} 已定义但没有可执行实现")
246
+
247
+
248
+ TOOL_DEFINITIONS = {
249
+ str(item.get("name", "")).strip(): item
250
+ for item in _raw_tool_definitions()
251
+ }
252
+ FUNCTION_MAP: Dict[str, ToolFunction] = {
253
+ name: _make_tool_function(spec)
254
+ for name, spec in TOOL_DEFINITIONS.items()
255
+ }
256
+ tools = [_to_model_tool(spec) for name, spec in TOOL_DEFINITIONS.items() if name in FUNCTION_MAP]
257
+
258
+
259
+ def call_function(name: str, arguments: str, context: Optional[ToolContext] = None) -> str:
260
+ tool_function = FUNCTION_MAP.get(name)
261
+ if tool_function is None:
262
+ return json.dumps({"error": f"未知函数: {name}"}, ensure_ascii=False)
263
+
264
+ try:
265
+ args = _parse_arguments(arguments)
266
+ result = tool_function(args, context or ToolContext())
267
+ return json.dumps(result, ensure_ascii=False)
268
+ except json.JSONDecodeError as exc:
269
+ return json.dumps(
270
+ {"error": f"工具参数不是合法JSON: {str(exc)}", "raw_arguments": arguments},
271
+ ensure_ascii=False,
272
+ )
273
+ except Exception as exc:
274
+ return json.dumps({"error": f"函数执行异常: {str(exc)}"}, ensure_ascii=False)