@shulex/cxclawctl 0.1.2 → 0.1.4
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/README.md +25 -22
- package/capabilities.lock.json +535 -131
- package/dist/cli.js +2203 -127
- package/dist/cli.js.map +1 -1
- package/package.json +20 -6
- package/requirements-cli.txt +0 -2
- package/skills/__lib__/__init__.py +0 -0
- package/skills/__lib__/skill-runtime/bootstrap.py +0 -134
- package/skills/__lib__/solvea-api/__init__.py +0 -0
- package/skills/__lib__/solvea-api/client.py +0 -1944
- package/skills/__lib__/solvea-api/dashboard.py +0 -100
- package/skills/__lib__/solvea-api/solvea_api.py +0 -1341
- package/skills/agent-analytics/SKILL.md +0 -74
- package/skills/agent-analytics/references/chat-report-fields.md +0 -36
- package/skills/agent-analytics/references/report-fields.md +0 -130
- package/skills/agent-analytics/references/transcript.md +0 -110
- package/skills/agent-builder/SKILL.md +0 -42
- package/skills/agent-builder/interfaces/fallback-chatflow.md +0 -47
- package/skills/agent-builder/references/chatflow/graph-spec.md +0 -259
- package/skills/agent-builder/references/chatflow/node-selection.md +0 -89
- package/skills/agent-builder/references/chatflow/nodes/agent.md +0 -350
- package/skills/agent-builder/references/chatflow/nodes/business-track.md +0 -43
- package/skills/agent-builder/references/chatflow/nodes/code.md +0 -157
- package/skills/agent-builder/references/chatflow/nodes/collect-order.md +0 -47
- package/skills/agent-builder/references/chatflow/nodes/finish.md +0 -34
- package/skills/agent-builder/references/chatflow/nodes/human-review.md +0 -60
- package/skills/agent-builder/references/chatflow/nodes/if-else.md +0 -100
- package/skills/agent-builder/references/chatflow/nodes/intent.md +0 -79
- package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +0 -43
- package/skills/agent-builder/references/chatflow/nodes/llm.md +0 -117
- package/skills/agent-builder/references/chatflow/nodes/note.md +0 -38
- package/skills/agent-builder/references/chatflow/nodes/question.md +0 -97
- package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +0 -68
- package/skills/agent-builder/references/chatflow/nodes/reply.md +0 -97
- package/skills/agent-builder/references/chatflow/nodes/start.md +0 -35
- package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +0 -48
- package/skills/agent-builder/references/chatflow/nodes/tool.md +0 -138
- package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +0 -63
- package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +0 -52
- package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +0 -50
- package/skills/agent-builder/references/chatflow/nodes/variable-process.md +0 -70
- package/skills/agent-builder/references/chatflow/overview.md +0 -53
- package/skills/agent-builder/references/chatflow/visual-translation.md +0 -64
- package/skills/agent-builder/references/chatflow/workflow.md +0 -134
- package/skills/agent-builder/references/multi-agent/create-update.md +0 -185
- package/skills/agent-builder/references/multi-agent/graph-spec.md +0 -246
- package/skills/agent-builder/references/multi-agent/graph-structure.md +0 -12
- package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +0 -13
- package/skills/agent-builder/references/multi-agent/migration/large.md +0 -554
- package/skills/agent-builder/references/multi-agent/migration/principles.md +0 -446
- package/skills/agent-builder/references/multi-agent/migration/small.md +0 -225
- package/skills/agent-builder/references/multi-agent/orchestration.md +0 -11
- package/skills/agent-builder/references/multi-agent/overview.md +0 -48
- package/skills/agent-builder/references/multi-agent/update.md +0 -128
- package/skills/agent-builder/references/multi-agent/workflow.md +0 -87
- package/skills/agent-builder/references/single-agent/overview.md +0 -48
- package/skills/agent-builder/references/single-agent/prompt.md +0 -16
- package/skills/agent-builder/references/single-agent/tools.md +0 -36
- package/skills/agent-builder/references/single-agent/voice.md +0 -46
- package/skills/agent-builder/references/tools/http-tool.md +0 -315
- package/skills/agent-builder/references/tools/mock-tool.md +0 -279
- package/skills/agent-builder/references/tools/overview.md +0 -46
- package/skills/agent-builder/references/tools/tool-config.md +0 -43
- package/skills/agent-builder/references/tools/x-token.md +0 -34
- package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +0 -3458
- package/skills/agent-builder/scripts/chatflow/validate_graph.py +0 -1697
- package/skills/agent-builder/scripts/copy_marketplace_agent.py +0 -61
- package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +0 -5974
- package/skills/agent-builder/scripts/multi-agent/replay_compare.py +0 -679
- package/skills/agent-builder/scripts/patch_agent_tools.py +0 -135
- package/skills/agent-builder/scripts/tools/validate_tool_config.py +0 -384
- package/skills/agent-tester/SKILL.md +0 -35
- package/skills/agent-tester/batch-test/overview.md +0 -86
- package/skills/agent-tester/scripts/batch_test_sheet.py +0 -213
- package/skills/agent-tester/single-test/badcase-retest.md +0 -85
- package/skills/agent-tester/single-test/direct-test.md +0 -24
- package/skills/agent-tester/single-test/overview.md +0 -142
- package/skills/delivery/SKILL.md +0 -112
- package/skills/delivery/interfaces/classify.md +0 -38
- package/skills/delivery/interfaces/create-agents.md +0 -89
- package/skills/delivery/interfaces/delivery-test.md +0 -67
- package/skills/delivery/references/flow.md +0 -40
- package/skills/delivery/references/validation.md +0 -15
- package/skills/delivery/scripts/batch_test.py +0 -704
- package/skills/delivery/scripts/classify.py +0 -22
- package/skills/delivery/scripts/create_agents.py +0 -695
- package/skills/delivery/scripts/gen_delivery_report.py +0 -335
- package/skills/delivery/scripts/gen_excel.py +0 -234
- package/skills/delivery/scripts/gen_report.py +0 -208
- package/skills/delivery/scripts/plan.py +0 -163
- package/skills/delivery/scripts/render.py +0 -713
- package/skills/delivery/scripts/render_summary.py +0 -38
- package/skills/delivery/scripts/sample_history_cases.py +0 -109
- package/skills/kb-builder/SKILL.md +0 -80
- package/skills/kb-builder/interfaces/qa-upload.md +0 -55
- package/skills/kb-builder/interfaces/web-kb.md +0 -84
- package/skills/kb-builder/references/directory.md +0 -373
- package/skills/kb-builder/references/export.md +0 -282
- package/skills/kb-builder/references/isolation-keywords.md +0 -113
- package/skills/kb-builder/references/knowledge.md +0 -153
- package/skills/kb-builder/references/product.md +0 -52
- package/skills/kb-builder/references/qa-from-help-center.md +0 -58
- package/skills/kb-builder/references/qa-from-pdf.md +0 -94
- package/skills/kb-builder/references/qa-from-tickets.md +0 -168
- package/skills/kb-builder/references/qa-from-web.md +0 -98
- package/skills/kb-builder/references/qa-quality.md +0 -73
- package/skills/kb-builder/references/reply-template.md +0 -237
- package/skills/kb-builder/references/tag-isolation-migration.md +0 -61
- package/skills/kb-builder/references/tags.md +0 -45
- package/skills/kb-builder/references/upload.md +0 -130
- package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +0 -333
- package/skills/kb-builder/scripts/build_directory_plan.py +0 -144
- package/skills/kb-builder/scripts/crawl_site.py +0 -433
- package/skills/kb-builder/scripts/create_dirs.py +0 -146
- package/skills/kb-builder/scripts/export_knowledge.py +0 -454
- package/skills/kb-builder/scripts/extract_ticket_qa.py +0 -441
- package/skills/kb-builder/scripts/extract_web_qa.py +0 -118
- package/skills/kb-builder/scripts/import_qa.py +0 -219
- package/skills/kb-builder/scripts/import_source_documents.py +0 -143
- package/skills/kb-builder/scripts/import_templates.py +0 -409
- package/skills/poc/SKILL.md +0 -125
- package/skills/poc/assets/default_manual.css +0 -279
- package/skills/poc/assets/notice_escalate.png +0 -0
- package/skills/poc/assets/notice_escalate2.png +0 -0
- package/skills/poc/assets/notice_new_chat.png +0 -0
- package/skills/poc/assets/poc_test_template.html +0 -645
- package/skills/poc/assets/poc_test_template.md +0 -104
- package/skills/poc/assets/step3_preview.png +0 -0
- package/skills/poc/assets/step4_order_note.png +0 -0
- package/skills/poc/interfaces/classify.md +0 -38
- package/skills/poc/interfaces/create-agents.md +0 -108
- package/skills/poc/interfaces/poc-test.md +0 -94
- package/skills/poc/references/flow.md +0 -47
- package/skills/poc/references/kb-directory.md +0 -22
- package/skills/poc/references/validation.md +0 -17
- package/skills/poc/scripts/classify.py +0 -22
- package/skills/poc/scripts/create_agents.py +0 -818
- package/skills/poc/scripts/gen_excel.py +0 -234
- package/skills/poc/scripts/gen_poc_manual.py +0 -249
- package/skills/poc/scripts/gen_report.py +0 -208
- package/skills/poc/scripts/inspect_chatflow.py +0 -209
- package/skills/poc/scripts/plan.py +0 -178
- package/skills/poc/scripts/poc_test.py +0 -495
- package/skills/poc/scripts/render.py +0 -713
- package/skills/poc/scripts/render_summary.py +0 -41
- package/skills/runtime-inspector/SKILL.md +0 -51
- package/skills/runtime-inspector/references/frontend-rendering.md +0 -126
- package/skills/runtime-inspector/references/model.md +0 -59
- package/skills/runtime-inspector/references/sandbox.md +0 -131
- package/skills/runtime-inspector/scripts/model_inspect.py +0 -68
- package/skills/runtime-inspector/scripts/prepare.py +0 -98
- package/skills/runtime-inspector/scripts/process.py +0 -84
- package/skills/runtime-inspector/scripts/read_state.py +0 -32
- package/skills/runtime-inspector/scripts/sandbox_inspect.py +0 -463
- package/skills/runtime-inspector/scripts/test_claude_proxy.py +0 -49
- package/skills/runtime-inspector/scripts/test_openai_proxy.py +0 -41
- package/skills/runtime-inspector/scripts/verify_persistence.py +0 -101
- package/skills/runtime-inspector/scripts/write_test.py +0 -41
- package/skills/setup/SKILL.md +0 -17
- package/skills/shulex-support/SKILL.md +0 -39
- package/skills/shulex-support/references/badcase-analysis/overview.md +0 -34
- package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +0 -105
- package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +0 -89
- package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +0 -36
- package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +0 -53
- package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +0 -259
- package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +0 -148
- package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +0 -57
- package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +0 -120
- package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +0 -126
- package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +0 -121
- package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +0 -208
- package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +0 -115
- package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +0 -72
- package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +0 -92
- package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +0 -147
- package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +0 -283
- package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +0 -77
- package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +0 -19
- package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +0 -23
- package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +0 -31
- package/skills/shulex-support/references/product-docs.md +0 -70
- package/skills/shulex-support/references/tickets.md +0 -184
- package/skills/shulex-support/scripts/support.py +0 -427
- package/skills/solvea-desk/SKILL.md +0 -39
- package/skills/solvea-desk/references/report/fields.md +0 -82
- package/skills/solvea-desk/references/report/report-design.md +0 -200
- package/skills/solvea-desk/references/report/report-template.html +0 -44
- package/skills/solvea-desk/references/report/report.css +0 -517
- package/skills/solvea-desk/references/report/workflow.md +0 -464
- package/skills/solvea-desk/references/tickets/query.md +0 -103
- package/skills/solvea-desk/references/tickets/tags.md +0 -83
- package/skills/solvea-desk/scripts/report/fetch_sources.py +0 -580
- package/skills/solvea-desk/scripts/report/render_report.py +0 -763
- package/skills/solvea-desk/scripts/tickets/desk_api.py +0 -73
- package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +0 -403
- package/skills/solvea-desk/scripts/tickets/ticket_tags.py +0 -305
- package/skills/solvea-kit/SKILL.md +0 -84
- package/skills/solvea-kit/references/agent.md +0 -145
- package/skills/solvea-kit/references/chat.md +0 -244
- package/skills/solvea-kit/references/intent.md +0 -69
- package/skills/solvea-kit/references/plugin.md +0 -53
- package/skills/solvea-kit/references/quality-inspection.md +0 -75
- package/skills/solvea-kit/references/strategy.md +0 -62
- package/skills/solvea-kit/references/tagging.md +0 -231
- package/skills/solvea-kit/references/variables.md +0 -53
- package/skills/solvea-kit/scripts/generate_label_import_file.py +0 -151
- 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())
|