Skip to content

Commit 4345692

Browse files
committed
Merge branch 'feat/risk_ai_search' into stage/20260309
2 parents 35c2f2d + e52dd5f commit 4345692

File tree

22 files changed

+1171
-282
lines changed

22 files changed

+1171
-282
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,4 @@ bkcodeai.json
5858
AGENTS.md
5959
.claude
6060
*debug*
61+
.playwright-cli
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - 审计中心 (BlueKing - Audit Center) available.
5+
Copyright (C) 2023 THL A29 Limited,
6+
a Tencent company. All rights reserved.
7+
Licensed under the MIT License (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at http://opensource.org/licenses/MIT
10+
Unless required by applicable law or agreed to in writing,
11+
software distributed under the License is distributed on
12+
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the
14+
specific language governing permissions and limitations under the License.
15+
We undertake not to change the open source license (MIT license) applicable
16+
to the current version of the project delivered to anyone in the future.
17+
"""
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - 审计中心 (BlueKing - Audit Center) available.
5+
Copyright (C) 2023 THL A29 Limited,
6+
a Tencent company. All rights reserved.
7+
Licensed under the MIT License (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at http://opensource.org/licenses/MIT
10+
Unless required by applicable law or agreed to in writing,
11+
software distributed under the License is distributed on
12+
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the
14+
specific language governing permissions and limitations under the License.
15+
We undertake not to change the open source license (MIT license) applicable
16+
to the current version of the project delivered to anyone in the future.
17+
"""
18+
19+
import abc
20+
import json
21+
22+
from bk_resource import BkApiResource
23+
from bk_resource.exceptions import APIRequestError
24+
from bk_resource.utils.common_utils import is_backend
25+
from blueapps.utils.logger import logger
26+
from django.conf import settings
27+
from django.core.handlers.wsgi import WSGIRequest
28+
from django.utils.translation import gettext_lazy
29+
from requests.exceptions import HTTPError
30+
31+
from api.constants import AIAgentCode
32+
from api.utils import get_agent_base_url
33+
34+
35+
class AIAgentBase(BkApiResource, abc.ABC):
36+
"""AI 智能体通用 API 基类
37+
38+
与 AIAuditReport 共享相同的认证逻辑,但 URL 通过 get_agent_base_url 动态路由。
39+
不直接继承 AIAuditReport 以避免循环导入(bk_resource 按字母序扫描 api/ 目录)。
40+
"""
41+
42+
module_name = "bk_plugins_ai_agent"
43+
base_url = ""
44+
platform_authorization = True
45+
tags = ["AIAgent"]
46+
TIMEOUT = 300
47+
48+
@property
49+
def app_code(self) -> str:
50+
return settings.AI_AGENT_APP_CODE or settings.AI_AUDIT_REPORT_APP_CODE or settings.APP_CODE
51+
52+
@property
53+
def secret_key(self) -> str:
54+
return settings.AI_AGENT_SECRET_KEY or settings.AI_AUDIT_REPORT_SECRET_KEY or settings.SECRET_KEY
55+
56+
def add_esb_info_before_request(self, params: dict) -> dict:
57+
params["bk_app_code"] = self.app_code
58+
params["bk_app_secret"] = self.secret_key
59+
60+
if params.pop("_is_backend", False) or is_backend():
61+
params.pop("_request", None)
62+
params = self.add_platform_auth_params(params, force_platform_auth=True)
63+
return params
64+
65+
from blueapps.utils.request_provider import get_local_request
66+
67+
_request = params.pop("_request", None)
68+
req: WSGIRequest = _request or get_local_request()
69+
70+
auth_info = self.build_auth_args(req)
71+
params.update(auth_info)
72+
if req is not None:
73+
user = getattr(req, "user", None)
74+
if user:
75+
params["bk_username"] = getattr(user, "bk_username", None) or getattr(user, "username", None) or ""
76+
77+
params = self.add_platform_auth_params(params)
78+
return params
79+
80+
81+
class ChatCompletion(AIAgentBase):
82+
"""通用 AI Agent 对话接口,通过 agent_code 参数路由到不同智能体"""
83+
84+
name = gettext_lazy("通用智能体对话")
85+
method = "POST"
86+
action = "/bk_plugin/openapi/agent/chat_completion/"
87+
88+
def build_url(self, validated_request_data):
89+
agent_code = validated_request_data.pop("agent_code", None)
90+
if not agent_code:
91+
raise ValueError("agent_code is required for bk_plugins_ai_agent.ChatCompletion")
92+
if isinstance(agent_code, str):
93+
agent_code = AIAgentCode(agent_code)
94+
base_url = get_agent_base_url(agent_code)
95+
return base_url.rstrip("/") + "/" + self.action.lstrip("/")
96+
97+
def build_header(self, validated_request_data):
98+
headers = super().build_header(validated_request_data)
99+
user = validated_request_data.pop("user", None)
100+
if user:
101+
headers["X-BKAIDEV-USER"] = user
102+
return headers
103+
104+
def before_request(self, kwargs):
105+
request_data = kwargs.get("json") or kwargs.get("data") or {}
106+
if isinstance(request_data, dict):
107+
execute_kwargs = request_data.get("execute_kwargs") or {}
108+
if execute_kwargs.get("stream"):
109+
kwargs["stream"] = True
110+
return kwargs
111+
112+
def _is_stream_response(self, response) -> bool:
113+
content_type = (response.headers.get("Content-Type") or response.headers.get("content-type") or "").lower()
114+
if "text/event-stream" in content_type:
115+
return True
116+
try:
117+
body = getattr(response.request, "body", None)
118+
if not body:
119+
return False
120+
if isinstance(body, (bytes, bytearray)):
121+
body = body.decode("utf-8")
122+
if isinstance(body, str):
123+
body = json.loads(body)
124+
if not isinstance(body, dict):
125+
return False
126+
execute_kwargs = body.get("execute_kwargs") or {}
127+
return bool(execute_kwargs.get("stream"))
128+
except Exception:
129+
return False
130+
131+
def _parse_stream_response(self, response) -> str:
132+
# done event 仅用于日志记录(平台元数据),实际内容始终从 text event 拼接
133+
done_content = None
134+
text_content = ""
135+
for line in response.iter_lines(decode_unicode=True):
136+
if not line or not line.startswith("data:"):
137+
continue
138+
data = line[len("data:") :].strip()
139+
if data == "[DONE]":
140+
break
141+
try:
142+
event = json.loads(data)
143+
except json.JSONDecodeError:
144+
continue
145+
event_type = event.get("event")
146+
content = event.get("content", "")
147+
cover = event.get("cover", False)
148+
if event_type == "done":
149+
done_content = content
150+
elif event_type == "text":
151+
text_content = content if cover else text_content + content
152+
if done_content is not None:
153+
logger.debug("AI stream done content: %s", done_content)
154+
logger.debug("AI stream text content: %s", text_content)
155+
return text_content
156+
157+
def parse_response(self, response):
158+
if self._is_stream_response(response):
159+
try:
160+
response.raise_for_status()
161+
except HTTPError as err:
162+
try:
163+
result_json = response.json()
164+
except Exception:
165+
result_json = {}
166+
content = str(err.response.content)
167+
if isinstance(result_json, dict):
168+
content = "[{code}] {message}".format(
169+
code=result_json.get("code"),
170+
message=result_json.get("message"),
171+
)
172+
raise APIRequestError(
173+
module_name=self.module_name,
174+
url=self.action,
175+
status_code=response.status_code,
176+
result=content,
177+
)
178+
return self._parse_stream_response(response)
179+
180+
data = super().parse_response(response)
181+
if isinstance(data, dict):
182+
if "content" in data:
183+
return data["content"]
184+
choices = data.get("choices")
185+
if choices and isinstance(choices, list):
186+
first = choices[0]
187+
if isinstance(first, dict):
188+
delta = first.get("delta") or first.get("message") or {}
189+
if isinstance(delta, dict) and "content" in delta:
190+
return delta["content"]
191+
return data

0 commit comments

Comments
 (0)