@bangdao-ai/zentao-mcp 2.0.4 → 2.0.6
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.
- package/main.py +14 -5
- package/package.json +1 -1
- package/src/core/zentao_nexus.py +31 -8
- package/src/zenbot/bot_service.py +147 -24
- package/src/zenbot/config.py +16 -1
- package/src/zenbot/dingtalk_handler.py +47 -12
- package/src/zenbot/intent_parser.py +121 -5
- package/src/zenbot/llm_client.py +45 -14
- package/src/zenbot/logging_utils.py +64 -0
package/main.py
CHANGED
|
@@ -14,15 +14,24 @@ from dingtalk_stream import DingTalkStreamClient, Credential, ChatbotMessage
|
|
|
14
14
|
|
|
15
15
|
from zenbot.config import JsonFormatter
|
|
16
16
|
from zenbot.dingtalk_handler import ZentaoBotHandler
|
|
17
|
+
from zenbot.logging_utils import struct_log
|
|
17
18
|
|
|
18
19
|
load_dotenv()
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
def _setup_structured_logging() -> None:
|
|
22
|
+
fmt = JsonFormatter()
|
|
23
|
+
h = logging.StreamHandler(sys.stderr)
|
|
24
|
+
h.setFormatter(fmt)
|
|
25
|
+
for name in ("zentaoagent", "zentao_audit", "zentao_security"):
|
|
26
|
+
lg = logging.getLogger(name)
|
|
27
|
+
lg.handlers.clear()
|
|
28
|
+
lg.propagate = False
|
|
29
|
+
lg.addHandler(h)
|
|
30
|
+
lg.setLevel(logging.INFO)
|
|
22
31
|
|
|
32
|
+
|
|
33
|
+
_setup_structured_logging()
|
|
23
34
|
logger = logging.getLogger("zentaoagent")
|
|
24
|
-
logger.addHandler(_stderr_handler)
|
|
25
|
-
logger.setLevel(logging.INFO)
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
def main():
|
|
@@ -36,7 +45,7 @@ def main():
|
|
|
36
45
|
bot_handler = ZentaoBotHandler()
|
|
37
46
|
client = DingTalkStreamClient(credential, logger)
|
|
38
47
|
client.register_callback_handler(ChatbotMessage.TOPIC, bot_handler)
|
|
39
|
-
logger.
|
|
48
|
+
struct_log(logger, logging.INFO, "zenbot_startup", component="main", version="v2")
|
|
40
49
|
client.start_forever()
|
|
41
50
|
|
|
42
51
|
|
package/package.json
CHANGED
package/src/core/zentao_nexus.py
CHANGED
|
@@ -15,6 +15,12 @@ import csv
|
|
|
15
15
|
import shutil
|
|
16
16
|
import requests
|
|
17
17
|
|
|
18
|
+
try:
|
|
19
|
+
from zenbot.logging_utils import struct_log as _struct_log
|
|
20
|
+
except ImportError:
|
|
21
|
+
def _struct_log(logger, level, event, msg=None, component=None, **ctx):
|
|
22
|
+
logger.log(level, f"{event} {ctx}")
|
|
23
|
+
|
|
18
24
|
logger = logging.getLogger("zentaoagent")
|
|
19
25
|
_security_logger = logging.getLogger("zentao_security")
|
|
20
26
|
|
|
@@ -128,7 +134,12 @@ class ZenTaoNativeClient:
|
|
|
128
134
|
safe_form = {k: (v if k not in ('password',) else '***') for k, v in form_data.items()}
|
|
129
135
|
else:
|
|
130
136
|
safe_form = None
|
|
131
|
-
|
|
137
|
+
_struct_log(
|
|
138
|
+
logger, logging.INFO, "zentao_api_request",
|
|
139
|
+
component="zentao_api",
|
|
140
|
+
m=module, f=action, url_params=safe_params,
|
|
141
|
+
form_preview=safe_form,
|
|
142
|
+
)
|
|
132
143
|
|
|
133
144
|
if not self.silent:
|
|
134
145
|
self._log_request(url_params, form_data, files)
|
|
@@ -149,11 +160,22 @@ class ZenTaoNativeClient:
|
|
|
149
160
|
resp_summary = json.dumps(result, ensure_ascii=False)
|
|
150
161
|
if len(resp_summary) > 1000:
|
|
151
162
|
resp_summary = resp_summary[:1000] + f"...(truncated, total {len(resp_summary)} chars)"
|
|
152
|
-
|
|
163
|
+
st = result.get("status")
|
|
164
|
+
info = result.get("info") or result.get("message") or ""
|
|
165
|
+
_struct_log(
|
|
166
|
+
logger, logging.INFO, "zentao_api_response",
|
|
167
|
+
component="zentao_api",
|
|
168
|
+
m=module, f=action, http_status=resp.status_code,
|
|
169
|
+
api_status=st, info_preview=str(info)[:400],
|
|
170
|
+
body_truncated=len(resp_summary) > 1000,
|
|
171
|
+
)
|
|
153
172
|
return result
|
|
154
173
|
|
|
155
174
|
except requests.exceptions.RequestException as e:
|
|
156
|
-
|
|
175
|
+
_struct_log(
|
|
176
|
+
logger, logging.ERROR, "zentao_api_transport_error",
|
|
177
|
+
component="zentao_api", m=module, f=action, error=str(e),
|
|
178
|
+
)
|
|
157
179
|
return {"status": 0, "message": "fail", "info": f"请求失败: {e}", "data": {}}
|
|
158
180
|
|
|
159
181
|
def _prepare_form_data(self, form_data: Optional[Dict]) -> Optional[list]:
|
|
@@ -792,7 +814,7 @@ class ZenTaoNativeClient:
|
|
|
792
814
|
优先使用 allneedlist/uploadbyapi(token 认证兼容),
|
|
793
815
|
回退到 file/upload(需要 web session 权限,部分环境 401)。
|
|
794
816
|
"""
|
|
795
|
-
file_path = _safe_path(file_path
|
|
817
|
+
file_path = _safe_path(file_path)
|
|
796
818
|
if not os.path.exists(file_path):
|
|
797
819
|
return {"status": 0, "message": "fail",
|
|
798
820
|
"info": f"文件不存在: {file_path}", "data": {}}
|
|
@@ -1461,7 +1483,7 @@ class ZenTaoNexus:
|
|
|
1461
1483
|
return fieldnames, rows, case_type
|
|
1462
1484
|
|
|
1463
1485
|
def create_case_from_csv(self, csv_file_path, case_type=None) -> list:
|
|
1464
|
-
csv_file_path = _safe_path(csv_file_path
|
|
1486
|
+
csv_file_path = _safe_path(csv_file_path)
|
|
1465
1487
|
if not os.path.exists(csv_file_path):
|
|
1466
1488
|
return [{"status": 0, "message": f"CSV文件不存在: {csv_file_path}"}]
|
|
1467
1489
|
|
|
@@ -1534,12 +1556,13 @@ class ZenTaoNexus:
|
|
|
1534
1556
|
if not batch_cases:
|
|
1535
1557
|
return [{"status": 0, "message": "CSV 中无有效用例行"}]
|
|
1536
1558
|
|
|
1537
|
-
|
|
1559
|
+
pid = self.resolve_product_id()
|
|
1560
|
+
result = self.client.testcase_import_batch(pid, batch_cases)
|
|
1538
1561
|
ok = result.get('status') == 1
|
|
1539
1562
|
|
|
1540
1563
|
if ok:
|
|
1541
1564
|
latest = self.client.testcase_browse(
|
|
1542
|
-
|
|
1565
|
+
pid, orderBy='id_desc',
|
|
1543
1566
|
recPerPage=str(len(batch_cases) + 5))
|
|
1544
1567
|
latest_cases = latest.get("data", {}).get("cases", [])
|
|
1545
1568
|
if isinstance(latest_cases, dict):
|
|
@@ -1581,7 +1604,7 @@ class ZenTaoNexus:
|
|
|
1581
1604
|
return results
|
|
1582
1605
|
|
|
1583
1606
|
def submit_csv_cases_to_zentao(self, csv_file_path, case_type=None) -> Dict:
|
|
1584
|
-
csv_file_path = _safe_path(csv_file_path
|
|
1607
|
+
csv_file_path = _safe_path(csv_file_path)
|
|
1585
1608
|
if not os.path.exists(csv_file_path):
|
|
1586
1609
|
return {"status": "error", "message": f"CSV文件不存在: {csv_file_path}",
|
|
1587
1610
|
"total_cases": 0, "success_count": 0, "failed_count": 0, "success_rate": 0.0}
|
|
@@ -4,10 +4,11 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
6
|
from .config import build_user_context
|
|
7
|
+
from .logging_utils import struct_log
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger("zentaoagent")
|
|
9
10
|
audit_logger = logging.getLogger("zentao_audit")
|
|
10
|
-
from .intent_parser import parse_intent, IntentResult
|
|
11
|
+
from .intent_parser import parse_intent, fix_params_on_api_error, IntentResult
|
|
11
12
|
from .session_manager import (
|
|
12
13
|
get_session, create_session, update_session, clear_session, is_cancel_keyword,
|
|
13
14
|
get_last_bug_ids, set_last_action, get_last_action,
|
|
@@ -57,7 +58,10 @@ def dispatch(
|
|
|
57
58
|
) -> str:
|
|
58
59
|
user_context = build_user_context(staff_id, staff_nick, session_webhook, conversation_id, conversation_type)
|
|
59
60
|
if not user_context:
|
|
60
|
-
|
|
61
|
+
struct_log(
|
|
62
|
+
logger, logging.WARNING, "user_unbound",
|
|
63
|
+
component="bot_service", staff_id=staff_id, staff_nick=staff_nick,
|
|
64
|
+
)
|
|
61
65
|
return format_unbound_user()
|
|
62
66
|
|
|
63
67
|
if is_cancel_keyword(content):
|
|
@@ -71,7 +75,10 @@ def dispatch(
|
|
|
71
75
|
pending = _pending_confirms.get(session_key)
|
|
72
76
|
if pending and content.strip().lower() in _CONFIRM_KEYWORDS:
|
|
73
77
|
_pending_confirms.pop(session_key, None)
|
|
74
|
-
|
|
78
|
+
struct_log(
|
|
79
|
+
logger, logging.INFO, "confirm_passed",
|
|
80
|
+
component="bot_service", intent=pending.intent, session_key=session_key,
|
|
81
|
+
)
|
|
75
82
|
reply = _execute_handler(pending, user_context)
|
|
76
83
|
set_last_action(session_key, pending.intent, pending.params, reply)
|
|
77
84
|
return reply
|
|
@@ -146,12 +153,20 @@ def dispatch(
|
|
|
146
153
|
_pending_confirms[session_key] = intent_result
|
|
147
154
|
bug_ids = intent_result.params.get("bug_ids") or [intent_result.params.get("bug_id", "")]
|
|
148
155
|
ids_str = ", ".join(f"#{b}" for b in bug_ids if b)
|
|
149
|
-
|
|
156
|
+
struct_log(
|
|
157
|
+
logger, logging.INFO, "bug_close_confirm_prompt",
|
|
158
|
+
component="bot_service", session_key=session_key, bug_ids_preview=ids_str,
|
|
159
|
+
)
|
|
150
160
|
return f"你确定要关闭 Bug {ids_str} 吗?回复「确认关闭」继续,或「取消」放弃~"
|
|
151
161
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
struct_log(
|
|
163
|
+
logger, logging.INFO, "dispatch_execute",
|
|
164
|
+
component="bot_service",
|
|
165
|
+
intent=intent_result.intent,
|
|
166
|
+
confidence=round(intent_result.confidence, 4),
|
|
167
|
+
params=intent_result.params,
|
|
168
|
+
missing_params=intent_result.missing_params,
|
|
169
|
+
)
|
|
155
170
|
reply = _execute_handler(intent_result, user_context)
|
|
156
171
|
set_last_action(session_key, intent_result.intent, intent_result.params, reply)
|
|
157
172
|
return reply
|
|
@@ -166,31 +181,132 @@ _WRITE_INTENTS = frozenset({
|
|
|
166
181
|
})
|
|
167
182
|
|
|
168
183
|
|
|
184
|
+
_MAX_FIELD_RETRIES = 5
|
|
185
|
+
_ERROR_PREFIX = "哎呀,没成功 😥\n\n禅道返回: "
|
|
186
|
+
_ERROR_SUFFIX = "\n\n要不检查一下状态再试试?"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _extract_error_info(reply: str) -> str | None:
|
|
190
|
+
"""从 format_error 的固定格式中提取原始错误信息,非该格式返回 None。"""
|
|
191
|
+
if not reply.startswith(_ERROR_PREFIX):
|
|
192
|
+
return None
|
|
193
|
+
tail = reply[len(_ERROR_PREFIX):]
|
|
194
|
+
idx = tail.find(_ERROR_SUFFIX)
|
|
195
|
+
if idx == -1:
|
|
196
|
+
return None
|
|
197
|
+
return tail[:idx].strip() or None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _safe_call_handler(handler, user_context, intent_result: IntentResult) -> tuple[str, bool]:
|
|
201
|
+
"""调用 handler,返回 (reply, is_exception)。异常被捕获后 is_exception=True。"""
|
|
202
|
+
try:
|
|
203
|
+
result = handler(user_context, intent_result)
|
|
204
|
+
return result, False
|
|
205
|
+
except ZentaoPermissionError as e:
|
|
206
|
+
struct_log(
|
|
207
|
+
logger, logging.WARNING, "handler_permission_denied",
|
|
208
|
+
component="bot_service", intent=intent_result.intent,
|
|
209
|
+
zentao_code=user_context.zentao_code, error=str(e),
|
|
210
|
+
)
|
|
211
|
+
return str(e), True
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.exception("handler 执行异常 intent=%s", intent_result.intent)
|
|
214
|
+
return format_error(str(e)), True
|
|
215
|
+
|
|
216
|
+
|
|
169
217
|
def _execute_handler(intent_result: IntentResult, user_context) -> str:
|
|
170
218
|
if intent_result.intent not in _ALLOWED_INTENTS:
|
|
171
|
-
|
|
219
|
+
struct_log(
|
|
220
|
+
logger, logging.WARNING, "invalid_intent",
|
|
221
|
+
component="bot_service", intent=intent_result.intent,
|
|
222
|
+
zentao_code=user_context.zentao_code,
|
|
223
|
+
)
|
|
172
224
|
return format_unknown()
|
|
173
225
|
|
|
174
226
|
handler = HANDLERS.get(intent_result.intent)
|
|
175
227
|
if not handler:
|
|
176
|
-
logger.
|
|
228
|
+
struct_log(logger, logging.WARNING, "unknown_intent_handler",
|
|
229
|
+
component="bot_service", intent=intent_result.intent)
|
|
177
230
|
return format_unknown()
|
|
178
231
|
|
|
179
232
|
if intent_result.intent in _WRITE_INTENTS:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
233
|
+
struct_log(
|
|
234
|
+
audit_logger, logging.INFO, "audit_write",
|
|
235
|
+
component="audit",
|
|
236
|
+
op=intent_result.intent,
|
|
237
|
+
user=user_context.zentao_code,
|
|
238
|
+
params=intent_result.params,
|
|
239
|
+
)
|
|
183
240
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
241
|
+
reply, raised = _safe_call_handler(handler, user_context, intent_result)
|
|
242
|
+
|
|
243
|
+
if not raised and intent_result.intent in _WRITE_INTENTS:
|
|
244
|
+
for attempt in range(1, _MAX_FIELD_RETRIES + 1):
|
|
245
|
+
error_info = _extract_error_info(reply)
|
|
246
|
+
if not error_info:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
struct_log(
|
|
250
|
+
logger, logging.INFO, "field_retry_start",
|
|
251
|
+
component="bot_service",
|
|
252
|
+
intent=intent_result.intent, attempt=attempt,
|
|
253
|
+
error_info=error_info,
|
|
254
|
+
current_params=intent_result.params,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
fixed_params = fix_params_on_api_error(
|
|
258
|
+
intent_result.intent, intent_result.params,
|
|
259
|
+
error_info, user_context, attempt,
|
|
260
|
+
)
|
|
261
|
+
if not fixed_params or fixed_params == intent_result.params:
|
|
262
|
+
struct_log(
|
|
263
|
+
logger, logging.INFO, "field_retry_abort",
|
|
264
|
+
component="bot_service",
|
|
265
|
+
intent=intent_result.intent, attempt=attempt,
|
|
266
|
+
reason="no_fix" if not fixed_params else "params_unchanged",
|
|
267
|
+
)
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
intent_result.params = fixed_params
|
|
271
|
+
|
|
272
|
+
struct_log(
|
|
273
|
+
audit_logger, logging.INFO, "audit_field_retry",
|
|
274
|
+
component="audit",
|
|
275
|
+
op=intent_result.intent,
|
|
276
|
+
user=user_context.zentao_code,
|
|
277
|
+
attempt=attempt,
|
|
278
|
+
fixed_params=fixed_params,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
reply, raised = _safe_call_handler(handler, user_context, intent_result)
|
|
282
|
+
if raised:
|
|
283
|
+
struct_log(
|
|
284
|
+
logger, logging.WARNING, "field_retry_exception",
|
|
285
|
+
component="bot_service",
|
|
286
|
+
intent=intent_result.intent, attempt=attempt,
|
|
287
|
+
)
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
else:
|
|
291
|
+
struct_log(
|
|
292
|
+
logger, logging.WARNING, "field_retry_exhausted",
|
|
293
|
+
component="bot_service",
|
|
294
|
+
intent=intent_result.intent, max_retries=_MAX_FIELD_RETRIES,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not raised:
|
|
298
|
+
if _extract_error_info(reply) is None:
|
|
299
|
+
struct_log(
|
|
300
|
+
logger, logging.INFO, "handler_ok",
|
|
301
|
+
component="bot_service", intent=intent_result.intent,
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
struct_log(
|
|
305
|
+
logger, logging.WARNING, "handler_failed_after_retries",
|
|
306
|
+
component="bot_service", intent=intent_result.intent,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return reply
|
|
194
310
|
|
|
195
311
|
|
|
196
312
|
def _inject_image_paths(intent_result: IntentResult, image_paths: list[str] | None) -> None:
|
|
@@ -200,7 +316,10 @@ def _inject_image_paths(intent_result: IntentResult, image_paths: list[str] | No
|
|
|
200
316
|
if intent_result.intent == "bug_upload" and not intent_result.params.get("file_path"):
|
|
201
317
|
intent_result.params["file_path"] = image_paths[0]
|
|
202
318
|
intent_result.missing_params = [p for p in intent_result.missing_params if p != "file_path"]
|
|
203
|
-
|
|
319
|
+
struct_log(
|
|
320
|
+
logger, logging.INFO, "inject_image_path",
|
|
321
|
+
component="bot_service", file_path=image_paths[0],
|
|
322
|
+
)
|
|
204
323
|
|
|
205
324
|
|
|
206
325
|
def _fix_bug_resolve_missing(intent_result: IntentResult) -> None:
|
|
@@ -285,7 +404,11 @@ def _handle_general_chat(content: str, image_paths: list[str] | None, user_conte
|
|
|
285
404
|
soul_content=soul,
|
|
286
405
|
)
|
|
287
406
|
if reply:
|
|
288
|
-
|
|
407
|
+
struct_log(
|
|
408
|
+
logger, logging.INFO, "general_chat_reply",
|
|
409
|
+
component="bot_service", user=user_context.zentao_code,
|
|
410
|
+
reply_preview=reply[:200],
|
|
411
|
+
)
|
|
289
412
|
return reply
|
|
290
413
|
except Exception:
|
|
291
414
|
logger.exception("通用问答异常")
|
package/src/zenbot/config.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -118,6 +119,15 @@ def resolve_product_id(product_name: str | None, user_code: str | None = None) -
|
|
|
118
119
|
|
|
119
120
|
|
|
120
121
|
class JsonFormatter(logging.Formatter):
|
|
122
|
+
"""单行 JSON:含 ts(UTC ISO8601)、level、logger、msg,可选 event、ctx。"""
|
|
123
|
+
|
|
124
|
+
def formatTime(self, record, datefmt=None):
|
|
125
|
+
return (
|
|
126
|
+
datetime.fromtimestamp(record.created, tz=timezone.utc)
|
|
127
|
+
.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
128
|
+
+ "Z"
|
|
129
|
+
)
|
|
130
|
+
|
|
121
131
|
def format(self, record):
|
|
122
132
|
try:
|
|
123
133
|
msg = record.getMessage()
|
|
@@ -128,9 +138,14 @@ class JsonFormatter(logging.Formatter):
|
|
|
128
138
|
log = {
|
|
129
139
|
"ts": self.formatTime(record),
|
|
130
140
|
"level": record.levelname,
|
|
131
|
-
"
|
|
141
|
+
"logger": record.name,
|
|
142
|
+
"module": getattr(record, "module", ""),
|
|
132
143
|
"msg": msg,
|
|
133
144
|
}
|
|
145
|
+
if hasattr(record, "struct_event") and record.struct_event:
|
|
146
|
+
log["event"] = record.struct_event
|
|
147
|
+
if hasattr(record, "struct_ctx") and record.struct_ctx:
|
|
148
|
+
log["ctx"] = record.struct_ctx
|
|
134
149
|
if record.exc_info and not record.exc_text:
|
|
135
150
|
record.exc_text = self.formatException(record.exc_info)
|
|
136
151
|
if record.exc_text:
|
|
@@ -8,6 +8,7 @@ from dingtalk_stream.chatbot import ChatbotHandler
|
|
|
8
8
|
from . import dingtalk_reply
|
|
9
9
|
from .bot_service import dispatch
|
|
10
10
|
from .dingtalk_api import resolve_job_number, download_robot_file
|
|
11
|
+
from .logging_utils import struct_log
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger("zentaoagent")
|
|
13
14
|
|
|
@@ -91,7 +92,11 @@ def _download_images(robot_code: str, download_codes: list[str]) -> list[str]:
|
|
|
91
92
|
if local_path:
|
|
92
93
|
paths.append(local_path)
|
|
93
94
|
else:
|
|
94
|
-
|
|
95
|
+
struct_log(
|
|
96
|
+
logger, logging.WARNING, "dingtalk_image_download_fail",
|
|
97
|
+
component="dingtalk_handler",
|
|
98
|
+
download_code_prefix=code[:12] if len(code) > 12 else code,
|
|
99
|
+
)
|
|
95
100
|
return paths
|
|
96
101
|
|
|
97
102
|
|
|
@@ -116,35 +121,57 @@ class ZentaoBotHandler(ChatbotHandler):
|
|
|
116
121
|
_userid_to_jobnumber[userid] = job_number
|
|
117
122
|
staff_id = job_number
|
|
118
123
|
else:
|
|
119
|
-
|
|
124
|
+
struct_log(
|
|
125
|
+
logger, logging.WARNING, "dingtalk_jobnumber_unresolved",
|
|
126
|
+
component="dingtalk_handler", userid=userid, staff_nick=staff_nick,
|
|
127
|
+
)
|
|
120
128
|
staff_id = userid
|
|
121
129
|
|
|
122
130
|
robot_code = incoming_message.robot_code or ""
|
|
123
131
|
download_codes = _extract_download_codes(incoming_message)
|
|
124
132
|
image_paths: list[str] = []
|
|
125
133
|
if download_codes:
|
|
126
|
-
|
|
134
|
+
struct_log(
|
|
135
|
+
logger, logging.INFO, "dingtalk_inbound_images",
|
|
136
|
+
component="dingtalk_handler",
|
|
137
|
+
image_count=len(download_codes), robot_code=robot_code,
|
|
138
|
+
)
|
|
127
139
|
image_paths = _download_images(robot_code, download_codes)
|
|
128
140
|
|
|
129
141
|
referenced = _extract_referenced_content(message.data, incoming_message)
|
|
130
142
|
if referenced:
|
|
131
143
|
content = f"【用户引用的消息】\n{referenced}\n\n【用户输入】\n{content}"
|
|
132
|
-
|
|
144
|
+
struct_log(
|
|
145
|
+
logger, logging.INFO, "dingtalk_inbound_reference",
|
|
146
|
+
component="dingtalk_handler", referenced_len=len(referenced),
|
|
147
|
+
)
|
|
133
148
|
|
|
134
149
|
if not content.strip() and not image_paths:
|
|
135
|
-
|
|
136
|
-
|
|
150
|
+
struct_log(
|
|
151
|
+
logger, logging.WARNING, "dingtalk_empty_message",
|
|
152
|
+
component="dingtalk_handler",
|
|
153
|
+
msgtype=incoming_message.message_type,
|
|
154
|
+
raw_data_keys=list(message.data.keys()),
|
|
155
|
+
)
|
|
137
156
|
reply_text = "没收到内容,试试直接打字发送?"
|
|
138
|
-
|
|
157
|
+
struct_log(
|
|
158
|
+
logger, logging.INFO, "dingtalk_outbound",
|
|
159
|
+
component="dingtalk_handler", staff_id=staff_id, reply_preview=reply_text[:200],
|
|
160
|
+
)
|
|
139
161
|
dingtalk_reply.reply_markdown(session_webhook, "小禅", reply_text)
|
|
140
162
|
return AckMessage.STATUS_OK, "ok"
|
|
141
163
|
|
|
142
164
|
if not content.strip() and image_paths:
|
|
143
165
|
content = "请帮我看看这张图片"
|
|
144
166
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
167
|
+
struct_log(
|
|
168
|
+
logger, logging.INFO, "dingtalk_inbound_text",
|
|
169
|
+
component="dingtalk_handler",
|
|
170
|
+
userid=userid, staff_id=staff_id, staff_nick=staff_nick,
|
|
171
|
+
msgtype=incoming_message.message_type,
|
|
172
|
+
image_count=len(image_paths),
|
|
173
|
+
content_preview=content.strip()[:300],
|
|
174
|
+
)
|
|
148
175
|
|
|
149
176
|
try:
|
|
150
177
|
reply_text = dispatch(
|
|
@@ -157,9 +184,17 @@ class ZentaoBotHandler(ChatbotHandler):
|
|
|
157
184
|
image_paths=image_paths,
|
|
158
185
|
)
|
|
159
186
|
except Exception:
|
|
160
|
-
|
|
187
|
+
struct_log(
|
|
188
|
+
logger, logging.ERROR, "dispatch_exception",
|
|
189
|
+
component="dingtalk_handler", staff_id=staff_id,
|
|
190
|
+
)
|
|
191
|
+
logger.exception("dispatch_exception detail")
|
|
161
192
|
reply_text = "处理消息时出错,请稍后再试"
|
|
162
193
|
|
|
163
|
-
|
|
194
|
+
struct_log(
|
|
195
|
+
logger, logging.INFO, "dingtalk_outbound",
|
|
196
|
+
component="dingtalk_handler", staff_id=staff_id,
|
|
197
|
+
reply_preview=reply_text[:300],
|
|
198
|
+
)
|
|
164
199
|
dingtalk_reply.reply_markdown(session_webhook, "小禅", reply_text)
|
|
165
200
|
return AckMessage.STATUS_OK, "ok"
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from .config import UserContext
|
|
10
10
|
from . import llm_client
|
|
11
|
+
from .logging_utils import struct_log
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger("zentaoagent")
|
|
13
14
|
|
|
@@ -273,7 +274,10 @@ def parse_intent(
|
|
|
273
274
|
try:
|
|
274
275
|
content = llm_client.chat_completion(messages)
|
|
275
276
|
if not content:
|
|
276
|
-
|
|
277
|
+
struct_log(
|
|
278
|
+
logger, logging.WARNING, "intent_llm_empty",
|
|
279
|
+
component="intent_parser", user_input_preview=user_input[:300],
|
|
280
|
+
)
|
|
277
281
|
return IntentResult("unknown", 0.0, {}, [], "理解失败,请用更明确的指令")
|
|
278
282
|
data = json.loads(content)
|
|
279
283
|
intent = data.get("intent", "unknown")
|
|
@@ -281,12 +285,124 @@ def parse_intent(
|
|
|
281
285
|
params = data.get("params") or {}
|
|
282
286
|
missing_params = data.get("missing_params") or []
|
|
283
287
|
clarify_question = data.get("clarify_question") or ""
|
|
284
|
-
|
|
285
|
-
|
|
288
|
+
struct_log(
|
|
289
|
+
logger, logging.INFO, "intent_parse_result",
|
|
290
|
+
component="intent_parser",
|
|
291
|
+
user_input_preview=user_input[:400],
|
|
292
|
+
intent=intent, confidence=confidence,
|
|
293
|
+
params=params, missing_params=missing_params,
|
|
294
|
+
)
|
|
286
295
|
return IntentResult(intent, confidence, params, missing_params, clarify_question)
|
|
287
296
|
except (json.JSONDecodeError, TypeError) as e:
|
|
288
|
-
|
|
297
|
+
struct_log(
|
|
298
|
+
logger, logging.ERROR, "intent_json_error",
|
|
299
|
+
component="intent_parser", user_input_preview=user_input[:300], error=str(e),
|
|
300
|
+
)
|
|
289
301
|
return IntentResult("unknown", 0.0, {}, [], "理解失败,请用更明确的指令")
|
|
290
302
|
except Exception as e:
|
|
291
|
-
|
|
303
|
+
struct_log(
|
|
304
|
+
logger, logging.ERROR, "intent_parse_exception",
|
|
305
|
+
component="intent_parser", user_input_preview=user_input[:300], error=str(e),
|
|
306
|
+
)
|
|
307
|
+
logger.exception("intent_parse_exception detail")
|
|
292
308
|
return IntentResult("unknown", 0.0, {}, [], "理解失败,请用更明确的指令")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ─────────── API 字段自修复 ───────────
|
|
312
|
+
|
|
313
|
+
_FIX_PARAMS_SYSTEM = """\
|
|
314
|
+
你是禅道 API 调试专家。handler 调用禅道 API 失败了,你需要根据错误信息判断能否通过修正参数来修复。
|
|
315
|
+
|
|
316
|
+
## 禅道 API 常见字段格式规则
|
|
317
|
+
- severity: 整数 1-4(1最高),字符串形式如 "1"
|
|
318
|
+
- pri: 整数 1-4(1最高),字符串形式如 "1"
|
|
319
|
+
- openedBuild: 字符串,如 "trunk"
|
|
320
|
+
- resolution: fixed|bydesign|duplicate|notrepro|postponed|willnotfix|reqchange|external|tostory
|
|
321
|
+
- assignedTo: 禅道工号(英文小写),不是中文姓名
|
|
322
|
+
- product: 数字字符串,如 "365"
|
|
323
|
+
- deadline / estStarted: 日期格式 "YYYY-MM-DD"
|
|
324
|
+
- type: bug类型如 codeerror|config|install|security|performance|standard|automation|designdefect|others
|
|
325
|
+
- status 相关字段不能在创建时指定
|
|
326
|
+
- 布尔类字段用 "0" / "1"
|
|
327
|
+
|
|
328
|
+
## 意图参数定义
|
|
329
|
+
{intent_schema}
|
|
330
|
+
|
|
331
|
+
## 当前情况
|
|
332
|
+
- 意图: {intent}
|
|
333
|
+
- 当前参数: {params}
|
|
334
|
+
- API 错误信息: {error_info}
|
|
335
|
+
- 已尝试修复次数: {attempt}
|
|
336
|
+
|
|
337
|
+
## 输出 JSON(严格,不输出其他内容)
|
|
338
|
+
{{"can_fix": true/false, "fixed_params": {{...}}, "fix_description": "一句话说明修了什么"}}
|
|
339
|
+
|
|
340
|
+
规则:
|
|
341
|
+
- can_fix=true 时,fixed_params 必须包含完整参数(在当前参数基础上做修改)
|
|
342
|
+
- 以下情况 can_fix 必须为 false:权限不足、ID不存在、网络错误、认证失败、服务器内部错误
|
|
343
|
+
- 不要凭空编造 ID(bug_id / product_id / story_id 等)
|
|
344
|
+
- 如果多次尝试后错误不变,can_fix 设为 false
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def fix_params_on_api_error(
|
|
349
|
+
intent: str,
|
|
350
|
+
params: dict,
|
|
351
|
+
error_info: str,
|
|
352
|
+
user_context: UserContext,
|
|
353
|
+
attempt: int,
|
|
354
|
+
) -> dict | None:
|
|
355
|
+
"""用 LLM 分析 API 错误并尝试修正参数。返回修正后的 params 或 None(无法修复)。"""
|
|
356
|
+
prompt = _FIX_PARAMS_SYSTEM.format(
|
|
357
|
+
intent_schema=INTENT_SCHEMA,
|
|
358
|
+
intent=intent,
|
|
359
|
+
params=json.dumps(params, ensure_ascii=False),
|
|
360
|
+
error_info=error_info,
|
|
361
|
+
attempt=attempt,
|
|
362
|
+
)
|
|
363
|
+
messages = [
|
|
364
|
+
{"role": "system", "content": prompt},
|
|
365
|
+
{"role": "user", "content": f"请分析错误并给出修复方案(第 {attempt} 次尝试)"},
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
content = llm_client.chat_completion(messages)
|
|
370
|
+
if not content:
|
|
371
|
+
struct_log(
|
|
372
|
+
logger, logging.WARNING, "fix_params_llm_empty",
|
|
373
|
+
component="intent_parser", intent=intent, attempt=attempt,
|
|
374
|
+
)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
data = json.loads(content)
|
|
378
|
+
can_fix = data.get("can_fix", False)
|
|
379
|
+
fix_desc = data.get("fix_description", "")
|
|
380
|
+
|
|
381
|
+
struct_log(
|
|
382
|
+
logger, logging.INFO, "fix_params_result",
|
|
383
|
+
component="intent_parser",
|
|
384
|
+
intent=intent, attempt=attempt, can_fix=can_fix,
|
|
385
|
+
fix_description=fix_desc,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if not can_fix:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
fixed = data.get("fixed_params")
|
|
392
|
+
if not isinstance(fixed, dict) or not fixed:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
return fixed
|
|
396
|
+
|
|
397
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
398
|
+
struct_log(
|
|
399
|
+
logger, logging.ERROR, "fix_params_json_error",
|
|
400
|
+
component="intent_parser", intent=intent, attempt=attempt, error=str(e),
|
|
401
|
+
)
|
|
402
|
+
return None
|
|
403
|
+
except Exception as e:
|
|
404
|
+
struct_log(
|
|
405
|
+
logger, logging.ERROR, "fix_params_exception",
|
|
406
|
+
component="intent_parser", intent=intent, attempt=attempt, error=str(e),
|
|
407
|
+
)
|
|
408
|
+
return None
|
package/src/zenbot/llm_client.py
CHANGED
|
@@ -8,6 +8,8 @@ import time
|
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
|
+
from .logging_utils import struct_log
|
|
12
|
+
|
|
11
13
|
logger = logging.getLogger("zentaoagent")
|
|
12
14
|
|
|
13
15
|
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
|
@@ -42,8 +44,12 @@ def chat_completion(messages: list, max_retries: int = 2) -> str | None:
|
|
|
42
44
|
"messages": messages,
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
struct_log(
|
|
48
|
+
logger, logging.INFO, "llm_request",
|
|
49
|
+
component="llm_client",
|
|
50
|
+
model=LLM_MODEL, messages_count=len(messages),
|
|
51
|
+
messages_summary=_summarize_messages(messages),
|
|
52
|
+
)
|
|
47
53
|
logger.debug("LLM请求完整messages: %s",
|
|
48
54
|
json.dumps(messages, ensure_ascii=False, indent=2))
|
|
49
55
|
|
|
@@ -57,17 +63,28 @@ def chat_completion(messages: list, max_retries: int = 2) -> str | None:
|
|
|
57
63
|
data = resp.json()
|
|
58
64
|
content = data.get("choices", [{}])[0].get("message", {}).get("content")
|
|
59
65
|
usage = data.get("usage", {})
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
struct_log(
|
|
67
|
+
logger, logging.INFO, "llm_response",
|
|
68
|
+
component="llm_client",
|
|
69
|
+
model=LLM_MODEL,
|
|
70
|
+
elapsed_sec=round(elapsed, 3),
|
|
71
|
+
prompt_tokens=usage.get("prompt_tokens"),
|
|
72
|
+
completion_tokens=usage.get("completion_tokens"),
|
|
73
|
+
content_preview=(content or "")[:500],
|
|
74
|
+
)
|
|
65
75
|
return content
|
|
66
76
|
except (requests.exceptions.Timeout, requests.exceptions.RequestException) as e:
|
|
67
77
|
last_err = e
|
|
68
|
-
|
|
78
|
+
struct_log(
|
|
79
|
+
logger, logging.WARNING, "llm_retry",
|
|
80
|
+
component="llm_client",
|
|
81
|
+
attempt=attempt + 1, max_retries=max_retries, error=str(e),
|
|
82
|
+
)
|
|
69
83
|
continue
|
|
70
|
-
|
|
84
|
+
struct_log(
|
|
85
|
+
logger, logging.ERROR, "llm_all_failed",
|
|
86
|
+
component="llm_client", max_retries=max_retries, last_error=str(last_err),
|
|
87
|
+
)
|
|
71
88
|
raise last_err
|
|
72
89
|
|
|
73
90
|
|
|
@@ -143,8 +160,12 @@ def general_chat(
|
|
|
143
160
|
"messages": messages,
|
|
144
161
|
}
|
|
145
162
|
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
struct_log(
|
|
164
|
+
logger, logging.INFO, "llm_general_chat_request",
|
|
165
|
+
component="llm_client",
|
|
166
|
+
model=model, has_images=has_images,
|
|
167
|
+
message_preview=(user_message or "")[:300],
|
|
168
|
+
)
|
|
148
169
|
|
|
149
170
|
try:
|
|
150
171
|
t0 = time.time()
|
|
@@ -154,9 +175,19 @@ def general_chat(
|
|
|
154
175
|
data = resp.json()
|
|
155
176
|
content = data.get("choices", [{}])[0].get("message", {}).get("content")
|
|
156
177
|
usage = data.get("usage", {})
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
struct_log(
|
|
179
|
+
logger, logging.INFO, "llm_general_chat_response",
|
|
180
|
+
component="llm_client",
|
|
181
|
+
model=model,
|
|
182
|
+
elapsed_sec=round(elapsed, 3),
|
|
183
|
+
prompt_tokens=usage.get("prompt_tokens"),
|
|
184
|
+
completion_tokens=usage.get("completion_tokens"),
|
|
185
|
+
)
|
|
159
186
|
return content
|
|
160
187
|
except Exception as e:
|
|
161
|
-
|
|
188
|
+
struct_log(
|
|
189
|
+
logger, logging.ERROR, "llm_general_chat_failed",
|
|
190
|
+
component="llm_client", model=model, error=str(e),
|
|
191
|
+
)
|
|
192
|
+
logger.exception("llm_general_chat_failed detail")
|
|
162
193
|
return None
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""结构化日志:与 JsonFormatter 配合,输出可检索的 event + ctx 字段。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_MAX_STR = 2000
|
|
10
|
+
_MAX_LIST = 50
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _truncate(s: str, max_len: int = _MAX_STR) -> str:
|
|
14
|
+
if len(s) <= max_len:
|
|
15
|
+
return s
|
|
16
|
+
return s[: max_len - 3] + "..."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def json_safe(value: Any, depth: int = 0) -> Any:
|
|
20
|
+
"""将任意值转为 JSON 可序列化结构,控制深度与体积。"""
|
|
21
|
+
if depth > 6:
|
|
22
|
+
return "<max_depth>"
|
|
23
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
24
|
+
return value
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return _truncate(value)
|
|
27
|
+
if isinstance(value, bytes):
|
|
28
|
+
return _truncate(value.decode("utf-8", errors="replace"))
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
out = {}
|
|
31
|
+
for i, (k, v) in enumerate(value.items()):
|
|
32
|
+
if i >= 80:
|
|
33
|
+
out["_truncated_keys"] = len(value) - 80
|
|
34
|
+
break
|
|
35
|
+
key = str(k) if not isinstance(k, str) else k
|
|
36
|
+
out[key] = json_safe(v, depth + 1)
|
|
37
|
+
return out
|
|
38
|
+
if isinstance(value, (list, tuple, set)):
|
|
39
|
+
seq = list(value)
|
|
40
|
+
if len(seq) > _MAX_LIST:
|
|
41
|
+
return [json_safe(x, depth + 1) for x in seq[:_MAX_LIST]] + [f"...+{len(seq) - _MAX_LIST} items"]
|
|
42
|
+
return [json_safe(x, depth + 1) for x in seq]
|
|
43
|
+
return _truncate(str(value))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def struct_log(
|
|
47
|
+
logger: logging.Logger,
|
|
48
|
+
level: int,
|
|
49
|
+
event: str,
|
|
50
|
+
msg: str | None = None,
|
|
51
|
+
component: str | None = None,
|
|
52
|
+
**ctx: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
记录一条结构化日志。JsonFormatter 会输出 event、component(若有)、ctx 等字段。
|
|
56
|
+
"""
|
|
57
|
+
payload = json_safe(ctx) if ctx else {}
|
|
58
|
+
if component:
|
|
59
|
+
payload = {"component": component, **payload}
|
|
60
|
+
logger.log(
|
|
61
|
+
level,
|
|
62
|
+
msg or event,
|
|
63
|
+
extra={"struct_event": event, "struct_ctx": payload},
|
|
64
|
+
)
|