oh-aicoding-tool 0.1.0

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 (55) hide show
  1. package/CODEX_LANGFUSE_PLAN.md +62 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +166 -0
  4. package/bin/langfuse-cli.js +718 -0
  5. package/codex_langfuse_notify.py +591 -0
  6. package/langfuse_hook.py +603 -0
  7. package/opencode-ohai-report/.claude/commands/report-ai-issue.md +60 -0
  8. package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +30 -0
  9. package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +569 -0
  10. package/opencode-ohai-report/README.md +45 -0
  11. package/opencode-ohai-report/bin/cli.js +421 -0
  12. package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +313 -0
  13. package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +476 -0
  14. package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +405 -0
  15. package/opencode-ohai-report/examples/issue_output.json +4 -0
  16. package/opencode-ohai-report/package.json +40 -0
  17. package/opencode-ohai-report/scripts/claude_report_hook.py +257 -0
  18. package/opencode-ohai-report/scripts/create_issue.py +34 -0
  19. package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +254 -0
  20. package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +264 -0
  21. package/opencode-ohai-report/scripts/install-opencode-plugin.sh +218 -0
  22. package/opencode-ohai-report/scripts/merge-claude-settings.py +99 -0
  23. package/opencode-ohai-report/tools/ohai-report/README.md +151 -0
  24. package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +26 -0
  25. package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +5 -0
  26. package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +9 -0
  27. package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +319 -0
  28. package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +32 -0
  29. package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +14 -0
  30. package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +313 -0
  31. package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +360 -0
  32. package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +1 -0
  33. package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +38 -0
  34. package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +64 -0
  35. package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +80 -0
  36. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +1 -0
  37. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +15 -0
  38. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +405 -0
  39. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +21 -0
  40. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +354 -0
  41. package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +9 -0
  42. package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +61 -0
  43. package/opencode-ohai-report/tools/ohai-report/ohai_report.py +10 -0
  44. package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +166 -0
  45. package/package.json +59 -0
  46. package/scripts/codex-langfuse-check.mjs +101 -0
  47. package/scripts/codex-langfuse-setup.mjs +181 -0
  48. package/scripts/langfuse-check.mjs +90 -0
  49. package/scripts/langfuse-setup.mjs +278 -0
  50. package/scripts/opencode-langfuse-check.mjs +94 -0
  51. package/scripts/opencode-langfuse-run.mjs +96 -0
  52. package/scripts/opencode-langfuse-setup.mjs +478 -0
  53. package/scripts/resolve-opencode-cli.mjs +58 -0
  54. package/setup-langfuse.bat +163 -0
  55. package/setup-langfuse.sh +130 -0
