@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.
Files changed (208) hide show
  1. package/README.md +25 -22
  2. package/capabilities.lock.json +535 -131
  3. package/dist/cli.js +2203 -127
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +20 -6
  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,679 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Replay one synthetic or historical case against old/new agents and compare behavior.
3
-
4
- Default mode is dry-run. Pass --execute to send chat-stream requests.
5
- Use --synthetic for a quick smoke test, or fetch one real transcript by
6
- --source-chat-id, --source-agent-id, or --intent. --cases-file is a replay
7
- cache / override. Use --save-cases-file to persist generated/fetched cases.
8
-
9
- Cases file schema:
10
-
11
- {
12
- "cases": [
13
- {
14
- "id": "order_not_found",
15
- "messages": ["Where is order 123?"],
16
- "metadata": {"customer_email": "x@example.com"},
17
- "expected": {
18
- "handoff": true,
19
- "contains": ["order"],
20
- "not_contains": ["{{Template_Reply}}"]
21
- }
22
- }
23
- ]
24
- }
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import argparse
30
- import difflib
31
- import html
32
- import json
33
- import os
34
- import sys
35
- import uuid
36
- from pathlib import Path
37
- from typing import Any
38
-
39
- _solvea_lib_dir = os.environ.get("SOLVEA_LIB_DIR")
40
- if not _solvea_lib_dir:
41
- cur = os.path.abspath(os.path.dirname(__file__))
42
- while True:
43
- candidate = os.path.join(cur, "__lib__")
44
- if os.path.isdir(candidate):
45
- _solvea_lib_dir = candidate
46
- break
47
- parent = os.path.dirname(cur)
48
- if parent == cur:
49
- raise RuntimeError("Cannot locate skills __lib__")
50
- cur = parent
51
- sys.path.insert(0, os.path.join(_solvea_lib_dir, "skill-runtime"))
52
- from bootstrap import * # noqa: F401,F403
53
- from client import SolveaClient
54
-
55
-
56
- def _read_json(path: Path) -> Any:
57
- return json.loads(path.read_text(encoding="utf-8"))
58
-
59
-
60
- def _write_json(path: Path, value: Any) -> None:
61
- path.parent.mkdir(parents=True, exist_ok=True)
62
- path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
63
-
64
-
65
- def _norm_text(value: Any) -> str:
66
- if value is None:
67
- return ""
68
- return " ".join(str(value).split())
69
-
70
-
71
- def _coerce_list_payload(value: Any) -> list[Any]:
72
- if isinstance(value, list):
73
- return value
74
- if not isinstance(value, dict):
75
- return []
76
- for key in ("list", "records", "rows", "data", "items"):
77
- nested = value.get(key)
78
- if isinstance(nested, list):
79
- return nested
80
- if isinstance(nested, dict):
81
- for nested_key in ("list", "records", "rows", "items"):
82
- nested_list = nested.get(nested_key)
83
- if isinstance(nested_list, list):
84
- return nested_list
85
- return []
86
-
87
-
88
- def _safe_metadata(value: Any) -> dict[str, Any]:
89
- if not isinstance(value, dict):
90
- return {}
91
- out: dict[str, Any] = {}
92
- for key, item in value.items():
93
- if key in {
94
- "displayData",
95
- "displayPrompt",
96
- "chatflow_run_info",
97
- "createdAt",
98
- "updatedAt",
99
- "createdBy",
100
- "updatedBy",
101
- }:
102
- continue
103
- if isinstance(item, (str, int, float, bool)) or item is None:
104
- out[str(key)] = item
105
- elif isinstance(item, list) and len(item) <= 20 and all(isinstance(v, (str, int, float, bool)) or v is None for v in item):
106
- out[str(key)] = item
107
- elif isinstance(item, dict) and len(item) <= 20:
108
- nested = _safe_metadata(item)
109
- if nested:
110
- out[str(key)] = nested
111
- return out
112
-
113
-
114
- def _content_from_message(item: dict[str, Any]) -> str:
115
- for key in ("content", "message", "text", "question", "query"):
116
- value = item.get(key)
117
- if isinstance(value, str) and value.strip():
118
- return value.strip()
119
- if isinstance(value, dict):
120
- for nested_key in ("text", "content", "message"):
121
- nested = value.get(nested_key)
122
- if isinstance(nested, str) and nested.strip():
123
- return nested.strip()
124
- return ""
125
-
126
-
127
- def _is_user_message(item: dict[str, Any]) -> bool:
128
- role_values = [
129
- item.get("role"),
130
- item.get("type"),
131
- item.get("sender"),
132
- item.get("senderRole"),
133
- item.get("messageType"),
134
- item.get("from"),
135
- ]
136
- role_text = " ".join(str(value).upper() for value in role_values if value is not None)
137
- if any(token in role_text for token in ("USER", "CUSTOMER", "VISITOR", "BUYER")):
138
- return True
139
- if any(token in role_text for token in ("ASSISTANT", "AGENT", "BOT", "SYSTEM", "AI")):
140
- return False
141
- return bool(_content_from_message(item)) and not any(key in item for key in ("answer", "reply", "response"))
142
-
143
-
144
- def _extract_transcript_messages(detail: Any) -> tuple[list[str], dict[str, Any]]:
145
- if not isinstance(detail, dict):
146
- return [], {}
147
-
148
- metadata: dict[str, Any] = {}
149
- for key in ("metadata", "meta", "variables"):
150
- metadata = _safe_metadata(detail.get(key))
151
- if metadata:
152
- break
153
- if not metadata and isinstance(detail.get("context"), dict):
154
- context = detail["context"]
155
- metadata = _safe_metadata(context.get("metadata") or context.get("meta") or context.get("ext"))
156
-
157
- candidates: list[Any] = []
158
- for key in ("turns", "messages", "messageList", "records", "conversation", "history"):
159
- if isinstance(detail.get(key), list):
160
- candidates = detail[key]
161
- break
162
- if not candidates and isinstance(detail.get("data"), dict):
163
- data = detail["data"]
164
- for key in ("turns", "messages", "messageList", "records", "conversation", "history"):
165
- if isinstance(data.get(key), list):
166
- candidates = data[key]
167
- break
168
- if not metadata:
169
- metadata = _safe_metadata(data.get("metadata") or data.get("meta"))
170
- if not candidates:
171
- candidates = _coerce_list_payload(detail)
172
-
173
- messages: list[str] = []
174
- for item in candidates:
175
- if not isinstance(item, dict):
176
- continue
177
- if not metadata:
178
- metadata = _safe_metadata(item.get("metadata") or item.get("meta"))
179
- content = _content_from_message(item)
180
- if content and _is_user_message(item):
181
- messages.append(content)
182
- return messages, metadata
183
-
184
-
185
- def _case_from_transcript(client: SolveaClient, chat_id: str, *, source_type: str, source_label: str | None = None) -> dict[str, Any]:
186
- detail = client.get_transcript(chat_id)
187
- messages, metadata = _extract_transcript_messages(detail)
188
- if not messages:
189
- raise ValueError(f"no USER messages found in transcript chatId={chat_id}")
190
- return {
191
- "id": f"{source_type}_{chat_id}",
192
- "source": {
193
- "type": source_type,
194
- "chatId": chat_id,
195
- "label": source_label,
196
- },
197
- "messages": messages,
198
- "metadata": metadata,
199
- "expected": {
200
- "not_contains": ["{{Template_Reply}}", "<span"],
201
- },
202
- }
203
-
204
-
205
- def _case_from_synthetic_args(args: argparse.Namespace) -> dict[str, Any]:
206
- messages = args.synthetic_message or ["Where is my order 123456?"]
207
- metadata: dict[str, Any] = {}
208
- if args.synthetic_metadata:
209
- parsed = json.loads(args.synthetic_metadata)
210
- if not isinstance(parsed, dict):
211
- raise ValueError("--synthetic-metadata must be a JSON object")
212
- metadata = _safe_metadata(parsed)
213
- return {
214
- "id": args.synthetic_case_id or "synthetic_order_status_smoke",
215
- "source": {
216
- "type": "synthetic",
217
- "label": "smoke_test",
218
- },
219
- "messages": [str(message) for message in messages if str(message).strip()],
220
- "metadata": metadata,
221
- "expected": {
222
- "not_contains": ["{{Template_Reply}}", "<span"],
223
- },
224
- }
225
-
226
-
227
- def _fetch_cases_from_source(client: SolveaClient, args: argparse.Namespace) -> list[dict[str, Any]]:
228
- source_count = sum(bool(value) for value in (args.synthetic, args.source_chat_id, args.source_agent_id, args.intent))
229
- if source_count != 1:
230
- raise ValueError("provide exactly one source: --synthetic, --source-chat-id, --source-agent-id, or --intent")
231
-
232
- if args.synthetic:
233
- return [_case_from_synthetic_args(args)]
234
-
235
- if args.source_chat_id:
236
- return [_case_from_transcript(client, args.source_chat_id, source_type="chat_id")]
237
-
238
- if args.intent:
239
- rows = client.get_conversations_by_intent(args.intent, limit=args.limit)
240
- cases = []
241
- for row in rows[: args.limit]:
242
- if not isinstance(row, dict):
243
- continue
244
- chat_id = row.get("source_chat_id") or row.get("chatId") or row.get("id")
245
- messages = row.get("messages") if isinstance(row.get("messages"), list) else []
246
- if chat_id and messages:
247
- cases.append(
248
- {
249
- "id": f"intent_{args.intent}_{chat_id}",
250
- "source": {"type": "intent", "chatId": chat_id, "label": args.intent},
251
- "messages": [str(message) for message in messages if str(message).strip()],
252
- "metadata": _safe_metadata(row.get("metadata")),
253
- "expected": {"not_contains": ["{{Template_Reply}}", "<span"]},
254
- }
255
- )
256
- if not cases:
257
- raise ValueError(f"no historical conversations found for intent={args.intent}")
258
- return cases
259
-
260
- filters: dict[str, Any] = {"agentId": int(args.source_agent_id)}
261
- if args.source_touch_point:
262
- filters["touchpoint"] = args.source_touch_point
263
- rows_resp = client.list_transcripts(
264
- page_size=args.limit,
265
- page_index=1,
266
- debug=True,
267
- begin_ts=args.begin_ts,
268
- end_ts=args.end_ts,
269
- **filters,
270
- )
271
- rows = _coerce_list_payload(rows_resp)
272
- cases = []
273
- for row in rows[: args.limit]:
274
- if not isinstance(row, dict):
275
- continue
276
- chat_id = row.get("chatId") or row.get("id")
277
- if chat_id:
278
- cases.append(_case_from_transcript(client, str(chat_id), source_type="agent_id", source_label=str(args.source_agent_id)))
279
- if not cases:
280
- raise ValueError(f"no transcript found for source agentId={args.source_agent_id}")
281
- return cases
282
-
283
-
284
- def _content_similarity(left: str, right: str) -> float:
285
- left = _norm_text(left)
286
- right = _norm_text(right)
287
- if not left and not right:
288
- return 1.0
289
- if not left or not right:
290
- return 0.0
291
- return round(difflib.SequenceMatcher(None, left, right).ratio(), 4)
292
-
293
-
294
- def _extract_route(result: dict[str, Any]) -> dict[str, Any]:
295
- context = result.get("context") if isinstance(result.get("context"), dict) else {}
296
- ext = context.get("ext") if isinstance(context.get("ext"), dict) else {}
297
- agent = context.get("agent") if isinstance(context.get("agent"), dict) else {}
298
- run_info = ext.get("chatflow_run_info")
299
- final_node = {}
300
- if isinstance(run_info, list):
301
- for event in reversed(run_info):
302
- if not isinstance(event, dict):
303
- continue
304
- event_data = event.get("eventData") if isinstance(event.get("eventData"), dict) else {}
305
- if event.get("event") == "node_finished" or event_data.get("node_id"):
306
- final_node = event_data
307
- break
308
- elif isinstance(run_info, dict):
309
- final_node = run_info
310
- return {
311
- "agentId": agent.get("id") or agent.get("agentId"),
312
- "agentName": agent.get("name") or ext.get("invoke_agent_name"),
313
- "strategyName": result.get("strategyName"),
314
- "nodeId": final_node.get("node_id") or final_node.get("nodeId"),
315
- "nodeName": final_node.get("title") or final_node.get("node_name") or final_node.get("nodeName"),
316
- "nodeType": final_node.get("category") or final_node.get("node_type") or final_node.get("nodeType"),
317
- }
318
-
319
-
320
- def _extract_node_runs(result: dict[str, Any]) -> dict[str, Any]:
321
- context = result.get("context") if isinstance(result.get("context"), dict) else {}
322
- ext = context.get("ext") if isinstance(context.get("ext"), dict) else {}
323
- run_info = ext.get("chatflow_run_info", [])
324
- node_runs = []
325
- errors = []
326
- status = None
327
- if isinstance(run_info, list):
328
- for event in run_info:
329
- if not isinstance(event, dict):
330
- continue
331
- event_data = event.get("eventData") if isinstance(event.get("eventData"), dict) else {}
332
- if event.get("event") == "chat_finished":
333
- status = event_data.get("status", "")
334
- if isinstance(event_data.get("error"), dict):
335
- errors.append(
336
- {
337
- "nodeId": event_data.get("node_id") or event_data.get("nodeId"),
338
- "name": event_data["error"].get("name", ""),
339
- "detail": str(event_data["error"].get("detail", ""))[:300],
340
- }
341
- )
342
- if event.get("event") == "node_finished":
343
- node_runs.append(
344
- {
345
- "nodeId": event_data.get("node_id", ""),
346
- "nodeName": event_data.get("title", ""),
347
- "nodeType": event_data.get("category", ""),
348
- "status": event_data.get("status", ""),
349
- "outHandle": event_data.get("out_handle") or event_data.get("outHandle"),
350
- }
351
- )
352
- return {
353
- "status": status,
354
- "nodeRuns": node_runs,
355
- "nodePath": [
356
- {
357
- "nodeId": item.get("nodeId"),
358
- "nodeName": item.get("nodeName"),
359
- "nodeType": item.get("nodeType"),
360
- "status": item.get("status"),
361
- "outHandle": item.get("outHandle"),
362
- }
363
- for item in node_runs
364
- ],
365
- "nodePathUnavailable": not bool(node_runs),
366
- "errors": errors,
367
- }
368
-
369
-
370
- def _summarize_result(result: dict[str, Any]) -> dict[str, Any]:
371
- execution = _extract_node_runs(result)
372
- return {
373
- "chatId": result.get("chatId"),
374
- "content": result.get("content") or "",
375
- "reason": result.get("reason") or result.get("noReplyReason"),
376
- "handoff": result.get("handoff"),
377
- "type": result.get("type"),
378
- "isEnd": result.get("isEnd"),
379
- "route": _extract_route(result),
380
- "execution": execution,
381
- "nodePath": execution["nodePath"],
382
- "nodePathUnavailable": execution["nodePathUnavailable"],
383
- }
384
-
385
-
386
- def _expected_checks(expected: dict[str, Any], new_result: dict[str, Any]) -> list[dict[str, Any]]:
387
- checks = []
388
- content = new_result.get("content") or ""
389
- if "handoff" in expected:
390
- checks.append(
391
- {
392
- "name": "expected_handoff",
393
- "pass": new_result.get("handoff") == expected["handoff"],
394
- "expected": expected["handoff"],
395
- "actual": new_result.get("handoff"),
396
- }
397
- )
398
- for item in expected.get("contains", []) or []:
399
- checks.append({"name": f"contains:{item}", "pass": str(item) in content})
400
- for item in expected.get("not_contains", []) or []:
401
- checks.append({"name": f"not_contains:{item}", "pass": str(item) not in content})
402
- route = new_result.get("route") or {}
403
- for key in ("agentName", "nodeName", "nodeType"):
404
- expected_key = f"route_{key}"
405
- if expected_key in expected:
406
- checks.append(
407
- {
408
- "name": expected_key,
409
- "pass": route.get(key) == expected[expected_key],
410
- "expected": expected[expected_key],
411
- "actual": route.get(key),
412
- }
413
- )
414
- return checks
415
-
416
-
417
- def _side_metadata(base_metadata: dict[str, Any], agent_id: str | None) -> dict[str, Any]:
418
- metadata = dict(base_metadata or {})
419
- if agent_id:
420
- metadata["specified_agent_id"] = int(agent_id)
421
- return metadata
422
-
423
-
424
- def _run_side(
425
- client: SolveaClient,
426
- *,
427
- bot_id: str | None,
428
- agent_id: str | None,
429
- touch_point: str,
430
- case: dict[str, Any],
431
- side: str,
432
- ) -> dict[str, Any]:
433
- chat_id = f"replay-{case.get('id') or uuid.uuid4().hex}-{side}-{uuid.uuid4().hex[:8]}"
434
- final = {}
435
- metadata = _side_metadata(case.get("metadata") or {}, agent_id)
436
- for message in case.get("messages", []) or []:
437
- final = client.chat_stream(
438
- message=message,
439
- bot_id=bot_id,
440
- chat_id=chat_id,
441
- touch_point=touch_point,
442
- metadata=metadata,
443
- option={"debug": True, "migrationReplay": True},
444
- )
445
- return _summarize_result(final if isinstance(final, dict) else {})
446
-
447
-
448
- def _compare_case(old_result: dict[str, Any], new_result: dict[str, Any], expected: dict[str, Any], threshold: float) -> dict[str, Any]:
449
- similarity = _content_similarity(old_result.get("content", ""), new_result.get("content", ""))
450
- checks = _expected_checks(expected or {}, new_result)
451
- route_old = old_result.get("route") or {}
452
- route_new = new_result.get("route") or {}
453
- issues = []
454
- if similarity < threshold:
455
- issues.append("content_similarity_low")
456
- if old_result.get("handoff") != new_result.get("handoff"):
457
- issues.append("handoff_changed")
458
- if old_result.get("type") != new_result.get("type"):
459
- issues.append("type_changed")
460
- if route_old.get("agentName") and route_new.get("agentName") and route_old.get("agentName") != route_new.get("agentName"):
461
- issues.append("agent_route_changed")
462
- if any(not check["pass"] for check in checks):
463
- issues.append("expected_check_failed")
464
- return {
465
- "contentSimilarity": similarity,
466
- "handoffEqual": old_result.get("handoff") == new_result.get("handoff"),
467
- "typeEqual": old_result.get("type") == new_result.get("type"),
468
- "routeOld": route_old,
469
- "routeNew": route_new,
470
- "expectedChecks": checks,
471
- "issues": issues,
472
- "pass": not issues,
473
- }
474
-
475
-
476
- def _render_html(report: dict[str, Any]) -> str:
477
- rows = []
478
- for case in report.get("cases", []):
479
- status = "PASS" if case.get("compare", {}).get("pass") else "REVIEW"
480
- issues = ", ".join(case.get("compare", {}).get("issues", []))
481
- node_path = case.get("new", {}).get("nodePath") or []
482
- node_path_text = "\n".join(
483
- f"{idx + 1}. {item.get('nodeType') or ''} {item.get('nodeName') or item.get('nodeId') or ''} [{item.get('status') or ''}]"
484
- for idx, item in enumerate(node_path)
485
- )
486
- if not node_path_text and case.get("new", {}).get("nodePathUnavailable"):
487
- node_path_text = "node_path_unavailable"
488
- rows.append(
489
- "<tr>"
490
- f"<td>{html.escape(str(case.get('id')))}</td>"
491
- f"<td>{status}</td>"
492
- f"<td>{case.get('compare', {}).get('contentSimilarity')}</td>"
493
- f"<td>{html.escape(issues)}</td>"
494
- f"<td><pre>{html.escape(node_path_text)}</pre></td>"
495
- f"<td><pre>{html.escape(_norm_text(case.get('old', {}).get('content')))}</pre></td>"
496
- f"<td><pre>{html.escape(_norm_text(case.get('new', {}).get('content')))}</pre></td>"
497
- "</tr>"
498
- )
499
- return """<!doctype html>
500
- <html lang="zh-CN">
501
- <head>
502
- <meta charset="utf-8" />
503
- <title>Multi-agent Replay Compare</title>
504
- <style>
505
- body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 24px; color: #172033; }}
506
- table {{ border-collapse: collapse; width: 100%; }}
507
- th, td {{ border: 1px solid #d0d5dd; padding: 8px; vertical-align: top; }}
508
- th {{ background: #f2f4f7; }}
509
- pre {{ white-space: pre-wrap; max-width: 520px; }}
510
- </style>
511
- </head>
512
- <body>
513
- <h1>Multi-agent Replay Compare</h1>
514
- <p>Mode: {mode}</p>
515
- <p>Total: {total}, Pass: {passed}, Review: {review}</p>
516
- <table>
517
- <thead><tr><th>Case</th><th>Status</th><th>Similarity</th><th>Issues</th><th>New Node Path</th><th>Old</th><th>New</th></tr></thead>
518
- <tbody>{rows}</tbody>
519
- </table>
520
- </body>
521
- </html>
522
- """.format(
523
- mode=html.escape(report.get("mode", "")),
524
- total=report.get("summary", {}).get("total", 0),
525
- passed=report.get("summary", {}).get("pass", 0),
526
- review=report.get("summary", {}).get("review", 0),
527
- rows="\n".join(rows),
528
- )
529
-
530
-
531
- def _select_cases(cases: list[dict[str, Any]], args: argparse.Namespace) -> list[dict[str, Any]]:
532
- if args.case_id:
533
- selected = [case for case in cases if str(case.get("id")) == str(args.case_id)]
534
- if not selected:
535
- raise ValueError(f"case_id not found: {args.case_id}")
536
- return selected[:1]
537
- return cases[: args.limit]
538
-
539
-
540
- def run(args: argparse.Namespace) -> dict[str, Any]:
541
- source_client = None
542
- if args.cases_file:
543
- payload = _read_json(Path(args.cases_file))
544
- cases = payload.get("cases", payload if isinstance(payload, list) else [])
545
- else:
546
- source_client = None if args.synthetic else SolveaClient(env=args.env, project_id=args.project_id)
547
- cases = _fetch_cases_from_source(source_client, args)
548
- if not isinstance(cases, list):
549
- raise ValueError("cases file must be a JSON array or an object with cases[]")
550
- cases = _select_cases(cases, args)
551
- if args.save_cases_file:
552
- _write_json(Path(args.save_cases_file), {"cases": cases})
553
-
554
- report_cases = []
555
- client = source_client if args.execute and source_client is not None else SolveaClient(env=args.env, project_id=args.project_id) if args.execute else None
556
- for case in cases:
557
- if not isinstance(case, dict):
558
- continue
559
- case_id = case.get("id") or f"case_{len(report_cases):03d}"
560
- if args.execute:
561
- old_result = _run_side(
562
- client,
563
- bot_id=args.old_bot_id,
564
- agent_id=args.old_agent_id,
565
- touch_point=args.touch_point,
566
- case={**case, "id": case_id},
567
- side="old",
568
- )
569
- new_result = _run_side(
570
- client,
571
- bot_id=args.new_bot_id or args.old_bot_id,
572
- agent_id=args.new_agent_id,
573
- touch_point=args.touch_point,
574
- case={**case, "id": case_id},
575
- side="new",
576
- )
577
- else:
578
- old_result = _summarize_result({})
579
- new_result = _summarize_result({})
580
- compare = _compare_case(old_result, new_result, case.get("expected") or {}, args.similarity_threshold)
581
- report_cases.append(
582
- {
583
- "id": case_id,
584
- "source": case.get("source"),
585
- "messages": case.get("messages", []),
586
- "metadataKeys": sorted((case.get("metadata") or {}).keys()),
587
- "old": old_result,
588
- "new": new_result,
589
- "compare": compare,
590
- }
591
- )
592
-
593
- passed = sum(1 for case in report_cases if case.get("compare", {}).get("pass"))
594
- report = {
595
- "mode": "execute" if args.execute else "dry-run",
596
- "env": args.env,
597
- "caseSource": "cases_file" if args.cases_file else "synthetic" if args.synthetic else "transcript_api",
598
- "savedCasesFile": args.save_cases_file,
599
- "old": {"botId": args.old_bot_id, "agentId": args.old_agent_id},
600
- "new": {"botId": args.new_bot_id or args.old_bot_id, "agentId": args.new_agent_id},
601
- "summary": {"total": len(report_cases), "pass": passed, "review": len(report_cases) - passed},
602
- "cases": report_cases,
603
- }
604
- return report
605
-
606
-
607
- def build_parser() -> argparse.ArgumentParser:
608
- parser = argparse.ArgumentParser(description=__doc__)
609
- parser.add_argument("--cases-file", help="Optional replay cache. If omitted, generate/fetch one case from the selected source.")
610
- parser.add_argument("--save-cases-file", help="Persist generated/fetched cases so the same case can be replayed later.")
611
- parser.add_argument("--synthetic", action="store_true", help="Generate one synthetic smoke-test case instead of fetching transcript APIs.")
612
- parser.add_argument("--synthetic-case-id", help="Case id for --synthetic. Default: synthetic_order_status_smoke.")
613
- parser.add_argument(
614
- "--synthetic-message",
615
- action="append",
616
- help="User message for --synthetic. Can be repeated for multi-turn. Default: order status smoke message.",
617
- )
618
- parser.add_argument("--synthetic-metadata", help="JSON object metadata for --synthetic.")
619
- parser.add_argument("--source-chat-id", help="Fetch exactly this historical chatId and build one replay case.")
620
- parser.add_argument("--source-agent-id", help="Fetch one historical transcript that matched the old MULTI_AGENT agentId.")
621
- parser.add_argument("--intent", help="Fetch one historical transcript by intent name.")
622
- parser.add_argument("--old-bot-id")
623
- parser.add_argument("--new-bot-id")
624
- parser.add_argument("--old-agent-id")
625
- parser.add_argument("--new-agent-id")
626
- parser.add_argument("--env", choices=["staging", "prod"], default=os.environ.get("SOLVEA_ENV", "staging"))
627
- parser.add_argument("--project-id")
628
- parser.add_argument("--touch-point", default="LIVE_CHAT")
629
- parser.add_argument("--source-touch-point", help="Optional touchpoint filter when using --source-agent-id.")
630
- parser.add_argument("--begin-ts", type=int, help="Optional begin timestamp in ms when using --source-agent-id.")
631
- parser.add_argument("--end-ts", type=int, help="Optional end timestamp in ms when using --source-agent-id.")
632
- parser.add_argument("--similarity-threshold", type=float, default=0.55)
633
- parser.add_argument("--case-id", help="Run exactly one case by id.")
634
- parser.add_argument("--limit", type=int, default=1, help="Number of cases to run when --case-id is not set. Default: 1.")
635
- parser.add_argument("--execute", action="store_true", help="Actually call chat-stream. Default is dry-run.")
636
- parser.add_argument("--out-json", required=True)
637
- parser.add_argument("--out-html")
638
- return parser
639
-
640
-
641
- def main() -> None:
642
- args = build_parser().parse_args()
643
- if not args.cases_file and not (args.synthetic or args.source_chat_id or args.source_agent_id or args.intent):
644
- raise ValueError("provide --cases-file or one source: --synthetic, --source-chat-id, --source-agent-id, or --intent")
645
- if args.execute and not (args.old_bot_id or args.old_agent_id or args.new_agent_id):
646
- raise ValueError("--execute requires routing info such as --old-bot-id and/or --old-agent-id/--new-agent-id")
647
- report = run(args)
648
- _write_json(Path(args.out_json), report)
649
- if args.out_html:
650
- out_html = Path(args.out_html)
651
- out_html.parent.mkdir(parents=True, exist_ok=True)
652
- out_html.write_text(_render_html(report), encoding="utf-8")
653
- first_case = report["cases"][0] if report.get("cases") else {}
654
- print(
655
- json.dumps(
656
- {
657
- "out_json": args.out_json,
658
- "out_html": args.out_html,
659
- "summary": report["summary"],
660
- "case": first_case.get("id"),
661
- "case_source": first_case.get("source"),
662
- "saved_cases_file": report.get("savedCasesFile"),
663
- "new_final_result": {
664
- "content": first_case.get("new", {}).get("content"),
665
- "handoff": first_case.get("new", {}).get("handoff"),
666
- "type": first_case.get("new", {}).get("type"),
667
- "route": first_case.get("new", {}).get("route"),
668
- "nodePath": first_case.get("new", {}).get("nodePath"),
669
- "nodePathUnavailable": first_case.get("new", {}).get("nodePathUnavailable"),
670
- },
671
- },
672
- ensure_ascii=False,
673
- indent=2,
674
- )
675
- )
676
-
677
-
678
- if __name__ == "__main__":
679
- main()