@shulex/cxclawctl 0.0.0-stage → 0.1.2

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 (209) hide show
  1. package/README.md +228 -2
  2. package/bin/cxclawctl.mjs +11 -0
  3. package/capabilities.lock.json +898 -0
  4. package/dist/cli.js +363 -0
  5. package/dist/cli.js.map +1 -0
  6. package/package.json +33 -4
  7. package/requirements-cli.txt +2 -0
  8. package/skills/__lib__/__init__.py +0 -0
  9. package/skills/__lib__/skill-runtime/bootstrap.py +134 -0
  10. package/skills/__lib__/solvea-api/__init__.py +0 -0
  11. package/skills/__lib__/solvea-api/client.py +1944 -0
  12. package/skills/__lib__/solvea-api/dashboard.py +100 -0
  13. package/skills/__lib__/solvea-api/solvea_api.py +1341 -0
  14. package/skills/agent-analytics/SKILL.md +74 -0
  15. package/skills/agent-analytics/references/chat-report-fields.md +36 -0
  16. package/skills/agent-analytics/references/report-fields.md +130 -0
  17. package/skills/agent-analytics/references/transcript.md +110 -0
  18. package/skills/agent-builder/SKILL.md +42 -0
  19. package/skills/agent-builder/interfaces/fallback-chatflow.md +47 -0
  20. package/skills/agent-builder/references/chatflow/graph-spec.md +259 -0
  21. package/skills/agent-builder/references/chatflow/node-selection.md +89 -0
  22. package/skills/agent-builder/references/chatflow/nodes/agent.md +350 -0
  23. package/skills/agent-builder/references/chatflow/nodes/business-track.md +43 -0
  24. package/skills/agent-builder/references/chatflow/nodes/code.md +157 -0
  25. package/skills/agent-builder/references/chatflow/nodes/collect-order.md +47 -0
  26. package/skills/agent-builder/references/chatflow/nodes/finish.md +34 -0
  27. package/skills/agent-builder/references/chatflow/nodes/human-review.md +60 -0
  28. package/skills/agent-builder/references/chatflow/nodes/if-else.md +100 -0
  29. package/skills/agent-builder/references/chatflow/nodes/intent.md +79 -0
  30. package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +43 -0
  31. package/skills/agent-builder/references/chatflow/nodes/llm.md +117 -0
  32. package/skills/agent-builder/references/chatflow/nodes/note.md +38 -0
  33. package/skills/agent-builder/references/chatflow/nodes/question.md +97 -0
  34. package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +68 -0
  35. package/skills/agent-builder/references/chatflow/nodes/reply.md +97 -0
  36. package/skills/agent-builder/references/chatflow/nodes/start.md +35 -0
  37. package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +48 -0
  38. package/skills/agent-builder/references/chatflow/nodes/tool.md +138 -0
  39. package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +63 -0
  40. package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +52 -0
  41. package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +50 -0
  42. package/skills/agent-builder/references/chatflow/nodes/variable-process.md +70 -0
  43. package/skills/agent-builder/references/chatflow/overview.md +53 -0
  44. package/skills/agent-builder/references/chatflow/visual-translation.md +64 -0
  45. package/skills/agent-builder/references/chatflow/workflow.md +134 -0
  46. package/skills/agent-builder/references/multi-agent/create-update.md +185 -0
  47. package/skills/agent-builder/references/multi-agent/graph-spec.md +246 -0
  48. package/skills/agent-builder/references/multi-agent/graph-structure.md +12 -0
  49. package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +13 -0
  50. package/skills/agent-builder/references/multi-agent/migration/large.md +554 -0
  51. package/skills/agent-builder/references/multi-agent/migration/principles.md +446 -0
  52. package/skills/agent-builder/references/multi-agent/migration/small.md +225 -0
  53. package/skills/agent-builder/references/multi-agent/orchestration.md +11 -0
  54. package/skills/agent-builder/references/multi-agent/overview.md +48 -0
  55. package/skills/agent-builder/references/multi-agent/update.md +128 -0
  56. package/skills/agent-builder/references/multi-agent/workflow.md +87 -0
  57. package/skills/agent-builder/references/single-agent/overview.md +48 -0
  58. package/skills/agent-builder/references/single-agent/prompt.md +16 -0
  59. package/skills/agent-builder/references/single-agent/tools.md +36 -0
  60. package/skills/agent-builder/references/single-agent/voice.md +46 -0
  61. package/skills/agent-builder/references/tools/http-tool.md +315 -0
  62. package/skills/agent-builder/references/tools/mock-tool.md +279 -0
  63. package/skills/agent-builder/references/tools/overview.md +46 -0
  64. package/skills/agent-builder/references/tools/tool-config.md +43 -0
  65. package/skills/agent-builder/references/tools/x-token.md +34 -0
  66. package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +3458 -0
  67. package/skills/agent-builder/scripts/chatflow/validate_graph.py +1697 -0
  68. package/skills/agent-builder/scripts/copy_marketplace_agent.py +61 -0
  69. package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +5974 -0
  70. package/skills/agent-builder/scripts/multi-agent/replay_compare.py +679 -0
  71. package/skills/agent-builder/scripts/patch_agent_tools.py +135 -0
  72. package/skills/agent-builder/scripts/tools/validate_tool_config.py +384 -0
  73. package/skills/agent-tester/SKILL.md +35 -0
  74. package/skills/agent-tester/batch-test/overview.md +86 -0
  75. package/skills/agent-tester/scripts/batch_test_sheet.py +213 -0
  76. package/skills/agent-tester/single-test/badcase-retest.md +85 -0
  77. package/skills/agent-tester/single-test/direct-test.md +24 -0
  78. package/skills/agent-tester/single-test/overview.md +142 -0
  79. package/skills/delivery/SKILL.md +112 -0
  80. package/skills/delivery/interfaces/classify.md +38 -0
  81. package/skills/delivery/interfaces/create-agents.md +89 -0
  82. package/skills/delivery/interfaces/delivery-test.md +67 -0
  83. package/skills/delivery/references/flow.md +40 -0
  84. package/skills/delivery/references/validation.md +15 -0
  85. package/skills/delivery/scripts/batch_test.py +704 -0
  86. package/skills/delivery/scripts/classify.py +22 -0
  87. package/skills/delivery/scripts/create_agents.py +695 -0
  88. package/skills/delivery/scripts/gen_delivery_report.py +335 -0
  89. package/skills/delivery/scripts/gen_excel.py +234 -0
  90. package/skills/delivery/scripts/gen_report.py +208 -0
  91. package/skills/delivery/scripts/plan.py +163 -0
  92. package/skills/delivery/scripts/render.py +713 -0
  93. package/skills/delivery/scripts/render_summary.py +38 -0
  94. package/skills/delivery/scripts/sample_history_cases.py +109 -0
  95. package/skills/kb-builder/SKILL.md +80 -0
  96. package/skills/kb-builder/interfaces/qa-upload.md +55 -0
  97. package/skills/kb-builder/interfaces/web-kb.md +84 -0
  98. package/skills/kb-builder/references/directory.md +373 -0
  99. package/skills/kb-builder/references/export.md +282 -0
  100. package/skills/kb-builder/references/isolation-keywords.md +113 -0
  101. package/skills/kb-builder/references/knowledge.md +153 -0
  102. package/skills/kb-builder/references/product.md +52 -0
  103. package/skills/kb-builder/references/qa-from-help-center.md +58 -0
  104. package/skills/kb-builder/references/qa-from-pdf.md +94 -0
  105. package/skills/kb-builder/references/qa-from-tickets.md +168 -0
  106. package/skills/kb-builder/references/qa-from-web.md +98 -0
  107. package/skills/kb-builder/references/qa-quality.md +73 -0
  108. package/skills/kb-builder/references/reply-template.md +237 -0
  109. package/skills/kb-builder/references/tag-isolation-migration.md +61 -0
  110. package/skills/kb-builder/references/tags.md +45 -0
  111. package/skills/kb-builder/references/upload.md +130 -0
  112. package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +333 -0
  113. package/skills/kb-builder/scripts/build_directory_plan.py +144 -0
  114. package/skills/kb-builder/scripts/crawl_site.py +433 -0
  115. package/skills/kb-builder/scripts/create_dirs.py +146 -0
  116. package/skills/kb-builder/scripts/export_knowledge.py +454 -0
  117. package/skills/kb-builder/scripts/extract_ticket_qa.py +441 -0
  118. package/skills/kb-builder/scripts/extract_web_qa.py +118 -0
  119. package/skills/kb-builder/scripts/import_qa.py +219 -0
  120. package/skills/kb-builder/scripts/import_source_documents.py +143 -0
  121. package/skills/kb-builder/scripts/import_templates.py +409 -0
  122. package/skills/poc/SKILL.md +125 -0
  123. package/skills/poc/assets/default_manual.css +279 -0
  124. package/skills/poc/assets/notice_escalate.png +0 -0
  125. package/skills/poc/assets/notice_escalate2.png +0 -0
  126. package/skills/poc/assets/notice_new_chat.png +0 -0
  127. package/skills/poc/assets/poc_test_template.html +645 -0
  128. package/skills/poc/assets/poc_test_template.md +104 -0
  129. package/skills/poc/assets/step3_preview.png +0 -0
  130. package/skills/poc/assets/step4_order_note.png +0 -0
  131. package/skills/poc/interfaces/classify.md +38 -0
  132. package/skills/poc/interfaces/create-agents.md +108 -0
  133. package/skills/poc/interfaces/poc-test.md +94 -0
  134. package/skills/poc/references/flow.md +47 -0
  135. package/skills/poc/references/kb-directory.md +22 -0
  136. package/skills/poc/references/validation.md +17 -0
  137. package/skills/poc/scripts/classify.py +22 -0
  138. package/skills/poc/scripts/create_agents.py +818 -0
  139. package/skills/poc/scripts/gen_excel.py +234 -0
  140. package/skills/poc/scripts/gen_poc_manual.py +249 -0
  141. package/skills/poc/scripts/gen_report.py +208 -0
  142. package/skills/poc/scripts/inspect_chatflow.py +209 -0
  143. package/skills/poc/scripts/plan.py +178 -0
  144. package/skills/poc/scripts/poc_test.py +495 -0
  145. package/skills/poc/scripts/render.py +713 -0
  146. package/skills/poc/scripts/render_summary.py +41 -0
  147. package/skills/runtime-inspector/SKILL.md +51 -0
  148. package/skills/runtime-inspector/references/frontend-rendering.md +126 -0
  149. package/skills/runtime-inspector/references/model.md +59 -0
  150. package/skills/runtime-inspector/references/sandbox.md +131 -0
  151. package/skills/runtime-inspector/scripts/model_inspect.py +68 -0
  152. package/skills/runtime-inspector/scripts/prepare.py +98 -0
  153. package/skills/runtime-inspector/scripts/process.py +84 -0
  154. package/skills/runtime-inspector/scripts/read_state.py +32 -0
  155. package/skills/runtime-inspector/scripts/sandbox_inspect.py +463 -0
  156. package/skills/runtime-inspector/scripts/test_claude_proxy.py +49 -0
  157. package/skills/runtime-inspector/scripts/test_openai_proxy.py +41 -0
  158. package/skills/runtime-inspector/scripts/verify_persistence.py +101 -0
  159. package/skills/runtime-inspector/scripts/write_test.py +41 -0
  160. package/skills/setup/SKILL.md +17 -0
  161. package/skills/shulex-support/SKILL.md +39 -0
  162. package/skills/shulex-support/references/badcase-analysis/overview.md +34 -0
  163. package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +105 -0
  164. package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +89 -0
  165. package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +36 -0
  166. package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +53 -0
  167. package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +259 -0
  168. package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +148 -0
  169. package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +57 -0
  170. package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +120 -0
  171. package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +126 -0
  172. package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +121 -0
  173. package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +208 -0
  174. package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +115 -0
  175. package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +72 -0
  176. package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +92 -0
  177. package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +147 -0
  178. package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +283 -0
  179. package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +77 -0
  180. package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +19 -0
  181. package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +23 -0
  182. package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +31 -0
  183. package/skills/shulex-support/references/product-docs.md +70 -0
  184. package/skills/shulex-support/references/tickets.md +184 -0
  185. package/skills/shulex-support/scripts/support.py +427 -0
  186. package/skills/solvea-desk/SKILL.md +39 -0
  187. package/skills/solvea-desk/references/report/fields.md +82 -0
  188. package/skills/solvea-desk/references/report/report-design.md +200 -0
  189. package/skills/solvea-desk/references/report/report-template.html +44 -0
  190. package/skills/solvea-desk/references/report/report.css +517 -0
  191. package/skills/solvea-desk/references/report/workflow.md +464 -0
  192. package/skills/solvea-desk/references/tickets/query.md +103 -0
  193. package/skills/solvea-desk/references/tickets/tags.md +83 -0
  194. package/skills/solvea-desk/scripts/report/fetch_sources.py +580 -0
  195. package/skills/solvea-desk/scripts/report/render_report.py +763 -0
  196. package/skills/solvea-desk/scripts/tickets/desk_api.py +73 -0
  197. package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +403 -0
  198. package/skills/solvea-desk/scripts/tickets/ticket_tags.py +305 -0
  199. package/skills/solvea-kit/SKILL.md +84 -0
  200. package/skills/solvea-kit/references/agent.md +145 -0
  201. package/skills/solvea-kit/references/chat.md +244 -0
  202. package/skills/solvea-kit/references/intent.md +69 -0
  203. package/skills/solvea-kit/references/plugin.md +53 -0
  204. package/skills/solvea-kit/references/quality-inspection.md +75 -0
  205. package/skills/solvea-kit/references/strategy.md +62 -0
  206. package/skills/solvea-kit/references/tagging.md +231 -0
  207. package/skills/solvea-kit/references/variables.md +53 -0
  208. package/skills/solvea-kit/scripts/generate_label_import_file.py +151 -0
  209. package/skills/solvea-kit/scripts/intent_classify.py +843 -0
@@ -0,0 +1,679 @@
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()