@shulex/cxclawctl 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +25 -22
  2. package/dist/cli.js +2202 -127
  3. package/dist/cli.js.map +1 -1
  4. package/package.json +19 -6
  5. package/capabilities.lock.json +0 -898
  6. package/requirements-cli.txt +0 -2
  7. package/skills/__lib__/__init__.py +0 -0
  8. package/skills/__lib__/skill-runtime/bootstrap.py +0 -134
  9. package/skills/__lib__/solvea-api/__init__.py +0 -0
  10. package/skills/__lib__/solvea-api/client.py +0 -1944
  11. package/skills/__lib__/solvea-api/dashboard.py +0 -100
  12. package/skills/__lib__/solvea-api/solvea_api.py +0 -1341
  13. package/skills/agent-analytics/SKILL.md +0 -74
  14. package/skills/agent-analytics/references/chat-report-fields.md +0 -36
  15. package/skills/agent-analytics/references/report-fields.md +0 -130
  16. package/skills/agent-analytics/references/transcript.md +0 -110
  17. package/skills/agent-builder/SKILL.md +0 -42
  18. package/skills/agent-builder/interfaces/fallback-chatflow.md +0 -47
  19. package/skills/agent-builder/references/chatflow/graph-spec.md +0 -259
  20. package/skills/agent-builder/references/chatflow/node-selection.md +0 -89
  21. package/skills/agent-builder/references/chatflow/nodes/agent.md +0 -350
  22. package/skills/agent-builder/references/chatflow/nodes/business-track.md +0 -43
  23. package/skills/agent-builder/references/chatflow/nodes/code.md +0 -157
  24. package/skills/agent-builder/references/chatflow/nodes/collect-order.md +0 -47
  25. package/skills/agent-builder/references/chatflow/nodes/finish.md +0 -34
  26. package/skills/agent-builder/references/chatflow/nodes/human-review.md +0 -60
  27. package/skills/agent-builder/references/chatflow/nodes/if-else.md +0 -100
  28. package/skills/agent-builder/references/chatflow/nodes/intent.md +0 -79
  29. package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +0 -43
  30. package/skills/agent-builder/references/chatflow/nodes/llm.md +0 -117
  31. package/skills/agent-builder/references/chatflow/nodes/note.md +0 -38
  32. package/skills/agent-builder/references/chatflow/nodes/question.md +0 -97
  33. package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +0 -68
  34. package/skills/agent-builder/references/chatflow/nodes/reply.md +0 -97
  35. package/skills/agent-builder/references/chatflow/nodes/start.md +0 -35
  36. package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +0 -48
  37. package/skills/agent-builder/references/chatflow/nodes/tool.md +0 -138
  38. package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +0 -63
  39. package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +0 -52
  40. package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +0 -50
  41. package/skills/agent-builder/references/chatflow/nodes/variable-process.md +0 -70
  42. package/skills/agent-builder/references/chatflow/overview.md +0 -53
  43. package/skills/agent-builder/references/chatflow/visual-translation.md +0 -64
  44. package/skills/agent-builder/references/chatflow/workflow.md +0 -134
  45. package/skills/agent-builder/references/multi-agent/create-update.md +0 -185
  46. package/skills/agent-builder/references/multi-agent/graph-spec.md +0 -246
  47. package/skills/agent-builder/references/multi-agent/graph-structure.md +0 -12
  48. package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +0 -13
  49. package/skills/agent-builder/references/multi-agent/migration/large.md +0 -554
  50. package/skills/agent-builder/references/multi-agent/migration/principles.md +0 -446
  51. package/skills/agent-builder/references/multi-agent/migration/small.md +0 -225
  52. package/skills/agent-builder/references/multi-agent/orchestration.md +0 -11
  53. package/skills/agent-builder/references/multi-agent/overview.md +0 -48
  54. package/skills/agent-builder/references/multi-agent/update.md +0 -128
  55. package/skills/agent-builder/references/multi-agent/workflow.md +0 -87
  56. package/skills/agent-builder/references/single-agent/overview.md +0 -48
  57. package/skills/agent-builder/references/single-agent/prompt.md +0 -16
  58. package/skills/agent-builder/references/single-agent/tools.md +0 -36
  59. package/skills/agent-builder/references/single-agent/voice.md +0 -46
  60. package/skills/agent-builder/references/tools/http-tool.md +0 -315
  61. package/skills/agent-builder/references/tools/mock-tool.md +0 -279
  62. package/skills/agent-builder/references/tools/overview.md +0 -46
  63. package/skills/agent-builder/references/tools/tool-config.md +0 -43
  64. package/skills/agent-builder/references/tools/x-token.md +0 -34
  65. package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +0 -3458
  66. package/skills/agent-builder/scripts/chatflow/validate_graph.py +0 -1697
  67. package/skills/agent-builder/scripts/copy_marketplace_agent.py +0 -61
  68. package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +0 -5974
  69. package/skills/agent-builder/scripts/multi-agent/replay_compare.py +0 -679
  70. package/skills/agent-builder/scripts/patch_agent_tools.py +0 -135
  71. package/skills/agent-builder/scripts/tools/validate_tool_config.py +0 -384
  72. package/skills/agent-tester/SKILL.md +0 -35
  73. package/skills/agent-tester/batch-test/overview.md +0 -86
  74. package/skills/agent-tester/scripts/batch_test_sheet.py +0 -213
  75. package/skills/agent-tester/single-test/badcase-retest.md +0 -85
  76. package/skills/agent-tester/single-test/direct-test.md +0 -24
  77. package/skills/agent-tester/single-test/overview.md +0 -142
  78. package/skills/delivery/SKILL.md +0 -112
  79. package/skills/delivery/interfaces/classify.md +0 -38
  80. package/skills/delivery/interfaces/create-agents.md +0 -89
  81. package/skills/delivery/interfaces/delivery-test.md +0 -67
  82. package/skills/delivery/references/flow.md +0 -40
  83. package/skills/delivery/references/validation.md +0 -15
  84. package/skills/delivery/scripts/batch_test.py +0 -704
  85. package/skills/delivery/scripts/classify.py +0 -22
  86. package/skills/delivery/scripts/create_agents.py +0 -695
  87. package/skills/delivery/scripts/gen_delivery_report.py +0 -335
  88. package/skills/delivery/scripts/gen_excel.py +0 -234
  89. package/skills/delivery/scripts/gen_report.py +0 -208
  90. package/skills/delivery/scripts/plan.py +0 -163
  91. package/skills/delivery/scripts/render.py +0 -713
  92. package/skills/delivery/scripts/render_summary.py +0 -38
  93. package/skills/delivery/scripts/sample_history_cases.py +0 -109
  94. package/skills/kb-builder/SKILL.md +0 -80
  95. package/skills/kb-builder/interfaces/qa-upload.md +0 -55
  96. package/skills/kb-builder/interfaces/web-kb.md +0 -84
  97. package/skills/kb-builder/references/directory.md +0 -373
  98. package/skills/kb-builder/references/export.md +0 -282
  99. package/skills/kb-builder/references/isolation-keywords.md +0 -113
  100. package/skills/kb-builder/references/knowledge.md +0 -153
  101. package/skills/kb-builder/references/product.md +0 -52
  102. package/skills/kb-builder/references/qa-from-help-center.md +0 -58
  103. package/skills/kb-builder/references/qa-from-pdf.md +0 -94
  104. package/skills/kb-builder/references/qa-from-tickets.md +0 -168
  105. package/skills/kb-builder/references/qa-from-web.md +0 -98
  106. package/skills/kb-builder/references/qa-quality.md +0 -73
  107. package/skills/kb-builder/references/reply-template.md +0 -237
  108. package/skills/kb-builder/references/tag-isolation-migration.md +0 -61
  109. package/skills/kb-builder/references/tags.md +0 -45
  110. package/skills/kb-builder/references/upload.md +0 -130
  111. package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +0 -333
  112. package/skills/kb-builder/scripts/build_directory_plan.py +0 -144
  113. package/skills/kb-builder/scripts/crawl_site.py +0 -433
  114. package/skills/kb-builder/scripts/create_dirs.py +0 -146
  115. package/skills/kb-builder/scripts/export_knowledge.py +0 -454
  116. package/skills/kb-builder/scripts/extract_ticket_qa.py +0 -441
  117. package/skills/kb-builder/scripts/extract_web_qa.py +0 -118
  118. package/skills/kb-builder/scripts/import_qa.py +0 -219
  119. package/skills/kb-builder/scripts/import_source_documents.py +0 -143
  120. package/skills/kb-builder/scripts/import_templates.py +0 -409
  121. package/skills/poc/SKILL.md +0 -125
  122. package/skills/poc/assets/default_manual.css +0 -279
  123. package/skills/poc/assets/notice_escalate.png +0 -0
  124. package/skills/poc/assets/notice_escalate2.png +0 -0
  125. package/skills/poc/assets/notice_new_chat.png +0 -0
  126. package/skills/poc/assets/poc_test_template.html +0 -645
  127. package/skills/poc/assets/poc_test_template.md +0 -104
  128. package/skills/poc/assets/step3_preview.png +0 -0
  129. package/skills/poc/assets/step4_order_note.png +0 -0
  130. package/skills/poc/interfaces/classify.md +0 -38
  131. package/skills/poc/interfaces/create-agents.md +0 -108
  132. package/skills/poc/interfaces/poc-test.md +0 -94
  133. package/skills/poc/references/flow.md +0 -47
  134. package/skills/poc/references/kb-directory.md +0 -22
  135. package/skills/poc/references/validation.md +0 -17
  136. package/skills/poc/scripts/classify.py +0 -22
  137. package/skills/poc/scripts/create_agents.py +0 -818
  138. package/skills/poc/scripts/gen_excel.py +0 -234
  139. package/skills/poc/scripts/gen_poc_manual.py +0 -249
  140. package/skills/poc/scripts/gen_report.py +0 -208
  141. package/skills/poc/scripts/inspect_chatflow.py +0 -209
  142. package/skills/poc/scripts/plan.py +0 -178
  143. package/skills/poc/scripts/poc_test.py +0 -495
  144. package/skills/poc/scripts/render.py +0 -713
  145. package/skills/poc/scripts/render_summary.py +0 -41
  146. package/skills/runtime-inspector/SKILL.md +0 -51
  147. package/skills/runtime-inspector/references/frontend-rendering.md +0 -126
  148. package/skills/runtime-inspector/references/model.md +0 -59
  149. package/skills/runtime-inspector/references/sandbox.md +0 -131
  150. package/skills/runtime-inspector/scripts/model_inspect.py +0 -68
  151. package/skills/runtime-inspector/scripts/prepare.py +0 -98
  152. package/skills/runtime-inspector/scripts/process.py +0 -84
  153. package/skills/runtime-inspector/scripts/read_state.py +0 -32
  154. package/skills/runtime-inspector/scripts/sandbox_inspect.py +0 -463
  155. package/skills/runtime-inspector/scripts/test_claude_proxy.py +0 -49
  156. package/skills/runtime-inspector/scripts/test_openai_proxy.py +0 -41
  157. package/skills/runtime-inspector/scripts/verify_persistence.py +0 -101
  158. package/skills/runtime-inspector/scripts/write_test.py +0 -41
  159. package/skills/setup/SKILL.md +0 -17
  160. package/skills/shulex-support/SKILL.md +0 -39
  161. package/skills/shulex-support/references/badcase-analysis/overview.md +0 -34
  162. package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +0 -105
  163. package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +0 -89
  164. package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +0 -36
  165. package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +0 -53
  166. package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +0 -259
  167. package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +0 -148
  168. package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +0 -57
  169. package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +0 -120
  170. package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +0 -126
  171. package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +0 -121
  172. package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +0 -208
  173. package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +0 -115
  174. package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +0 -72
  175. package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +0 -92
  176. package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +0 -147
  177. package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +0 -283
  178. package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +0 -77
  179. package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +0 -19
  180. package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +0 -23
  181. package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +0 -31
  182. package/skills/shulex-support/references/product-docs.md +0 -70
  183. package/skills/shulex-support/references/tickets.md +0 -184
  184. package/skills/shulex-support/scripts/support.py +0 -427
  185. package/skills/solvea-desk/SKILL.md +0 -39
  186. package/skills/solvea-desk/references/report/fields.md +0 -82
  187. package/skills/solvea-desk/references/report/report-design.md +0 -200
  188. package/skills/solvea-desk/references/report/report-template.html +0 -44
  189. package/skills/solvea-desk/references/report/report.css +0 -517
  190. package/skills/solvea-desk/references/report/workflow.md +0 -464
  191. package/skills/solvea-desk/references/tickets/query.md +0 -103
  192. package/skills/solvea-desk/references/tickets/tags.md +0 -83
  193. package/skills/solvea-desk/scripts/report/fetch_sources.py +0 -580
  194. package/skills/solvea-desk/scripts/report/render_report.py +0 -763
  195. package/skills/solvea-desk/scripts/tickets/desk_api.py +0 -73
  196. package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +0 -403
  197. package/skills/solvea-desk/scripts/tickets/ticket_tags.py +0 -305
  198. package/skills/solvea-kit/SKILL.md +0 -84
  199. package/skills/solvea-kit/references/agent.md +0 -145
  200. package/skills/solvea-kit/references/chat.md +0 -244
  201. package/skills/solvea-kit/references/intent.md +0 -69
  202. package/skills/solvea-kit/references/plugin.md +0 -53
  203. package/skills/solvea-kit/references/quality-inspection.md +0 -75
  204. package/skills/solvea-kit/references/strategy.md +0 -62
  205. package/skills/solvea-kit/references/tagging.md +0 -231
  206. package/skills/solvea-kit/references/variables.md +0 -53
  207. package/skills/solvea-kit/scripts/generate_label_import_file.py +0 -151
  208. package/skills/solvea-kit/scripts/intent_classify.py +0 -843
