@bangdao-ai/zentao-mcp 2.0.5 → 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 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
- _stderr_handler = logging.StreamHandler(sys.stderr)
21
- _stderr_handler.setFormatter(JsonFormatter())
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.info("ZenBot v2 启动中...")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bangdao-ai/zentao-mcp",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "禅道项目管理AI中枢 v2.0 — 35+MCP工具覆盖需求·任务·Bug·用例·发布·我的地盘全生命周期,原生API模式,Bug智能分类引擎+CSV批量用例导入+截图上传,一句话驱动禅道",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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
- logger.info("禅道请求 m=%s&f=%s params=%s form=%s", module, action, safe_params, safe_form)
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
- logger.info("禅道响应 m=%s&f=%s http=%d body=%s", module, action, resp.status_code, resp_summary)
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
- logger.error("禅道请求异常 m=%s&f=%s error=%s", module, action, e)
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]:
@@ -1534,12 +1556,13 @@ class ZenTaoNexus:
1534
1556
  if not batch_cases:
1535
1557
  return [{"status": 0, "message": "CSV 中无有效用例行"}]
1536
1558
 
1537
- result = self.client.testcase_import_batch(self.product_id, batch_cases)
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
- self.product_id, orderBy='id_desc',
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):
@@ -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
- logger.warning("用户未绑定 staff_id=%s nick=%s", staff_id, staff_nick)
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
- logger.info("二次确认通过 intent=%s session=%s", pending.intent, session_key)
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
- logger.info("关闭Bug二次确认 session=%s bug_ids=%s", session_key, ids_str)
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
- logger.info("意图解析 intent=%s confidence=%.2f params=%s missing=%s",
153
- intent_result.intent, intent_result.confidence,
154
- intent_result.params, intent_result.missing_params)
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
- logger.warning("LLM返回非法意图 intent=%s user=%s", intent_result.intent, user_context.zentao_code)
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.warning("未知意图 intent=%s", intent_result.intent)
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
- audit_logger.info("WRITE op=%s user=%s params=%s",
181
- intent_result.intent, user_context.zentao_code,
182
- json.dumps(intent_result.params, ensure_ascii=False))
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
- try:
185
- result = handler(user_context, intent_result)
186
- logger.info("handler 执行完成 intent=%s", intent_result.intent)
187
- return result
188
- except ZentaoPermissionError as e:
189
- logger.warning("权限不足 intent=%s user=%s: %s", intent_result.intent, user_context.zentao_code, e)
190
- return str(e)
191
- except Exception as e:
192
- logger.exception("handler 执行异常 intent=%s", intent_result.intent)
193
- return format_error(str(e))
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
- logger.info("自动注入图片路径 file_path=%s", image_paths[0])
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
- logger.info("通用问答回复 user=%s reply=%s", user_context.zentao_code, reply[:100])
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("通用问答异常")
@@ -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
- "module": getattr(record, "module", record.name),
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
- logger.warning("图片下载失败 download_code=%s...%s", code[:8], code[-4:])
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
- logger.warning("无法解析工号 userid=%s nick=%s", userid, staff_nick)
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
- logger.info("检测到图片消息 count=%d robot_code=%s", len(download_codes), robot_code)
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
- logger.info("检测到引用/转发消息 ref_len=%d 已合并到 content", len(referenced))
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
- logger.warning("消息内容为空 msgtype=%s raw_data_keys=%s",
136
- incoming_message.message_type, list(message.data.keys()))
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
- logger.info("回复消息 staff_id=%s reply=%s", staff_id, reply_text[:100])
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
- logger.info("收到消息 userid=%s staff_id=%s nick=%s msgtype=%s images=%d content=%s",
146
- userid, staff_id, staff_nick,
147
- incoming_message.message_type, len(image_paths), content.strip()[:100])
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
- logger.exception("dispatch 异常")
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
- logger.info("回复消息 staff_id=%s reply=%s", staff_id, reply_text[:100])
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
- logger.warning("LLM返回空内容 user_input=%s", user_input)
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
- logger.info("意图解析结果 user_input=%s intent=%s confidence=%.2f params=%s missing=%s",
285
- user_input, intent, confidence, params, missing_params)
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
- logger.error("LLM返回JSON解析失败 user_input=%s error=%s", user_input, e)
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
- logger.error("意图解析异常 user_input=%s error=%s", user_input, e, exc_info=True)
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
@@ -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
- logger.info("LLM请求 model=%s messages_count=%d summary=%s",
46
- LLM_MODEL, len(messages), _summarize_messages(messages))
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
- logger.info("LLM响应 elapsed=%.2fs tokens_in=%s tokens_out=%s content=%s",
61
- elapsed,
62
- usage.get("prompt_tokens", "?"),
63
- usage.get("completion_tokens", "?"),
64
- content)
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
- logger.warning("LLM请求失败 attempt=%d/%d error=%s", attempt + 1, max_retries, e)
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
- logger.error("LLM请求全部失败 max_retries=%d last_error=%s", max_retries, last_err)
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
- logger.info("通用问答请求 model=%s has_images=%s message=%s",
147
- model, has_images, (user_message or "")[:100])
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
- logger.info("通用问答响应 elapsed=%.2fs tokens_in=%s tokens_out=%s",
158
- elapsed, usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"))
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
- logger.error("通用问答请求失败: %s", e, exc_info=True)
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
+ )