@@ -0,0 +1,319 @@
1
+ """Command line interface for ohai-report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ from .metadata import update_metadata
11
+ from .payload import build_issue_payload
12
+ from .schema import CreateIssueInput, MetadataUpdateInput, issue_fields, validate_create_input
13
+ from .sinks.gitcode import GitCodeConfig, GitCodeIssueSink, GitCodeSinkError, apply_gitcode_result
14
+ from .sinks.local import LocalIssueSink
15
+ from .sinks.webhook import WebhookConfig, WebhookIssueSink, WebhookSinkError, apply_webhook_result
16
+ from .workspace import resolve_workspace_root
17
+
18
+
19
+ def create_issue(args: argparse.Namespace) -> int:
20
+ issue = build_issue_from_args(args)
21
+ create_input = CreateIssueInput(
22
+ source=args.source,
23
+ issue=issue,
24
+ metadata=args.metadata,
25
+ )
26
+ errors = validate_create_input(create_input)
27
+ if errors:
28
+ print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False), file=sys.stderr)
29
+ return 2
30
+
31
+ root = resolve_workspace_root(getattr(args, "cwd", "") or "")
32
+ payload = build_issue_payload(root, create_input)
33
+ gitcode_result = None
34
+ webhook_result = None
35
+
36
+ try:
37
+ if args.sink == "gitcode":
38
+ cfg = GitCodeConfig.from_env(
39
+ owner=(args.gitcode_owner or "").strip() or None,
40
+ repo=(args.gitcode_repo or "").strip() or None,
41
+ token=(args.gitcode_token or "").strip() or None,
42
+ api_base=(args.gitcode_api_base or "").strip() or None,
43
+ labels=(args.gitcode_labels if args.gitcode_labels is not None else None),
44
+ label_maintainer=True if getattr(args, "gitcode_label_maintainer", False) else None,
45
+ )
46
+ gitcode_result = GitCodeIssueSink(cfg).save(payload)
47
+ payload = apply_gitcode_result(payload, gitcode_result)
48
+ result = LocalIssueSink(root).save(payload)
49
+ elif args.sink == "webhook":
50
+ sec_cli = (args.webhook_secret or "").strip()
51
+ wh_cfg = WebhookConfig.from_env(
52
+ url=(args.webhook_url or "").strip() or None,
53
+ secret=sec_cli if sec_cli else None,
54
+ timeout=args.webhook_timeout,
55
+ )
56
+ webhook_result = WebhookIssueSink(wh_cfg).save(
57
+ payload,
58
+ webhook_labels_cli=args.webhook_labels,
59
+ )
60
+ payload = apply_webhook_result(payload, webhook_result)
61
+ result = LocalIssueSink(root).save(payload)
62
+ else:
63
+ result = LocalIssueSink(root).save(payload)
64
+ except (GitCodeSinkError, WebhookSinkError) as exc:
65
+ err: dict[str, Any] = {"ok": False, "error": str(exc)}
66
+ if exc.status_code:
67
+ err["status_code"] = exc.status_code
68
+ if exc.body:
69
+ err["body"] = exc.body[:4096]
70
+ print(json.dumps(err, ensure_ascii=False), file=sys.stderr)
71
+ return 3
72
+
73
+ output: dict[str, Any] = {
74
+ "ok": True,
75
+ "issue_id": payload["issue_id"],
76
+ "session_id": payload.get("session_id", ""),
77
+ "trace_id": payload["trace_id"],
78
+ "observation_id": payload.get("observation_id", ""),
79
+ "user_id": payload.get("user_id", ""),
80
+ "user_email": payload.get("user_email", ""),
81
+ "message_id": payload.get("message_id", ""),
82
+ "opencode_subagent": payload.get("opencode_subagent", ""),
83
+ "langfuse_url": payload["langfuse_url"],
84
+ "path": str(result.path) if result.path else "",
85
+ "sink": args.sink,
86
+ }
87
+ if gitcode_result and gitcode_result.url:
88
+ output["gitcode_url"] = gitcode_result.url
89
+ num = gitcode_result.extra.get("number")
90
+ if num is not None:
91
+ output["gitcode_number"] = num
92
+ if webhook_result and webhook_result.url:
93
+ output["webhook_url"] = webhook_result.url
94
+
95
+ if args.json:
96
+ print(json.dumps(output, ensure_ascii=False))
97
+ elif args.quiet:
98
+ print(payload["issue_id"])
99
+ else:
100
+ suffix = ""
101
+ if gitcode_result and gitcode_result.url:
102
+ suffix = f"(GitCode: {gitcode_result.url})"
103
+ if webhook_result and webhook_result.url:
104
+ suffix = f"{suffix}(Webhook: {webhook_result.url})"
105
+ print(f"已上报:{payload['issue_id']}{suffix}")
106
+ return 0
107
+
108
+
109
+ def parse_field_overrides(values: list[str]) -> dict[str, str]:
110
+ fields: dict[str, str] = {}
111
+ for value in values:
112
+ if "=" not in value:
113
+ raise ValueError(f"invalid --field value: {value}")
114
+ key, item = value.split("=", 1)
115
+ key = key.strip()
116
+ if not key:
117
+ raise ValueError(f"invalid --field key: {value}")
118
+ fields[key] = item
119
+ return fields
120
+
121
+
122
+ def build_issue_from_args(args: argparse.Namespace) -> dict[str, Any]:
123
+ fields = issue_fields()
124
+ issue: dict[str, Any] = {}
125
+ for name, spec in fields.items():
126
+ default = spec.get("default")
127
+ if default is not None:
128
+ issue[name] = default
129
+
130
+ fixed_values = {
131
+ "title": args.title,
132
+ "category": args.category,
133
+ "summary": args.summary,
134
+ "expected_behavior": args.expected_behavior,
135
+ "actual_behavior": args.actual_behavior,
136
+ "severity": args.severity,
137
+ "user_description": args.user_description,
138
+ }
139
+ issue.update({key: value for key, value in fixed_values.items() if value})
140
+
141
+ if args.issue_json:
142
+ try:
143
+ json_issue = json.loads(args.issue_json)
144
+ except json.JSONDecodeError as exc:
145
+ raise SystemExit(f"invalid --issue-json: {exc}") from exc
146
+ if not isinstance(json_issue, dict):
147
+ raise SystemExit("invalid --issue-json: expected object")
148
+ issue.update(json_issue)
149
+
150
+ if args.issue_file:
151
+ try:
152
+ with open(args.issue_file, "r", encoding="utf-8") as fp:
153
+ file_issue = json.load(fp)
154
+ except (OSError, json.JSONDecodeError) as exc:
155
+ raise SystemExit(f"invalid --issue-file: {exc}") from exc
156
+ if not isinstance(file_issue, dict):
157
+ raise SystemExit("invalid --issue-file: expected object")
158
+ issue.update(file_issue)
159
+
160
+ try:
161
+ issue.update(parse_field_overrides(args.field or []))
162
+ except ValueError as exc:
163
+ raise SystemExit(str(exc)) from exc
164
+ return issue
165
+
166
+
167
+ def metadata_update(args: argparse.Namespace) -> int:
168
+ root = resolve_workspace_root(getattr(args, "cwd", "") or "")
169
+ update_input = MetadataUpdateInput(
170
+ source=args.source,
171
+ session_id=args.session_id,
172
+ trace_id=args.trace_id,
173
+ langfuse_url=args.langfuse_url,
174
+ observation_id=args.observation_id,
175
+ user_id=args.user_id,
176
+ message_id=args.message_id,
177
+ opencode_subagent=args.opencode_subagent,
178
+ model=args.model,
179
+ clear_message_subagent=args.drop_message_and_subagent,
180
+ cwd=args.cwd,
181
+ )
182
+ path = update_metadata(root, update_input)
183
+ if args.json:
184
+ print(json.dumps({"ok": True, "metadata_path": str(path)}, ensure_ascii=False))
185
+ return 0
186
+
187
+
188
+ def build_parser() -> argparse.ArgumentParser:
189
+ parser = argparse.ArgumentParser(prog="ohai-report")
190
+ subparsers = parser.add_subparsers(dest="command", required=True)
191
+
192
+ create = subparsers.add_parser("create", help="Create a local AI issue report")
193
+ create.add_argument("--source", default="opencode")
194
+ create.add_argument("--title", default="")
195
+ create.add_argument("--category", default="")
196
+ create.add_argument("--summary", default="")
197
+ create.add_argument("--expected-behavior", dest="expected_behavior", default="")
198
+ create.add_argument("--actual-behavior", dest="actual_behavior", default="")
199
+ create.add_argument("--severity", default="P2")
200
+ create.add_argument("--user-description", dest="user_description", default="")
201
+ create.add_argument("--issue-json", default="", help="JSON object containing issue fields")
202
+ create.add_argument("--issue-file", default="", help="Path to a JSON file containing issue fields")
203
+ create.add_argument("--field", action="append", default=[], help="Additional issue field as key=value")
204
+ create.add_argument("--metadata", default="auto", choices=["auto", "none"])
205
+ create.add_argument(
206
+ "--sink",
207
+ default="local",
208
+ choices=["local", "gitcode", "webhook"],
209
+ help="Persistence backend(webhook:POST JSON 到外部建单服务,并仍写入本地 issues)",
210
+ )
211
+ create.add_argument(
212
+ "--gitcode-owner",
213
+ default="",
214
+ help="GitCode 仓库 owner(命名空间),缺省读 OHAI_GITCODE_OWNER / GITCODE_OWNER",
215
+ )
216
+ create.add_argument(
217
+ "--gitcode-repo",
218
+ default="",
219
+ help="GitCode 仓库名(path),缺省读 OHAI_GITCODE_REPO / GITCODE_REPO",
220
+ )
221
+ create.add_argument(
222
+ "--gitcode-token",
223
+ default="",
224
+ help="GitCode access token(建议用环境变量 OHAI_GITCODE_TOKEN)",
225
+ )
226
+ create.add_argument(
227
+ "--gitcode-api-base",
228
+ default="",
229
+ help="API 根地址,缺省 https://api.gitcode.com 或 OHAI_GITCODE_API_BASE",
230
+ )
231
+ create.add_argument(
232
+ "--gitcode-labels",
233
+ default=None,
234
+ help="逗号分隔标签,可选;或环境变量 OHAI_GITCODE_LABELS",
235
+ )
236
+ create.add_argument(
237
+ "--gitcode-label-maintainer",
238
+ action="store_true",
239
+ help="机器人对仓库有 Maintainer:缺失的 tool/model/level/category 等标签会自动创建;"
240
+ "不设则读 OHAI_GITCODE_LABEL_MAINTAINER;无权限且缺标签时报错",
241
+ )
242
+ create.add_argument(
243
+ "--webhook-url",
244
+ default="",
245
+ help="Webhook 地址;缺省先读 WEBHOOK_URL/OHAI_WEBHOOK_URL,再回退 webhook_defaults.DEFAULT_WEBHOOK_URL",
246
+ )
247
+ create.add_argument(
248
+ "--webhook-secret",
249
+ default="",
250
+ help="可选,X-Webhook-Secret;不设则读 OHAI_WEBHOOK_SECRET / WEBHOOK_SECRET",
251
+ )
252
+ create.add_argument(
253
+ "--webhook-timeout",
254
+ type=float,
255
+ default=None,
256
+ help="请求超时秒数;缺省读 OHAI_WEBHOOK_TIMEOUT / WEBHOOK_TIMEOUT,否则 45",
257
+ )
258
+ create.add_argument(
259
+ "--webhook-labels",
260
+ default=None,
261
+ help="逗号分隔标签名,POST JSON 的 labels 数组;传入则覆盖 issue.labels 与 OHAI_WEBHOOK_LABELS 合并结果",
262
+ )
263
+ create.add_argument(
264
+ "--cwd",
265
+ default="",
266
+ help="仓库根目录(写入 .ohai-report/;默认当前工作目录)。OpenCode 插件应传会话 project 根。",
267
+ )
268
+ create.add_argument("--quiet", action="store_true")
269
+ create.add_argument("--json", action="store_true")
270
+ create.set_defaults(func=create_issue)
271
+
272
+ metadata = subparsers.add_parser("metadata", help="Manage local metadata")
273
+ metadata_sub = metadata.add_subparsers(dest="metadata_command", required=True)
274
+ update = metadata_sub.add_parser("update", help="Update local metadata indexes")
275
+ update.add_argument("--source", default="opencode")
276
+ update.add_argument("--session-id", default="")
277
+ update.add_argument("--trace-id", default="")
278
+ update.add_argument("--langfuse-url", default="")
279
+ update.add_argument(
280
+ "--observation-id",
281
+ default="",
282
+ help="当前 Langfuse observation / span id(或环境 LANGFUSE_OBSERVATION_ID)",
283
+ )
284
+ update.add_argument(
285
+ "--user-id",
286
+ default="",
287
+ help="覆盖写入 metadata.user.id(或依赖 LANGFUSE_USER_ID / OHAI_USER_ID 环境)",
288
+ )
289
+ update.add_argument(
290
+ "--message-id",
291
+ default="",
292
+ help="OpenCode 当前消息 ID(或环境 OPENCODE_MESSAGE_ID / OHAI_MESSAGE_ID)",
293
+ )
294
+ update.add_argument(
295
+ "--opencode-subagent",
296
+ default="",
297
+ help="OpenCode context.agent(或环境 OHAI_OPENCODE_SUBAGENT / OPENCODE_SUBAGENT)",
298
+ )
299
+ update.add_argument(
300
+ "--model",
301
+ default="",
302
+ help="当前 LLM 模型标识(OpenCode 会话 info.model.id 等;或环境 OPENCODE_MODEL / OHAI_MODEL)",
303
+ )
304
+ update.add_argument(
305
+ "--drop-message-and-subagent",
306
+ action="store_true",
307
+ help="清空 metadata 中的 message_id 与 opencode_subagent(OpenCode 插件侧不再采集时使用)",
308
+ )
309
+ update.add_argument("--cwd", default="")
310
+ update.add_argument("--json", action="store_true")
311
+ update.set_defaults(func=metadata_update)
312
+
313
+ return parser
314
+
315
+
316
+ def main(argv: list[str] | None = None) -> int:
317
+ parser = build_parser()
318
+ args = parser.parse_args(argv)
319
+ return args.func(args)
@@ -0,0 +1,32 @@
1
+ """Git context discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import subprocess
7
+
8
+
9
+ def run_git(args: list[str], root: pathlib.Path) -> str:
10
+ try:
11
+ result = subprocess.run(
12
+ ["git", *args],
13
+ cwd=root,
14
+ text=True,
15
+ capture_output=True,
16
+ timeout=2,
17
+ check=False,
18
+ )
19
+ except (FileNotFoundError, subprocess.SubprocessError):
20
+ return ""
21
+ if result.returncode != 0:
22
+ return ""
23
+ return result.stdout.strip()
24
+
25
+
26
+ def git_context(root: pathlib.Path) -> dict[str, str]:
27
+ return {
28
+ "repo": run_git(["config", "--get", "remote.origin.url"], root),
29
+ "branch": run_git(["rev-parse", "--abbrev-ref", "HEAD"], root),
30
+ "commit": run_git(["rev-parse", "--short", "HEAD"], root),
31
+ "cwd": str(root),
32
+ }
@@ -0,0 +1,14 @@
1
+ """GitCode 默认命名空间与仓库名(非密钥,可写死在此避免每次传 CLI 参数)。
2
+
3
+ Access Token **不要**写进仓库:在本机用户或 OpenCode 启动环境中配置一次即可:
4
+ OHAI_GITCODE_TOKEN 或 GITCODE_ACCESS_TOKEN
5
+
6
+ 仍可用环境变量 OHAI_GITCODE_OWNER / OHAI_GITCODE_REPO 覆盖下列默认值。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # 在此填写你的 GitCode/Gitee 仓库路径(与 API 中 owner、repo 一致);仍可用环境变量覆盖。
12
+ # 默认指向 OpenHarmony Insight 反馈仓;Token 切勿写入仓库,请用 OHAI_GITCODE_TOKEN 等环境变量。
13
+ DEFAULT_GITCODE_OWNER: str = "openharmonyinsight"
14
+ DEFAULT_GITCODE_REPO: str = "ai-dev-feedback"
@@ -0,0 +1,313 @@
1
+ """Issue 正文 Markdown:与「AI 使用问题反馈」模板对齐(GitCode / Webhook 共用)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from .metadata import is_placeholder_tracing_user_id
9
+
10
+
11
+ def _pick(issue: dict[str, Any], key: str, default: str = "unknown") -> str:
12
+ v = issue.get(key)
13
+ if v is None or (isinstance(v, str) and not str(v).strip()):
14
+ return default
15
+ return str(v).strip()
16
+
17
+
18
+ def _pick_optional_display(issue: dict[str, Any], key: str) -> str:
19
+ """可选展示字段:空或 LLM 填的 unknown 统一为「未填写」,避免像错误信息。"""
20
+ v = issue.get(key)
21
+ if v is None or (isinstance(v, str) and not str(v).strip()):
22
+ return "(未填写)"
23
+ s = str(v).strip()
24
+ if s.lower() == "unknown":
25
+ return "(未填写)"
26
+ return s
27
+
28
+
29
+ def _first_nonempty_str(*vals: object) -> str:
30
+ for v in vals:
31
+ if v is None:
32
+ continue
33
+ t = str(v).strip()
34
+ if t:
35
+ return t
36
+ return "unknown"
37
+
38
+
39
+ def _looks_like_email(s: str) -> bool:
40
+ t = (s or "").strip()
41
+ if "@" not in t or len(t) < 5:
42
+ return False
43
+ local, _, domain = t.partition("@")
44
+ if not local or not domain or "." not in domain:
45
+ return False
46
+ return True
47
+
48
+
49
+ def _log_user_email_display(raw: str) -> str:
50
+ """日志信息「公司邮箱」:非邮箱形态(如 OS 登录名)不展示。"""
51
+ t = (raw or "").strip()
52
+ if not t or t.lower() == "unknown":
53
+ return "(未采集)"
54
+ if _looks_like_email(t):
55
+ return t
56
+ return "(未采集)"
57
+
58
+
59
+ def _log_correlation_display(raw: str, *, is_user_id: bool = False) -> str:
60
+ """日志信息行:无有效采集时统一「未采集」,不把占位数字当真实 user。"""
61
+ t = (raw or "").strip()
62
+ if not t or t.lower() == "unknown":
63
+ return "(未采集)"
64
+ if is_user_id and is_placeholder_tracing_user_id(t):
65
+ return "(未采集)"
66
+ return t
67
+
68
+
69
+ _ALLOWED_AGENTS = frozenset({"opencode", "claude", "codex"})
70
+
71
+
72
+ _CATEGORY_LABEL_MAP: dict[str, str] = {
73
+ "模型输出错误": "model-error",
74
+ "工具调用失败": "tool-error",
75
+ "Skill 缺陷": "skill-bug",
76
+ "上下文缺失": "context-missing",
77
+ "环境问题": "env-issue",
78
+ "其他": "other",
79
+ }
80
+
81
+
82
+ def _pick_agent(issue: dict[str, Any], payload: dict[str, Any]) -> str:
83
+ """标签 agent:issue 优先;否则用上报 source(如 opencode)。"""
84
+ raw = issue.get("agent")
85
+ if raw is not None and str(raw).strip():
86
+ v = str(raw).strip().lower()
87
+ if v in _ALLOWED_AGENTS:
88
+ return v
89
+ legacy = issue.get("tool")
90
+ if legacy is not None and str(legacy).strip():
91
+ v = str(legacy).strip().lower()
92
+ if v in _ALLOWED_AGENTS:
93
+ return v
94
+ src = str(payload.get("source") or "").strip().lower()
95
+ if src in _ALLOWED_AGENTS:
96
+ return src
97
+ return "(未填写)"
98
+
99
+
100
+ def _normalize_p_level(s: str) -> str:
101
+ """将标签 level 规范为 P0–P3。"""
102
+ t = str(s).strip().upper()
103
+ if t in ("P0", "P1", "P2", "P3"):
104
+ return t
105
+ if t in ("0", "1", "2", "3"):
106
+ return f"P{t}"
107
+ return t
108
+
109
+
110
+ def format_issue_markdown(payload: dict[str, Any]) -> str:
111
+ """由 payload 生成「AI 使用问题反馈」结构正文(不含完整日志、diff、transcript)。"""
112
+ issue = payload.get("issue") if isinstance(payload.get("issue"), dict) else {}
113
+ title = _pick(issue, "title", "")
114
+ summary = _pick(issue, "summary", "")
115
+ user_desc = _pick(issue, "user_description", "")
116
+ category = _pick(issue, "category", "unknown")
117
+ severity = _pick(issue, "severity", "P2")
118
+ expected = _pick(issue, "expected_behavior", "")
119
+ actual = _pick(issue, "actual_behavior", "")
120
+
121
+ agent = _pick_agent(issue, payload)
122
+ scenario = _pick_optional_display(issue, "scenario")
123
+ task_type = _pick_optional_display(issue, "task_type")
124
+ workflow_phase = _pick_optional_display(issue, "workflow_phase")
125
+ affected_role = _pick_optional_display(issue, "affected_role")
126
+
127
+ model_raw = _pick(issue, "model", "")
128
+ model = "(未填写)" if not model_raw.strip() or model_raw.strip().lower() == "unknown" else model_raw.strip()
129
+
130
+ raw_level = issue.get("level")
131
+ if raw_level is None or (isinstance(raw_level, str) and not str(raw_level).strip()):
132
+ level_display = _normalize_p_level(severity)
133
+ else:
134
+ norm = _normalize_p_level(str(raw_level).strip())
135
+ level_display = norm if norm in ("P0", "P1", "P2", "P3") else _normalize_p_level(severity)
136
+
137
+ primary = _pick(issue, "primary_category", category)
138
+ sub_c = _pick(issue, "sub_category", "none")
139
+ if sub_c.lower() == "unknown":
140
+ sub_c = "none"
141
+ conf = _pick(issue, "classification_confidence", "low")
142
+ if conf.lower() == "unknown":
143
+ conf = "low"
144
+ rationale = _pick(
145
+ issue,
146
+ "classification_rationale",
147
+ "基于用户当前描述归纳;未读取会话日志与工具输出。",
148
+ )
149
+
150
+ impact = _pick(issue, "impact", "")
151
+ if not impact and (expected or actual):
152
+ impact = (
153
+ f"期望侧:{expected or '(未说明)'};实际侧:{actual or '(未说明)'}。"
154
+ "若与「问题摘要」重复,请以摘要为准。"
155
+ )
156
+ if not impact:
157
+ impact = "(请结合问题摘要与原始描述评估影响范围。)"
158
+
159
+ causes = _pick(issue, "possible_causes", "")
160
+ improvement = _pick(issue, "improvement_direction", expected or "(待补充)")
161
+ follow = _pick(
162
+ issue,
163
+ "suggested_follow_up",
164
+ "具体使用场景、模型版本与平台、对比其它模型的表现、可复现步骤与频率。",
165
+ )
166
+ dispatch = _pick(issue, "suggested_dispatch", "模型能力、产品体验、待分派")
167
+ d_low = dispatch.strip().casefold()
168
+ if d_low.endswith("unknown"):
169
+ head = dispatch[: -len("unknown")].rstrip("、").strip()
170
+ dispatch = f"{head}、待分派" if head else "待分派"
171
+
172
+ user_obj = payload.get("user") if isinstance(payload.get("user"), dict) else {}
173
+ raw_email = _first_nonempty_str(
174
+ issue.get("user_email"),
175
+ payload.get("user_email"),
176
+ user_obj.get("email"),
177
+ )
178
+ if raw_email == "unknown":
179
+ raw_email = ""
180
+ if not raw_email or not _looks_like_email(raw_email):
181
+ fallback = _first_nonempty_str(payload.get("user_id"), issue.get("user_id"), user_obj.get("id"))
182
+ if fallback != "unknown" and _looks_like_email(str(fallback)):
183
+ raw_email = str(fallback).strip()
184
+ log_user_email = _log_user_email_display(raw_email)
185
+ raw_sess = _first_nonempty_str(issue.get("session_id"), payload.get("session_id"))
186
+ log_session_id = _log_correlation_display(raw_sess)
187
+
188
+ lines: list[str] = [
189
+ "## AI 使用问题反馈",
190
+ "",
191
+ "### 标题",
192
+ title or "(无标题)",
193
+ "",
194
+ "### 问题摘要",
195
+ summary or "(无摘要)",
196
+ "",
197
+ "### 原始描述",
198
+ user_desc or "(无原始描述)",
199
+ "",
200
+ "### 使用场景",
201
+ f"- 场景:`{scenario}`",
202
+ f"- 任务类型:`{task_type}`",
203
+ f"- 工作流阶段:`{workflow_phase}`",
204
+ f"- 受影响角色:`{affected_role}`",
205
+ "",
206
+ "### 问题分类",
207
+ f"- 主分类:`{primary}`",
208
+ f"- 次分类:`{sub_c}`",
209
+ f"- 分类置信度:`{conf}`",
210
+ f"- 判断依据:{rationale}",
211
+ "",
212
+ "### 标签",
213
+ f"- `agent:{agent}`",
214
+ f"- `model:{model}`",
215
+ f"- `level:{level_display}`",
216
+ "",
217
+ "### 影响",
218
+ impact,
219
+ "",
220
+ "### 可能原因",
221
+ ]
222
+ if causes:
223
+ for para in causes.replace("\r\n", "\n").split("\n"):
224
+ p = para.strip()
225
+ if p:
226
+ if p.startswith("- "):
227
+ lines.append(p)
228
+ else:
229
+ lines.append(f"- {p}")
230
+ else:
231
+ lines.append("- 当前信息不足,具体根因需结合日志与复现信息进一步确认。")
232
+
233
+ log_block = [
234
+ "",
235
+ "### 日志信息",
236
+ f"- user_email:`{log_user_email}`",
237
+ f"- session_id:`{log_session_id}`",
238
+ "",
239
+ "_说明:「未采集」表示无有效公司邮箱或会话 ID。邮箱来自 `OHAI_USER_EMAIL` / metadata `user.email` / `--field user_email`;追踪用 `user_id` 仍保存在 Issue JSON 中,**本小节不单独列出**。Langfuse 等关联信息在 JSON 与 metadata 中。`model` 由 OpenCode 插件写入 metadata(`--metadata auto` 时覆盖 issue 中的 model)。_",
240
+ ]
241
+
242
+ lines.extend(
243
+ [
244
+ "",
245
+ "### 改进方向",
246
+ improvement,
247
+ "",
248
+ "### 建议补充信息",
249
+ follow,
250
+ "",
251
+ "### 建议分派方向",
252
+ dispatch,
253
+ *log_block,
254
+ ]
255
+ )
256
+ return "\n".join(lines)
257
+
258
+
259
+ _LABEL_VALUE_ALIASES = {
260
+ "模型输出错误": "model-output-error",
261
+ "工具调用失败": "tool-call-failure",
262
+ "Skill 缺陷": "skill-defect",
263
+ "上下文缺失": "context-missing",
264
+ "环境问题": "environment",
265
+ "其他": "other",
266
+ "构建": "build",
267
+ "编译": "compile",
268
+ }
269
+
270
+
271
+ def sanitize_gitcode_label_value(text: str, *, max_len: int = 64) -> str:
272
+ """Normalize remote labels to compact ASCII values."""
273
+ s = str(text or "").strip()
274
+ s = _LABEL_VALUE_ALIASES.get(s, s)
275
+ s = s.replace(",", " ").replace(";", " ").replace("\n", " ").replace("\r", " ")
276
+ s = " ".join(s.split())
277
+ s = s.encode("ascii", errors="ignore").decode("ascii")
278
+ s = re.sub(r"[^A-Za-z0-9_.-]+", "-", s).strip("-._")
279
+ if not s:
280
+ return "unspecified"
281
+ return s[:max_len] if len(s) <= max_len else s[: max_len - 1] + "…"
282
+
283
+
284
+ def derive_gitcode_dimension_labels(payload: dict[str, Any]) -> list[str]:
285
+ """与正文「### 标签」一致:tool(agent/旧 tool/source)、model、level、问题主分类。"""
286
+ issue = payload.get("issue") if isinstance(payload.get("issue"), dict) else {}
287
+ tool_val = _pick_agent(issue, payload)
288
+ if tool_val == "(未填写)":
289
+ tool_val = "unspecified"
290
+ model_raw = _pick(issue, "model", "")
291
+ if not model_raw.strip() or model_raw.strip().lower() == "unknown":
292
+ model_disp = "unspecified"
293
+ else:
294
+ model_disp = model_raw.strip()
295
+ severity = _pick(issue, "severity", "P2")
296
+ raw_level = issue.get("level")
297
+ if raw_level is None or (isinstance(raw_level, str) and not str(raw_level).strip()):
298
+ level_display = _normalize_p_level(severity)
299
+ else:
300
+ norm = _normalize_p_level(str(raw_level).strip())
301
+ level_display = norm if norm in ("P0", "P1", "P2", "P3") else _normalize_p_level(severity)
302
+ category = _pick(issue, "category", "unknown")
303
+ primary = _pick(issue, "primary_category", category)
304
+ if primary.lower() == "unknown":
305
+ primary_disp = "unspecified"
306
+ else:
307
+ primary_disp = _CATEGORY_LABEL_MAP.get(primary, primary)
308
+ return [
309
+ sanitize_gitcode_label_value(tool_val),
310
+ sanitize_gitcode_label_value(model_disp),
311
+ sanitize_gitcode_label_value(level_display),
312
+ sanitize_gitcode_label_value(primary_disp),
313
+ ]