@@ -1,763 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Render a solvea-desk HTML file from structured report data."""
3
-
4
- import os
5
- import sys
6
- sys.path.insert(0, os.path.join(os.environ["SOLVEA_LIB_DIR"], "skill-runtime"))
7
- from bootstrap import OUTPUTS_DIR, STATE_DIR # noqa: E402
8
-
9
- import argparse
10
- import json
11
- import re
12
- from datetime import datetime
13
- from html import escape
14
- from pathlib import Path
15
- from typing import Any
16
-
17
-
18
- SLUG_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
19
- SUPPORTED_CHART_TYPES = {"bar", "line", "pie", "scatter"}
20
- CHART_COLORS = ["#2563eb", "#0f766e", "#b54708", "#7c3aed", "#c026d3", "#475467", "#059669", "#dc2626"]
21
-
22
-
23
- def _skill_dir() -> Path:
24
- return Path(__file__).resolve().parents[2]
25
-
26
-
27
- def _read_json(path: Path, default: Any) -> Any:
28
- if not path.exists():
29
- return default
30
- return json.loads(path.read_text(encoding="utf-8"))
31
-
32
-
33
- def _text(value: Any) -> str:
34
- if value is None:
35
- return ""
36
- if isinstance(value, (dict, list)):
37
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
38
- return str(value)
39
-
40
-
41
- def _html(value: Any) -> str:
42
- return escape(_text(value), quote=True)
43
-
44
-
45
- def _is_numeric(value: Any) -> bool:
46
- return isinstance(value, (int, float)) and not isinstance(value, bool)
47
-
48
-
49
- def _number(value: Any) -> float | None:
50
- if isinstance(value, bool):
51
- return None
52
- if isinstance(value, (int, float)):
53
- return float(value)
54
- if isinstance(value, str):
55
- stripped = value.strip().replace(",", "")
56
- if not stripped:
57
- return None
58
- try:
59
- return float(stripped)
60
- except ValueError:
61
- return None
62
- return None
63
-
64
-
65
- def _display_number(value: float) -> int | float:
66
- return int(value) if value.is_integer() else round(value, 2)
67
-
68
-
69
- def _validate_slug(slug: str) -> str:
70
- normalized = slug.strip()
71
- if not SLUG_RE.fullmatch(normalized):
72
- raise ValueError("report slug must be ASCII lowercase snake_case, for example assignee_group_weekly")
73
- return normalized
74
-
75
-
76
- def _meta_items(report: dict[str, Any], notes: list[dict[str, Any]]) -> str:
77
- generated_at = report.get("generated_at") or datetime.now().astimezone().isoformat(timespec="seconds")
78
- range_text = report.get("range") or report.get("time_range") or (notes[0].get("range") if notes else "")
79
- row_count = report.get("row_count", "")
80
- truncated = report.get("truncated")
81
- if truncated is True:
82
- truncated_text = '<span class="badge warn">已截断</span>'
83
- elif truncated is False:
84
- truncated_text = '<span class="badge good">未截断</span>'
85
- else:
86
- truncated_text = "未说明"
87
- items = [
88
- ("统计范围", _html(range_text)),
89
- ("数据行数", _html(row_count)),
90
- ("截断状态", truncated_text),
91
- ("生成时间", _html(generated_at)),
92
- ]
93
- blocks = []
94
- for label, value in items:
95
- blocks.append(
96
- "\n".join(
97
- [
98
- ' <div class="meta-item">',
99
- f' <div class="meta-label">{_html(label)}</div>',
100
- f' <div class="meta-value">{value}</div>',
101
- " </div>",
102
- ]
103
- )
104
- )
105
- return "\n".join(blocks)
106
-
107
-
108
- def _kpi_blocks(report: dict[str, Any]) -> str:
109
- kpis = report.get("kpis") or []
110
- if not kpis:
111
- return ' <div class="empty">暂无核心指标。</div>'
112
- blocks = []
113
- for item in kpis[:5]:
114
- blocks.append(
115
- "\n".join(
116
- [
117
- ' <div class="kpi">',
118
- f' <div class="kpi-label">{_html(item.get("label") or item.get("metric"))}</div>',
119
- f' <div class="kpi-value">{_html(item.get("value"))}</div>',
120
- f' <div class="kpi-note">{_html(item.get("basis") or item.get("note") or item.get("definition"))}</div>',
121
- " </div>",
122
- ]
123
- )
124
- )
125
- return "\n".join(blocks)
126
-
127
-
128
- def _columns_for(section: dict[str, Any], rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
129
- columns = section.get("columns")
130
- if isinstance(columns, list) and columns:
131
- result = []
132
- for column in columns:
133
- if isinstance(column, str):
134
- result.append({"key": column, "label": column})
135
- elif isinstance(column, dict) and column.get("key"):
136
- result.append(column)
137
- if result:
138
- return result
139
- if not rows:
140
- return []
141
- return [{"key": key, "label": key} for key in rows[0].keys()]
142
-
143
-
144
- def _table(section: dict[str, Any]) -> str:
145
- rows = section.get("rows") or []
146
- if not rows:
147
- return '<p class="empty">暂无明细数据。</p>'
148
- columns = _columns_for(section, rows)
149
- header_cells = []
150
- for column in columns:
151
- key = column["key"]
152
- numeric = column.get("numeric")
153
- if numeric is None:
154
- numeric = any(_is_numeric(row.get(key)) for row in rows)
155
- cls = ' class="num"' if numeric else ""
156
- header_cells.append(f"<th{cls}>{_html(column.get('label') or key)}</th>")
157
- body_rows = []
158
- for row in rows:
159
- cells = []
160
- for column in columns:
161
- key = column["key"]
162
- numeric = column.get("numeric")
163
- if numeric is None:
164
- numeric = _is_numeric(row.get(key))
165
- cls = ' class="num"' if numeric else ""
166
- cells.append(f"<td{cls}>{_html(row.get(key))}</td>")
167
- body_rows.append(f" <tr>{''.join(cells)}</tr>")
168
- return "\n".join(
169
- [
170
- ' <div class="table-wrap">',
171
- " <table>",
172
- f" <thead><tr>{''.join(header_cells)}</tr></thead>",
173
- " <tbody>",
174
- "\n".join(body_rows),
175
- " </tbody>",
176
- " </table>",
177
- " </div>",
178
- ]
179
- )
180
-
181
-
182
- def _bar_chart(section: dict[str, Any], chart: dict[str, Any]) -> str:
183
- rows = section.get("rows") or []
184
- if not rows:
185
- return ""
186
- x_key = chart.get("xKey") or chart.get("x_key")
187
- y_key = chart.get("yKey") or chart.get("y_key")
188
- if not x_key or not y_key:
189
- return '<p class="note">图表配置缺少 xKey 或 yKey,已保留表格数据。</p>'
190
-
191
- points = []
192
- for row in rows:
193
- value = _number(row.get(y_key))
194
- if value is None:
195
- continue
196
- points.append((row.get(x_key), value))
197
- if not points:
198
- return '<p class="note">图表数值为空,已保留表格数据。</p>'
199
-
200
- max_items = chart.get("maxItems") or chart.get("max_items") or 24
201
- try:
202
- max_items = max(1, int(max_items))
203
- except (TypeError, ValueError):
204
- max_items = 24
205
- trimmed = points[:max_items]
206
- max_value = max(value for _, value in trimmed)
207
- if max_value <= 0:
208
- max_value = 1
209
-
210
- label = _text(chart.get("label") or y_key)
211
- title = _text(chart.get("title") or section.get("title") or "分析结果")
212
- bars = []
213
- for raw_name, value in trimmed:
214
- name = _text(raw_name)
215
- percent = max(2.0, min(100.0, value / max_value * 100))
216
- display_value = _display_number(value)
217
- bars.append(
218
- "\n".join(
219
- [
220
- f' <div class="chart-bar" aria-label="{_html(name)} {_html(label)} {_html(display_value)}">',
221
- f' <div class="chart-bar-value">{_html(display_value)}</div>',
222
- ' <div class="chart-bar-track">',
223
- f' <div class="chart-bar-fill" style="height: {percent:.2f}%"></div>',
224
- " </div>",
225
- f' <div class="chart-bar-label">{_html(name)}</div>',
226
- " </div>",
227
- ]
228
- )
229
- )
230
-
231
- more_note = ""
232
- if len(points) > len(trimmed):
233
- more_note = f' <p class="chart-note">图表展示前 {len(trimmed)} 项,其余数据保留在表格中。</p>'
234
- return "\n".join(
235
- [
236
- f' <div class="report-chart report-chart-bar" role="img" aria-label="{_html(title)} 图表">',
237
- f' <div class="chart-title">{_html(title)} 图表</div>',
238
- ' <div class="chart-bars">',
239
- "\n".join(bars),
240
- " </div>",
241
- more_note,
242
- " </div>",
243
- ]
244
- )
245
-
246
-
247
- def _chart_type(chart: dict[str, Any]) -> str:
248
- return _text(chart.get("chartType") or chart.get("chart_type") or chart.get("type")).lower()
249
-
250
-
251
- def _axis_keys(chart: dict[str, Any]) -> tuple[str | None, str | None]:
252
- return chart.get("xKey") or chart.get("x_key"), chart.get("yKey") or chart.get("y_key")
253
-
254
-
255
- def _column_label(section: dict[str, Any], key: str | None, fallback: str) -> str:
256
- if key:
257
- for column in section.get("columns") or []:
258
- if isinstance(column, dict) and column.get("key") == key:
259
- return _text(column.get("label") or key)
260
- return fallback
261
-
262
-
263
- def _xy_points(section: dict[str, Any], chart: dict[str, Any]) -> tuple[str, str, list[tuple[Any, float]]]:
264
- rows = section.get("rows") or []
265
- x_key, y_key = _axis_keys(chart)
266
- if not x_key or not y_key:
267
- return "", "", []
268
- points = []
269
- for row in rows:
270
- value = _number(row.get(y_key))
271
- if value is None:
272
- continue
273
- points.append((row.get(x_key), value))
274
- return x_key, y_key, points
275
-
276
-
277
- def _max_items(chart: dict[str, Any], default: int = 24) -> int:
278
- max_items = chart.get("maxItems") or chart.get("max_items") or default
279
- try:
280
- return max(1, int(max_items))
281
- except (TypeError, ValueError):
282
- return default
283
-
284
-
285
- def _line_chart(section: dict[str, Any], chart: dict[str, Any]) -> str:
286
- x_key, y_key, points = _xy_points(section, chart)
287
- if not x_key or not y_key:
288
- return '<p class="note">图表配置缺少 xKey 或 yKey,已保留表格数据。</p>'
289
- if not points:
290
- return '<p class="note">图表数值为空,已保留表格数据。</p>'
291
-
292
- trimmed = points[:_max_items(chart, 36)]
293
- values = [value for _, value in trimmed]
294
- min_value = min(values)
295
- max_value = max(values)
296
- if max_value == min_value:
297
- max_value = min_value + 1
298
- width = 720
299
- height = 260
300
- left = 46
301
- right = 18
302
- top = 22
303
- bottom = 48
304
- plot_width = width - left - right
305
- plot_height = height - top - bottom
306
- step = plot_width / max(1, len(trimmed) - 1)
307
- label = _text(chart.get("label") or _column_label(section, y_key, y_key))
308
- title = _text(chart.get("title") or section.get("title") or "分析结果")
309
-
310
- coordinates = []
311
- point_nodes = []
312
- labels = []
313
- for idx, (raw_name, value) in enumerate(trimmed):
314
- x = left + idx * step
315
- y = top + (max_value - value) / (max_value - min_value) * plot_height
316
- coordinates.append(f"{x:.2f},{y:.2f}")
317
- name = _text(raw_name)
318
- display_value = _display_number(value)
319
- point_nodes.append(
320
- f' <circle class="chart-point" cx="{x:.2f}" cy="{y:.2f}" r="4" '
321
- f'aria-label="{_html(name)} {_html(label)} {_html(display_value)}"></circle>'
322
- )
323
- labels.append(
324
- "\n".join(
325
- [
326
- f' <text class="chart-point-label" x="{x:.2f}" y="{max(12, y - 8):.2f}">{_html(display_value)}</text>',
327
- f' <text class="chart-axis-label" x="{x:.2f}" y="{height - 14}">{_html(name)}</text>',
328
- ]
329
- )
330
- )
331
-
332
- more_note = ""
333
- if len(points) > len(trimmed):
334
- more_note = f' <p class="chart-note">图表展示前 {len(trimmed)} 项,其余数据保留在表格中。</p>'
335
- coordinate_text = " ".join(coordinates)
336
- return "\n".join(
337
- [
338
- f' <div class="report-chart report-chart-line" role="img" aria-label="{_html(title)} 图表">',
339
- f' <div class="chart-title">{_html(title)} 图表</div>',
340
- f' <svg class="chart-svg" viewBox="0 0 {width} {height}" preserveAspectRatio="none">',
341
- f' <line class="chart-axis" x1="{left}" y1="{height - bottom}" x2="{width - right}" y2="{height - bottom}"></line>',
342
- f' <line class="chart-axis" x1="{left}" y1="{top}" x2="{left}" y2="{height - bottom}"></line>',
343
- f' <polyline class="chart-line" points="{coordinate_text}"></polyline>',
344
- "\n".join(point_nodes),
345
- "\n".join(labels),
346
- " </svg>",
347
- more_note,
348
- " </div>",
349
- ]
350
- )
351
-
352
-
353
- def _pie_chart(section: dict[str, Any], chart: dict[str, Any]) -> str:
354
- rows = section.get("rows") or []
355
- name_key = chart.get("nameKey") or chart.get("name_key") or chart.get("xKey") or chart.get("x_key")
356
- value_key = chart.get("valueKey") or chart.get("value_key") or chart.get("yKey") or chart.get("y_key")
357
- if not name_key or not value_key:
358
- return '<p class="note">饼图配置缺少 nameKey 或 valueKey,已保留表格数据。</p>'
359
-
360
- points = []
361
- for row in rows:
362
- value = _number(row.get(value_key))
363
- if value is None or value <= 0:
364
- continue
365
- points.append((row.get(name_key), value))
366
- if not points:
367
- return '<p class="note">图表数值为空,已保留表格数据。</p>'
368
-
369
- trimmed = points[:_max_items(chart, 12)]
370
- total = sum(value for _, value in trimmed)
371
- if total <= 0:
372
- return '<p class="note">图表数值为空,已保留表格数据。</p>'
373
- label = _text(chart.get("label") or _column_label(section, value_key, value_key))
374
- title = _text(chart.get("title") or section.get("title") or "分析结果")
375
- offset = 0.0
376
- segments = []
377
- legend_items = []
378
- for idx, (raw_name, value) in enumerate(trimmed):
379
- name = _text(raw_name)
380
- display_value = _display_number(value)
381
- percent = value / total * 100
382
- color = CHART_COLORS[idx % len(CHART_COLORS)]
383
- segments.append(
384
- f' <circle class="pie-segment" cx="110" cy="110" r="72" pathLength="100" '
385
- f'fill="transparent" stroke="{color}" stroke-width="42" '
386
- f'stroke-dasharray="{percent:.2f} {100 - percent:.2f}" stroke-dashoffset="{-offset:.2f}" '
387
- f'transform="rotate(-90 110 110)" aria-label="{_html(name)} {_html(label)} {_html(display_value)}"></circle>'
388
- )
389
- legend_items.append(
390
- "\n".join(
391
- [
392
- ' <li>',
393
- f' <span class="pie-swatch" style="background: {color}"></span>',
394
- f' <span>{_html(name)}</span>',
395
- f' <strong>{_html(display_value)}</strong>',
396
- " </li>",
397
- ]
398
- )
399
- )
400
- offset += percent
401
-
402
- more_note = ""
403
- if len(points) > len(trimmed):
404
- more_note = f' <p class="chart-note">图表展示前 {len(trimmed)} 项,其余数据保留在表格中。</p>'
405
- return "\n".join(
406
- [
407
- f' <div class="report-chart report-chart-pie" role="img" aria-label="{_html(title)} 图表">',
408
- f' <div class="chart-title">{_html(title)} 图表</div>',
409
- ' <div class="pie-layout">',
410
- ' <svg class="chart-pie-svg" viewBox="0 0 220 220">',
411
- "\n".join(segments),
412
- ' <circle class="pie-hole" cx="110" cy="110" r="48"></circle>',
413
- " </svg>",
414
- ' <ul class="pie-legend">',
415
- "\n".join(legend_items),
416
- " </ul>",
417
- " </div>",
418
- more_note,
419
- " </div>",
420
- ]
421
- )
422
-
423
-
424
- def _scatter_chart(section: dict[str, Any], chart: dict[str, Any]) -> str:
425
- rows = section.get("rows") or []
426
- x_key, y_key = _axis_keys(chart)
427
- if not x_key or not y_key:
428
- return '<p class="note">散点图配置缺少 xKey 或 yKey,已保留表格数据。</p>'
429
-
430
- points = []
431
- for row in rows:
432
- x_value = _number(row.get(x_key))
433
- y_value = _number(row.get(y_key))
434
- if x_value is None or y_value is None:
435
- continue
436
- points.append((x_value, y_value))
437
- if not points:
438
- return '<p class="note">图表数值为空,已保留表格数据。</p>'
439
-
440
- trimmed = points[:_max_items(chart, 80)]
441
- x_values = [x for x, _ in trimmed]
442
- y_values = [y for _, y in trimmed]
443
- min_x = min(x_values)
444
- max_x = max(x_values)
445
- min_y = min(y_values)
446
- max_y = max(y_values)
447
- if min_x == max_x:
448
- max_x = min_x + 1
449
- if min_y == max_y:
450
- max_y = min_y + 1
451
- width = 720
452
- height = 260
453
- left = 58
454
- right = 22
455
- top = 22
456
- bottom = 54
457
- plot_width = width - left - right
458
- plot_height = height - top - bottom
459
- x_label = _text(chart.get("xLabel") or chart.get("x_label") or _column_label(section, x_key, x_key))
460
- y_label = _text(chart.get("yLabel") or chart.get("y_label") or _column_label(section, y_key, y_key))
461
- title = _text(chart.get("title") or section.get("title") or "分析结果")
462
-
463
- point_nodes = []
464
- for x_value, y_value in trimmed:
465
- x = left + (x_value - min_x) / (max_x - min_x) * plot_width
466
- y = top + (max_y - y_value) / (max_y - min_y) * plot_height
467
- point_nodes.append(
468
- f' <circle class="chart-point" cx="{x:.2f}" cy="{y:.2f}" r="5" '
469
- f'aria-label="{_html(x_label)} {_html(_display_number(x_value))} {_html(y_label)} {_html(_display_number(y_value))}"></circle>'
470
- )
471
-
472
- more_note = ""
473
- if len(points) > len(trimmed):
474
- more_note = f' <p class="chart-note">图表展示前 {len(trimmed)} 项,其余数据保留在表格中。</p>'
475
- return "\n".join(
476
- [
477
- f' <div class="report-chart report-chart-scatter" role="img" aria-label="{_html(title)} 图表">',
478
- f' <div class="chart-title">{_html(title)} 图表</div>',
479
- f' <svg class="chart-svg" viewBox="0 0 {width} {height}" preserveAspectRatio="none">',
480
- f' <line class="chart-axis" x1="{left}" y1="{height - bottom}" x2="{width - right}" y2="{height - bottom}"></line>',
481
- f' <line class="chart-axis" x1="{left}" y1="{top}" x2="{left}" y2="{height - bottom}"></line>',
482
- f' <text class="chart-axis-title" x="{width / 2:.2f}" y="{height - 14}">{_html(x_label)}</text>',
483
- f' <text class="chart-axis-title chart-axis-title-y" x="16" y="{height / 2:.2f}">{_html(y_label)}</text>',
484
- "\n".join(point_nodes),
485
- " </svg>",
486
- more_note,
487
- " </div>",
488
- ]
489
- )
490
-
491
-
492
- def _chart(section: dict[str, Any]) -> str:
493
- chart = section.get("chart")
494
- if not isinstance(chart, dict):
495
- return ""
496
- chart_type = _chart_type(chart)
497
- if chart_type == "bar":
498
- return _bar_chart(section, chart)
499
- if chart_type == "line":
500
- return _line_chart(section, chart)
501
- if chart_type == "pie":
502
- return _pie_chart(section, chart)
503
- if chart_type == "scatter":
504
- return _scatter_chart(section, chart)
505
- if chart_type:
506
- return f'<p class="note">报表暂只支持 bar、line、pie、scatter 图表,{_html(chart_type)} 已以表格展示。</p>'
507
- return ""
508
-
509
-
510
- def _chart_toggle(section: dict[str, Any], chart_html: str, section_index: int) -> str:
511
- toggle_id = f"chart-toggle-{section_index}"
512
- chart_input_id = f"{toggle_id}-chart"
513
- table_input_id = f"{toggle_id}-table"
514
- return "\n".join(
515
- [
516
- f' <div class="chart-toggle" id="{toggle_id}">',
517
- ' <div class="chart-toggle-controls" role="tablist" aria-label="图表和表格切换">',
518
- f' <input id="{table_input_id}" name="{toggle_id}" type="radio" value="table" checked>',
519
- f' <label for="{table_input_id}">表格</label>',
520
- f' <input id="{chart_input_id}" name="{toggle_id}" type="radio" value="chart">',
521
- f' <label for="{chart_input_id}">图表</label>',
522
- " </div>",
523
- ' <div class="chart-panel chart-panel-table">',
524
- _table(section),
525
- " </div>",
526
- ' <div class="chart-panel chart-panel-chart">',
527
- chart_html,
528
- " </div>",
529
- " </div>",
530
- ]
531
- )
532
-
533
-
534
- def _sections(report: dict[str, Any]) -> str:
535
- rendered = []
536
- for idx, section in enumerate(report.get("sections") or []):
537
- title = _html(section.get("title") or "分析结果")
538
- description = section.get("description")
539
- paragraphs = section.get("paragraphs") or []
540
- chart_html = _chart(section)
541
- if chart_html:
542
- body = [_chart_toggle(section, chart_html, idx)]
543
- else:
544
- body = [_table(section)]
545
- if paragraphs:
546
- body = [f" <p>{_html(paragraph)}</p>" for paragraph in paragraphs] + body
547
- if section.get("note"):
548
- body.append(f' <p class="note">{_html(section.get("note"))}</p>')
549
- rendered.append(
550
- "\n".join(
551
- [
552
- ' <section class="section">',
553
- f" <h2>{title}</h2>",
554
- f' <p class="section-description">{_html(description)}</p>' if description else "",
555
- "\n".join(line for line in body if line),
556
- " </section>",
557
- ]
558
- )
559
- )
560
- if rendered:
561
- return "\n\n".join(rendered)
562
- return "\n".join(
563
- [
564
- ' <section class="section">',
565
- " <h2>分析结果</h2>",
566
- ' <p class="empty">暂无分析结果。</p>',
567
- " </section>",
568
- ]
569
- )
570
-
571
-
572
- def _formula_notes(notes: list[dict[str, Any]]) -> str:
573
- if not notes:
574
- return ' <p class="empty">暂无口径说明。</p>'
575
- blocks = []
576
- for note in notes[:12]:
577
- lines = [
578
- f"<strong>{_html(note.get('metric'))}:{_html(note.get('value'))}</strong>",
579
- f"算法:{_html(note.get('algorithm'))}",
580
- f"范围:{_html(note.get('range'))}",
581
- f"口径:{_html(note.get('definition'))}",
582
- ]
583
- limitations = note.get("limitations") or []
584
- if limitations:
585
- lines.append(f"限制:{_html(';'.join(_text(item) for item in limitations))}")
586
- blocks.append(
587
- ' <div class="formula">'
588
- + "".join(f'<div class="formula-line">{line}</div>' for line in lines)
589
- + "</div>"
590
- )
591
- return "\n".join(blocks)
592
-
593
-
594
- def _validate_report_contract(report: dict[str, Any]) -> None:
595
- if not report.get("kpis") and not report.get("sections"):
596
- raise ValueError(
597
- "report_data.json must contain at least one of top-level 'kpis' or 'sections'. "
598
- "Do not use legacy/free-form fields such as 'overview', 'personal_performance', "
599
- "'daily_trend', or 'status_distribution' as renderer input."
600
- )
601
- kpis = report.get("kpis")
602
- if kpis is not None:
603
- if not isinstance(kpis, list):
604
- raise ValueError("report_data.json field 'kpis' must be a list")
605
- for idx, item in enumerate(kpis):
606
- if not isinstance(item, dict) or not item.get("label") or "value" not in item:
607
- raise ValueError(f"report_data.json kpis[{idx}] must contain 'label' and 'value'")
608
- sections = report.get("sections")
609
- if sections is not None:
610
- if not isinstance(sections, list):
611
- raise ValueError("report_data.json field 'sections' must be a list")
612
- for idx, section in enumerate(sections):
613
- if not isinstance(section, dict):
614
- raise ValueError(f"report_data.json sections[{idx}] must be an object")
615
- rows = section.get("rows")
616
- if not isinstance(rows, list) or not rows:
617
- raise ValueError(f"report_data.json sections[{idx}].rows must be a non-empty list")
618
- columns = section.get("columns")
619
- if not isinstance(columns, list) or not columns:
620
- raise ValueError(f"report_data.json sections[{idx}].columns must be a non-empty list")
621
- for col_idx, column in enumerate(columns):
622
- if not isinstance(column, dict) or not column.get("key"):
623
- raise ValueError(
624
- f"report_data.json sections[{idx}].columns[{col_idx}] must contain 'key'"
625
- )
626
- chart = section.get("chart")
627
- if chart is not None:
628
- if not isinstance(chart, dict):
629
- raise ValueError(f"report_data.json sections[{idx}].chart must be an object")
630
- chart_type = _chart_type(chart)
631
- if chart_type not in SUPPORTED_CHART_TYPES:
632
- raise ValueError(
633
- f"report_data.json sections[{idx}].chart.chartType must be one of "
634
- "bar, line, pie, scatter"
635
- )
636
- if chart_type == "pie":
637
- if not (chart.get("nameKey") or chart.get("name_key") or chart.get("xKey") or chart.get("x_key")):
638
- raise ValueError(f"report_data.json sections[{idx}].chart.nameKey is required for pie")
639
- if not (
640
- chart.get("valueKey")
641
- or chart.get("value_key")
642
- or chart.get("yKey")
643
- or chart.get("y_key")
644
- ):
645
- raise ValueError(f"report_data.json sections[{idx}].chart.valueKey is required for pie")
646
- else:
647
- if not chart.get("xKey") and not chart.get("x_key"):
648
- raise ValueError(f"report_data.json sections[{idx}].chart.xKey is required")
649
- if not chart.get("yKey") and not chart.get("y_key"):
650
- raise ValueError(f"report_data.json sections[{idx}].chart.yKey is required")
651
-
652
-
653
- def _manifest_row_count(manifest: dict[str, Any]) -> int | None:
654
- row_counts = manifest.get("row_counts")
655
- if not isinstance(row_counts, dict) or not row_counts:
656
- return None
657
- data_count = row_counts.get("data")
658
- if isinstance(data_count, int) and not isinstance(data_count, bool):
659
- return data_count
660
- total = 0
661
- found = False
662
- for value in row_counts.values():
663
- if isinstance(value, int) and not isinstance(value, bool):
664
- total += value
665
- found = True
666
- return total if found else None
667
-
668
-
669
- def _apply_manifest_defaults(report: dict[str, Any], manifest: dict[str, Any]) -> None:
670
- if report.get("row_count") in (None, ""):
671
- row_count = _manifest_row_count(manifest)
672
- if row_count is not None:
673
- report["row_count"] = row_count
674
-
675
-
676
- def render_html(report: dict[str, Any], notes: list[dict[str, Any]]) -> str:
677
- _validate_report_contract(report)
678
- references_dir = _skill_dir() / "references" / "report"
679
- template = (references_dir / "report-template.html").read_text(encoding="utf-8")
680
- css = (references_dir / "report.css").read_text(encoding="utf-8")
681
- footer = report.get("footer") or "本报告由 Solvea Kit 基于当前账号授权范围内的工单快照生成,仅供运营分析使用。"
682
- replacements = {
683
- "{{REPORT_TITLE}}": _html(report.get("title") or "工单报表"),
684
- "{{REPORT_SUBTITLE}}": _html(report.get("subtitle") or "基于当前账号授权范围内的工单快照生成。"),
685
- "{{REPORT_CSS}}": css,
686
- "{{META_ITEMS}}": _meta_items(report, notes),
687
- "{{KPI_BLOCKS}}": _kpi_blocks(report),
688
- "{{SECTIONS}}": _sections(report),
689
- "{{FORMULA_NOTES}}": _formula_notes(notes),
690
- "{{FOOTER}}": _html(footer),
691
- }
692
- for placeholder, value in replacements.items():
693
- template = template.replace(placeholder, value)
694
- return template
695
-
696
-
697
- def main() -> int:
698
- parser = argparse.ArgumentParser(description="Render solvea-desk HTML from report_data.json")
699
- parser.add_argument("--task-id", required=True, help="Shulex Desk task id")
700
- parser.add_argument("--report-slug", default="", help="ASCII lowercase snake_case report slug")
701
- parser.add_argument("--report-data", default="", help="Path to report_data.json; defaults to state task dir")
702
- parser.add_argument("--notes", default="", help="Path to calculation_notes.json; defaults to state task dir")
703
- parser.add_argument("--output", default="", help="Output HTML path; defaults to output task dir")
704
- parser.add_argument("--validate-only", action="store_true", help="Validate inputs without writing HTML")
705
- args = parser.parse_args()
706
-
707
- state_task_dir = STATE_DIR / args.task_id
708
- output_task_dir = OUTPUTS_DIR / args.task_id
709
- report_data_path = Path(args.report_data) if args.report_data else state_task_dir / "report_data.json"
710
- notes_path = Path(args.notes) if args.notes else state_task_dir / "calculation_notes.json"
711
- manifest_path = state_task_dir / "manifest.json"
712
- report = _read_json(report_data_path, {})
713
- notes = _read_json(notes_path, [])
714
- manifest = _read_json(manifest_path, {})
715
- if not isinstance(report, dict):
716
- raise ValueError("report_data.json must contain a JSON object")
717
- if not isinstance(notes, list):
718
- raise ValueError("calculation_notes.json must contain a JSON array")
719
- if not isinstance(manifest, dict):
720
- manifest = {}
721
- _apply_manifest_defaults(report, manifest)
722
-
723
- slug = _validate_slug(args.report_slug or report.get("report_slug") or "desk_report")
724
- _validate_report_contract(report)
725
- if args.validate_only:
726
- print(
727
- json.dumps(
728
- {
729
- "ok": True,
730
- "validated": True,
731
- "task_id": args.task_id,
732
- "report_slug": slug,
733
- "report_data": str(report_data_path),
734
- "notes": str(notes_path),
735
- },
736
- ensure_ascii=False,
737
- indent=2,
738
- )
739
- )
740
- return 0
741
- output_path = Path(args.output) if args.output else output_task_dir / f"{slug}.html"
742
- output_path.parent.mkdir(parents=True, exist_ok=True)
743
- output_path.write_text(render_html(report, notes), encoding="utf-8")
744
-
745
- print(
746
- json.dumps(
747
- {
748
- "ok": True,
749
- "task_id": args.task_id,
750
- "report_slug": slug,
751
- "report": str(output_path),
752
- "report_data": str(report_data_path),
753
- "notes": str(notes_path),
754
- },
755
- ensure_ascii=False,
756
- indent=2,
757
- )
758
- )
759
- return 0
760
-
761
-
762
- if __name__ == "__main__":
763
- raise SystemExit(main())