@shulex/cxclawctl 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +25 -22
  2. package/dist/cli.js +2202 -127
  3. package/dist/cli.js.map +1 -1
  4. package/package.json +19 -6
  5. package/capabilities.lock.json +0 -898
  6. package/requirements-cli.txt +0 -2
  7. package/skills/__lib__/__init__.py +0 -0
  8. package/skills/__lib__/skill-runtime/bootstrap.py +0 -134
  9. package/skills/__lib__/solvea-api/__init__.py +0 -0
  10. package/skills/__lib__/solvea-api/client.py +0 -1944
  11. package/skills/__lib__/solvea-api/dashboard.py +0 -100
  12. package/skills/__lib__/solvea-api/solvea_api.py +0 -1341
  13. package/skills/agent-analytics/SKILL.md +0 -74
  14. package/skills/agent-analytics/references/chat-report-fields.md +0 -36
  15. package/skills/agent-analytics/references/report-fields.md +0 -130
  16. package/skills/agent-analytics/references/transcript.md +0 -110
  17. package/skills/agent-builder/SKILL.md +0 -42
  18. package/skills/agent-builder/interfaces/fallback-chatflow.md +0 -47
  19. package/skills/agent-builder/references/chatflow/graph-spec.md +0 -259
  20. package/skills/agent-builder/references/chatflow/node-selection.md +0 -89
  21. package/skills/agent-builder/references/chatflow/nodes/agent.md +0 -350
  22. package/skills/agent-builder/references/chatflow/nodes/business-track.md +0 -43
  23. package/skills/agent-builder/references/chatflow/nodes/code.md +0 -157
  24. package/skills/agent-builder/references/chatflow/nodes/collect-order.md +0 -47
  25. package/skills/agent-builder/references/chatflow/nodes/finish.md +0 -34
  26. package/skills/agent-builder/references/chatflow/nodes/human-review.md +0 -60
  27. package/skills/agent-builder/references/chatflow/nodes/if-else.md +0 -100
  28. package/skills/agent-builder/references/chatflow/nodes/intent.md +0 -79
  29. package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +0 -43
  30. package/skills/agent-builder/references/chatflow/nodes/llm.md +0 -117
  31. package/skills/agent-builder/references/chatflow/nodes/note.md +0 -38
  32. package/skills/agent-builder/references/chatflow/nodes/question.md +0 -97
  33. package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +0 -68
  34. package/skills/agent-builder/references/chatflow/nodes/reply.md +0 -97
  35. package/skills/agent-builder/references/chatflow/nodes/start.md +0 -35
  36. package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +0 -48
  37. package/skills/agent-builder/references/chatflow/nodes/tool.md +0 -138
  38. package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +0 -63
  39. package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +0 -52
  40. package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +0 -50
  41. package/skills/agent-builder/references/chatflow/nodes/variable-process.md +0 -70
  42. package/skills/agent-builder/references/chatflow/overview.md +0 -53
  43. package/skills/agent-builder/references/chatflow/visual-translation.md +0 -64
  44. package/skills/agent-builder/references/chatflow/workflow.md +0 -134
  45. package/skills/agent-builder/references/multi-agent/create-update.md +0 -185
  46. package/skills/agent-builder/references/multi-agent/graph-spec.md +0 -246
  47. package/skills/agent-builder/references/multi-agent/graph-structure.md +0 -12
  48. package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +0 -13
  49. package/skills/agent-builder/references/multi-agent/migration/large.md +0 -554
  50. package/skills/agent-builder/references/multi-agent/migration/principles.md +0 -446
  51. package/skills/agent-builder/references/multi-agent/migration/small.md +0 -225
  52. package/skills/agent-builder/references/multi-agent/orchestration.md +0 -11
  53. package/skills/agent-builder/references/multi-agent/overview.md +0 -48
  54. package/skills/agent-builder/references/multi-agent/update.md +0 -128
  55. package/skills/agent-builder/references/multi-agent/workflow.md +0 -87
  56. package/skills/agent-builder/references/single-agent/overview.md +0 -48
  57. package/skills/agent-builder/references/single-agent/prompt.md +0 -16
  58. package/skills/agent-builder/references/single-agent/tools.md +0 -36
  59. package/skills/agent-builder/references/single-agent/voice.md +0 -46
  60. package/skills/agent-builder/references/tools/http-tool.md +0 -315
  61. package/skills/agent-builder/references/tools/mock-tool.md +0 -279
  62. package/skills/agent-builder/references/tools/overview.md +0 -46
  63. package/skills/agent-builder/references/tools/tool-config.md +0 -43
  64. package/skills/agent-builder/references/tools/x-token.md +0 -34
  65. package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +0 -3458
  66. package/skills/agent-builder/scripts/chatflow/validate_graph.py +0 -1697
  67. package/skills/agent-builder/scripts/copy_marketplace_agent.py +0 -61
  68. package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +0 -5974
  69. package/skills/agent-builder/scripts/multi-agent/replay_compare.py +0 -679
  70. package/skills/agent-builder/scripts/patch_agent_tools.py +0 -135
  71. package/skills/agent-builder/scripts/tools/validate_tool_config.py +0 -384
  72. package/skills/agent-tester/SKILL.md +0 -35
  73. package/skills/agent-tester/batch-test/overview.md +0 -86
  74. package/skills/agent-tester/scripts/batch_test_sheet.py +0 -213
  75. package/skills/agent-tester/single-test/badcase-retest.md +0 -85
  76. package/skills/agent-tester/single-test/direct-test.md +0 -24
  77. package/skills/agent-tester/single-test/overview.md +0 -142
  78. package/skills/delivery/SKILL.md +0 -112
  79. package/skills/delivery/interfaces/classify.md +0 -38
  80. package/skills/delivery/interfaces/create-agents.md +0 -89
  81. package/skills/delivery/interfaces/delivery-test.md +0 -67
  82. package/skills/delivery/references/flow.md +0 -40
  83. package/skills/delivery/references/validation.md +0 -15
  84. package/skills/delivery/scripts/batch_test.py +0 -704
  85. package/skills/delivery/scripts/classify.py +0 -22
  86. package/skills/delivery/scripts/create_agents.py +0 -695
  87. package/skills/delivery/scripts/gen_delivery_report.py +0 -335
  88. package/skills/delivery/scripts/gen_excel.py +0 -234
  89. package/skills/delivery/scripts/gen_report.py +0 -208
  90. package/skills/delivery/scripts/plan.py +0 -163
  91. package/skills/delivery/scripts/render.py +0 -713
  92. package/skills/delivery/scripts/render_summary.py +0 -38
  93. package/skills/delivery/scripts/sample_history_cases.py +0 -109
  94. package/skills/kb-builder/SKILL.md +0 -80
  95. package/skills/kb-builder/interfaces/qa-upload.md +0 -55
  96. package/skills/kb-builder/interfaces/web-kb.md +0 -84
  97. package/skills/kb-builder/references/directory.md +0 -373
  98. package/skills/kb-builder/references/export.md +0 -282
  99. package/skills/kb-builder/references/isolation-keywords.md +0 -113
  100. package/skills/kb-builder/references/knowledge.md +0 -153
  101. package/skills/kb-builder/references/product.md +0 -52
  102. package/skills/kb-builder/references/qa-from-help-center.md +0 -58
  103. package/skills/kb-builder/references/qa-from-pdf.md +0 -94
  104. package/skills/kb-builder/references/qa-from-tickets.md +0 -168
  105. package/skills/kb-builder/references/qa-from-web.md +0 -98
  106. package/skills/kb-builder/references/qa-quality.md +0 -73
  107. package/skills/kb-builder/references/reply-template.md +0 -237
  108. package/skills/kb-builder/references/tag-isolation-migration.md +0 -61
  109. package/skills/kb-builder/references/tags.md +0 -45
  110. package/skills/kb-builder/references/upload.md +0 -130
  111. package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +0 -333
  112. package/skills/kb-builder/scripts/build_directory_plan.py +0 -144
  113. package/skills/kb-builder/scripts/crawl_site.py +0 -433
  114. package/skills/kb-builder/scripts/create_dirs.py +0 -146
  115. package/skills/kb-builder/scripts/export_knowledge.py +0 -454
  116. package/skills/kb-builder/scripts/extract_ticket_qa.py +0 -441
  117. package/skills/kb-builder/scripts/extract_web_qa.py +0 -118
  118. package/skills/kb-builder/scripts/import_qa.py +0 -219
  119. package/skills/kb-builder/scripts/import_source_documents.py +0 -143
  120. package/skills/kb-builder/scripts/import_templates.py +0 -409
  121. package/skills/poc/SKILL.md +0 -125
  122. package/skills/poc/assets/default_manual.css +0 -279
  123. package/skills/poc/assets/notice_escalate.png +0 -0
  124. package/skills/poc/assets/notice_escalate2.png +0 -0
  125. package/skills/poc/assets/notice_new_chat.png +0 -0
  126. package/skills/poc/assets/poc_test_template.html +0 -645
  127. package/skills/poc/assets/poc_test_template.md +0 -104
  128. package/skills/poc/assets/step3_preview.png +0 -0
  129. package/skills/poc/assets/step4_order_note.png +0 -0
  130. package/skills/poc/interfaces/classify.md +0 -38
  131. package/skills/poc/interfaces/create-agents.md +0 -108
  132. package/skills/poc/interfaces/poc-test.md +0 -94
  133. package/skills/poc/references/flow.md +0 -47
  134. package/skills/poc/references/kb-directory.md +0 -22
  135. package/skills/poc/references/validation.md +0 -17
  136. package/skills/poc/scripts/classify.py +0 -22
  137. package/skills/poc/scripts/create_agents.py +0 -818
  138. package/skills/poc/scripts/gen_excel.py +0 -234
  139. package/skills/poc/scripts/gen_poc_manual.py +0 -249
  140. package/skills/poc/scripts/gen_report.py +0 -208
  141. package/skills/poc/scripts/inspect_chatflow.py +0 -209
  142. package/skills/poc/scripts/plan.py +0 -178
  143. package/skills/poc/scripts/poc_test.py +0 -495
  144. package/skills/poc/scripts/render.py +0 -713
  145. package/skills/poc/scripts/render_summary.py +0 -41
  146. package/skills/runtime-inspector/SKILL.md +0 -51
  147. package/skills/runtime-inspector/references/frontend-rendering.md +0 -126
  148. package/skills/runtime-inspector/references/model.md +0 -59
  149. package/skills/runtime-inspector/references/sandbox.md +0 -131
  150. package/skills/runtime-inspector/scripts/model_inspect.py +0 -68
  151. package/skills/runtime-inspector/scripts/prepare.py +0 -98
  152. package/skills/runtime-inspector/scripts/process.py +0 -84
  153. package/skills/runtime-inspector/scripts/read_state.py +0 -32
  154. package/skills/runtime-inspector/scripts/sandbox_inspect.py +0 -463
  155. package/skills/runtime-inspector/scripts/test_claude_proxy.py +0 -49
  156. package/skills/runtime-inspector/scripts/test_openai_proxy.py +0 -41
  157. package/skills/runtime-inspector/scripts/verify_persistence.py +0 -101
  158. package/skills/runtime-inspector/scripts/write_test.py +0 -41
  159. package/skills/setup/SKILL.md +0 -17
  160. package/skills/shulex-support/SKILL.md +0 -39
  161. package/skills/shulex-support/references/badcase-analysis/overview.md +0 -34
  162. package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +0 -105
  163. package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +0 -89
  164. package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +0 -36
  165. package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +0 -53
  166. package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +0 -259
  167. package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +0 -148
  168. package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +0 -57
  169. package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +0 -120
  170. package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +0 -126
  171. package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +0 -121
  172. package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +0 -208
  173. package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +0 -115
  174. package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +0 -72
  175. package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +0 -92
  176. package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +0 -147
  177. package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +0 -283
  178. package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +0 -77
  179. package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +0 -19
  180. package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +0 -23
  181. package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +0 -31
  182. package/skills/shulex-support/references/product-docs.md +0 -70
  183. package/skills/shulex-support/references/tickets.md +0 -184
  184. package/skills/shulex-support/scripts/support.py +0 -427
  185. package/skills/solvea-desk/SKILL.md +0 -39
  186. package/skills/solvea-desk/references/report/fields.md +0 -82
  187. package/skills/solvea-desk/references/report/report-design.md +0 -200
  188. package/skills/solvea-desk/references/report/report-template.html +0 -44
  189. package/skills/solvea-desk/references/report/report.css +0 -517
  190. package/skills/solvea-desk/references/report/workflow.md +0 -464
  191. package/skills/solvea-desk/references/tickets/query.md +0 -103
  192. package/skills/solvea-desk/references/tickets/tags.md +0 -83
  193. package/skills/solvea-desk/scripts/report/fetch_sources.py +0 -580
  194. package/skills/solvea-desk/scripts/report/render_report.py +0 -763
  195. package/skills/solvea-desk/scripts/tickets/desk_api.py +0 -73
  196. package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +0 -403
  197. package/skills/solvea-desk/scripts/tickets/ticket_tags.py +0 -305
  198. package/skills/solvea-kit/SKILL.md +0 -84
  199. package/skills/solvea-kit/references/agent.md +0 -145
  200. package/skills/solvea-kit/references/chat.md +0 -244
  201. package/skills/solvea-kit/references/intent.md +0 -69
  202. package/skills/solvea-kit/references/plugin.md +0 -53
  203. package/skills/solvea-kit/references/quality-inspection.md +0 -75
  204. package/skills/solvea-kit/references/strategy.md +0 -62
  205. package/skills/solvea-kit/references/tagging.md +0 -231
  206. package/skills/solvea-kit/references/variables.md +0 -53
  207. package/skills/solvea-kit/scripts/generate_label_import_file.py +0 -151
  208. package/skills/solvea-kit/scripts/intent_classify.py +0 -843
@@ -1,843 +0,0 @@
1
- """
2
- Intent Classification — shared Solvea Kit capability.
3
-
4
- Business inputs: TAXONOMY (L1 → predefined L2 names) + DISAMBIGUATION_RULES
5
- System generates: L1/L2 definitions, new L2s (max 3/L1 capped at 5 total), new L1s (max 3)
6
-
7
- Modes:
8
- --xlsx: Full LLM classification of complaint data
9
- --no-excel: Generate synthetic results from fixed TAXONOMY, no LLM needed
10
-
11
- Usage:
12
- python3 scripts/intent_classify.py --owner poc --xlsx mounted/chat-files/user/complaints.xlsx [--max-rows 2000]
13
- python3 scripts/intent_classify.py --owner delivery --no-excel
14
- """
15
-
16
- import os, sys
17
- sys.path.insert(0, os.path.join(os.environ.get("SOLVEA_LIB_DIR", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "__lib__"))), "skill-runtime"))
18
- from bootstrap import *
19
-
20
- import json, re, argparse, html as html_lib
21
- from collections import defaultdict
22
- from concurrent.futures import ThreadPoolExecutor, as_completed
23
- import threading
24
-
25
- import openpyxl
26
- from bs4 import BeautifulSoup
27
- from openai import OpenAI
28
-
29
-
30
- # ── Business inputs ───────────────────────────────────────────────────────────
31
-
32
- TAXONOMY: dict[str, list[str]] = {
33
- "Pre-Sales Inquiry": ["Product Recommendation", "Discount Inquiry", "Price Inquiry", "Stock Availability", "Payment Issues"],
34
- "Invoices": ["Request Invoice", "Modify Invoice Information"],
35
- "Order Issues": ["Modify Order", "Check Order Status", "Cancel Order"],
36
- "Logistics Issues": ["Track Shipment", "Shipping Exception"],
37
- "Package issues": ["Missing Items", "Wrong Items Received", "Damaged Package"],
38
- "Product issues": ["Product Usage", "Physical Damage", "Non-Physical Issues"],
39
- "Return and exchange": [],
40
- }
41
-
42
- FIXED_DESCRIPTIONS: dict[str, str] = {
43
- "Pre-Sales Inquiry": "Covers all pre-purchase consultations and issues, including product recommendations, discount inquiries, price inquiries, stock availability checks, payment method questions, payment process troubleshooting, and other related pre-sales needs before placing an order.",
44
- "Invoices": "Includes requests for invoice issuance, inquiries about invoice status, modifications to invoice information (such as recipient name, tax ID, billing address), and other consultation or problems related to invoices.",
45
- "Order Issues": "Covers order modification requests (e.g., changing shipping address, adjusting product quantity), inquiries about order details (order status, order confirmation, order history), order cancellation requests, and other consultation or problems related to orders.",
46
- "Logistics Issues": "Includes tracking inquiries (requesting logistics tracking number, checking shipping progress), reports of logistics abnormalities (delayed delivery, stuck in transit, incorrect shipping route), and other logistics-related problems.",
47
- "Package issues": "Covers issues such as missing items, missing manual, incomplete packages, lost packages, wrong items delivered (mismatched with order), package damage (external or internal damage to the package), and other package-related consultations or problems.",
48
- "Product issues": "Includes product usage questions (operation guidance, function inquiries, usage precautions), physical damage to products (non-packaging-related product damage), non-physical product quality issues (performance failures, functional defects, quality inconsistencies with descriptions), and other product-related consultations or problems.",
49
- "Return and exchange": "Covers customer requests for returns, refunds, or exchanges. Note: if the customer explicitly mentions product quality issues as the reason, classify under Product issues instead. This intent applies to return/refund/exchange requests for non-quality reasons (e.g., wrong size, change of mind, incompatible needs).",
50
- }
51
-
52
- DISAMBIGUATION_RULES: list[str] = [
53
- "If a message involves both order status checking AND logistics tracking → classify as Logistics Issues (not Order Issues)",
54
- "If a message involves both Physical Damage (Product issues) AND Damaged Package (Package issues) → classify as Package issues / Damaged Package",
55
- "If a message involves both Product issues AND Return and exchange → classify as Product issues",
56
- "Return and exchange has the lowest priority: if ANY other L1 intent is present, classify as that other intent instead of Return and exchange",
57
- ]
58
-
59
-
60
- # ── Constants ─────────────────────────────────────────────────────────────────
61
-
62
- BATCH_SIZE = 40
63
- MAX_WORKERS = 5
64
- MAX_NEW_L1 = 3
65
- MAX_NEW_L2_PER_L1 = 3
66
- MAX_L2_TOTAL = 5 # predefined + new L2s capped at 5 per L1
67
- OTHER_THRESHOLD = 0.10
68
- MAX_RESEARCH_ROUNDS = 5
69
- _checkpoint_lock = threading.Lock()
70
- SERVICE_MANAGED_MODEL = "server-managed"
71
-
72
-
73
- # ── Token tracking ────────────────────────────────────────────────────────────
74
-
75
- class TokenTracker:
76
- def __init__(self):
77
- self._lock = threading.Lock()
78
- self._usage: dict = {}
79
-
80
- def record(self, model: str, input_tokens: int, output_tokens: int):
81
- with self._lock:
82
- if model not in self._usage:
83
- self._usage[model] = {"input": 0, "output": 0, "calls": 0}
84
- self._usage[model]["input"] += input_tokens
85
- self._usage[model]["output"] += output_tokens
86
- self._usage[model]["calls"] += 1
87
-
88
- def summary(self) -> dict:
89
- result = {}
90
- for model, usage in self._usage.items():
91
- result[model] = {
92
- "calls": usage["calls"],
93
- "input_tokens": usage["input"],
94
- "output_tokens": usage["output"],
95
- "total_tokens": usage["input"] + usage["output"],
96
- }
97
- return result
98
-
99
- def print_summary(self):
100
- pass # token 用量不打印到 stdout(写入 results JSON meta 里)
101
-
102
-
103
- _tracker = TokenTracker()
104
-
105
-
106
- class _MessageResponse:
107
- def __init__(self, text: str):
108
- self.content = [type("Content", (), {"text": text})()]
109
-
110
-
111
- def _api_call(client: OpenAI, model: str, **kwargs):
112
- messages = []
113
- if kwargs.get("system"):
114
- messages.append({"role": "system", "content": kwargs["system"]})
115
- messages.extend(kwargs.get("messages") or [])
116
- resp = client.chat.completions.create(
117
- # /internal/openai/v1 overwrites this sentinel with the service-managed model.
118
- model=SERVICE_MANAGED_MODEL,
119
- max_tokens=kwargs.get("max_tokens"),
120
- messages=messages,
121
- temperature=0,
122
- )
123
- if getattr(resp, "usage", None):
124
- _tracker.record(SERVICE_MANAGED_MODEL, resp.usage.prompt_tokens or 0, resp.usage.completion_tokens or 0)
125
- return _MessageResponse((resp.choices[0].message.content or "").strip())
126
-
127
-
128
- # ── HTML helpers ──────────────────────────────────────────────────────────────
129
-
130
- _FOOTER_MARKERS = [
131
- "copyright 20", "© 20",
132
- "was this e-mail helpful", "was this email helpful",
133
- "all rights reserved",
134
- "to unsubscribe", "opt-out here", "opt out here",
135
- "if you'd rather not receive", "if you would rather not receive",
136
- "you are receiving this",
137
- ]
138
-
139
- def has_html(text: str) -> bool:
140
- return bool(re.search(r'<[a-zA-Z][^>]{0,100}>', text))
141
-
142
- def _strip_email_footer(text: str) -> str:
143
- if len(text) < 400:
144
- return text
145
- low = text.lower()
146
- cut = len(text)
147
- for marker in _FOOTER_MARKERS:
148
- pos = low.find(marker)
149
- if 0 < pos < cut:
150
- cut = pos
151
- return text[:cut].strip()
152
-
153
- def clean_message(text: str) -> str:
154
- text = str(text or "").strip()
155
- text = html_lib.unescape(text)
156
- if has_html(text):
157
- soup = BeautifulSoup(text, 'html.parser')
158
- for tag in soup(["style", "script", "head", "meta", "link"]):
159
- tag.decompose()
160
- text = soup.get_text(separator=' ', strip=True)
161
- text = re.sub(r'\s+', ' ', text).strip()
162
- return _strip_email_footer(text)
163
-
164
-
165
-
166
-
167
- # ── Excel loader ──────────────────────────────────────────────────────────────
168
-
169
- def load_xlsx(path: str, max_rows: int = 0,
170
- messages_col: str | None = None,
171
- title_col: str | None = None) -> list:
172
- wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
173
- ws = wb.active
174
- rows = list(ws.iter_rows(values_only=True))
175
- if not rows:
176
- raise ValueError("Excel file is empty")
177
- headers = [str(h).strip() if h is not None else "" for h in rows[0]]
178
-
179
- def _find_col(name: str | None, required: bool = False) -> int | None:
180
- if name is None:
181
- return None
182
- if name in headers:
183
- return headers.index(name)
184
- available = ", ".join(h for h in headers if h)
185
- raise ValueError(f"Column '{name}' not found. Available: {available}")
186
-
187
- if not messages_col:
188
- available = ", ".join(h for h in headers if h)
189
- raise ValueError(f"--messages-col is required. Available columns: {available}")
190
-
191
- idx_messages = _find_col(messages_col, required=True)
192
- idx_title = _find_col(title_col)
193
- items = []
194
- for i, row in enumerate(rows[1:], start=1):
195
- if max_rows and i > max_rows:
196
- break
197
- messages_raw = str(row[idx_messages]) if row[idx_messages] is not None else ""
198
- text_clean = clean_message(messages_raw)
199
- if not text_clean:
200
- continue
201
- title_val = str(row[idx_title]).strip() if idx_title is not None and row[idx_title] else ""
202
- items.append({
203
- "id": str(i),
204
- "title": title_val,
205
- "text": text_clean,
206
- "messages": messages_raw,
207
- })
208
- return items
209
-
210
-
211
- # ── Prompt builders ───────────────────────────────────────────────────────────
212
-
213
- def _taxonomy_text() -> str:
214
- lines = []
215
- for l1, l2s in TAXONOMY.items():
216
- if l2s:
217
- lines.append(f"- {l1}: {', '.join(l2s)}")
218
- else:
219
- lines.append(f"- {l1}: (no predefined L2 — coin a specific 2-5 word name from the message content)")
220
- return "\n".join(lines)
221
-
222
- def _rules_text() -> str:
223
- return "\n".join(f"{i+1}. {r}" for i, r in enumerate(DISAMBIGUATION_RULES))
224
-
225
- def _valid_l1_list() -> str:
226
- return ", ".join(f'"{k}"' for k in TAXONOMY) + ', "Other"'
227
-
228
- CLASSIFY_SYSTEM = f"""You are an e-commerce customer support intent classifier.
229
- Output ONLY a JSON array. No markdown, no explanation, no code fences.
230
-
231
- ## Intent Taxonomy
232
- {_taxonomy_text()}
233
-
234
- ## Disambiguation Rules (apply when a message matches multiple intents)
235
- {_rules_text()}
236
-
237
- ## Output format for each item:
238
- {{"id": "<id>", "l1": "<MUST be one of the allowed values below>", "l2": "<REQUIRED: exact predefined L2 name, or 2-5 word descriptive name — NEVER empty>", "new_l2_hint": "<only when l2 is newly coined>", "new_l1_hint": "<only when l1='Other' — topic in 2-5 words>"}}
239
-
240
- ## Allowed l1 values (copy EXACTLY, no variations):
241
- {_valid_l1_list()}
242
-
243
- ## Rules:
244
- - l1 MUST be copied verbatim from the allowed list — do NOT paraphrase or invent new names
245
- - Use "Other" only when the message falls completely outside all taxonomy categories
246
- - l2 MUST be non-empty for EVERY item — use predefined L2 names when they fit; otherwise coin a 2-5 word name
247
- - new_l2_hint: include ONLY for newly coined l2 names
248
- - new_l1_hint: include ONLY when l1="Other"; describe a genuine customer need, not data characteristics
249
- - Apply disambiguation rules before assigning l1
250
- """
251
-
252
- DEFINITION_PROMPT = """You are writing intent definitions for an e-commerce AI customer support routing system.
253
- These definitions determine how incoming messages are routed — they must precisely reflect the actual customer requests in the samples.
254
-
255
- ## L1 Intent: {l1_name}
256
- ## Standard base description (use as starting point, refine based on data):
257
- {base_description}
258
-
259
- ## Predefined L2 sub-intents: {predefined_l2_json}
260
- ## Newly discovered L2 sub-intents (from data): {new_l2_json}
261
-
262
- ## Sample messages classified under this intent:
263
- {samples_text}
264
-
265
- Your task: refine the base description into a precise L1 definition that reflects the actual patterns in the samples above.
266
- Keep the core scope of the base description, but adjust wording to match the specific scenarios visible in the data.
267
-
268
- Output ONLY JSON (no markdown):
269
- {{
270
- "l1_def": "One sentence (≤30 words) — start from the base description, adjust to reflect actual customer patterns in the samples",
271
- "l2_defs": {{
272
- "<l2_name>": "One sentence (≤20 words) based on actual sample patterns — describe the specific customer request, not a generic category label"
273
- }},
274
- "new_l2_normalized": {{
275
- "<newly_coined_l2>": "<closest predefined L2 name>"
276
- }}
277
- }}
278
-
279
- Rules:
280
- - l1_def must preserve the intent scope from the base description; only adjust emphasis or wording based on data
281
- - l2_defs must include ALL l2 names listed above (predefined + newly discovered)
282
- - new_l2_normalized: for each newly coined L2, map to its closest predefined L2 if semantically similar; omit if genuinely distinct
283
- - Definitions MUST reference specific scenarios from the samples where possible
284
- - Avoid generic filler like "issues with", "problems related to", "requests about"
285
- """
286
-
287
- CLUSTER_SYSTEM = """You are an e-commerce customer support intent design expert.
288
- Given complaint groups (each with a topic hint, count, and samples), merge them into coherent L1 intent categories.
289
- Output ONLY a JSON array, no markdown:
290
- [{"merged_hints": ["hint1", "hint2"], "name": "Intent Name", "desc": "One sentence description"}]
291
-
292
- ## Quality bar for new intents:
293
- - Prefer intents with a clear, actionable customer need that a support agent can resolve differently from existing intents
294
- - Merge hints that describe the same underlying customer problem
295
- - If a group's samples are entirely auto-generated system messages with zero customer question, merge it into "Other"
296
-
297
- Rules:
298
- - Every input hint must appear in exactly one group's "merged_hints"
299
- - Names: 2-5 words, title case, English, concise and professional
300
- - Merge hints describing the same customer problem type
301
- - Do NOT create intents that overlap with the known intents listed
302
- - Use "Other" group for noise, ambiguous items, and anything that fails the valid intent criteria above
303
- """
304
-
305
- SPLIT_SYSTEM = """You are an e-commerce customer support expert.
306
- Analyze these complaint texts and group them into distinct intent categories.
307
- Output ONLY JSON (no markdown):
308
- [{"name": "Intent Name", "desc": "One sentence description", "indices": [0, 2, 5]}]
309
-
310
- Rules:
311
- - name: 2-5 words, title case, English
312
- - Every index 0..N-1 must appear in exactly ONE group
313
- - Each group needs at least 2 complaints
314
- - Use "Other" for items that don't fit any clear pattern
315
- - Target: 2-8 distinct groups
316
- """
317
-
318
-
319
-
320
- # ── Stage 2: Cluster "Other" items into new L1s ───────────────────────────────
321
-
322
- def _split_into_groups(items: list, known_names: list, client) -> tuple:
323
- sample = items[:80]
324
- lines = "\n".join(f"[{i}] {it.get('text', '')[:200]}" for i, it in enumerate(sample))
325
- user_msg = f"Known intents (do NOT duplicate): {', '.join(known_names)}\n\nComplaints:\n{lines}"
326
- try:
327
- resp = _api_call(client,
328
- SERVICE_MANAGED_MODEL,
329
- max_tokens=2000,
330
- system=SPLIT_SYSTEM,
331
- messages=[{"role": "user", "content": user_msg}],
332
- )
333
- groups = _parse_json_response(resp.content[0].text)
334
- idx_to_name = {idx: g["name"]
335
- for g in groups for idx in g.get("indices", [])
336
- if 0 <= idx < len(sample)}
337
- cluster_items: dict = defaultdict(list)
338
- for i, item in enumerate(sample):
339
- cluster_items[idx_to_name.get(i, "Other")].append(item)
340
- for item in items[len(sample):]:
341
- cluster_items["Other"].append(item)
342
- clusters, seen = [], set()
343
- for g in groups:
344
- n = g["name"]
345
- if n not in seen and cluster_items.get(n):
346
- clusters.append({"merged_hints": [n], "name": n, "desc": g.get("desc", "")})
347
- seen.add(n)
348
- if cluster_items.get("Other") and "Other" not in seen:
349
- clusters.append({"merged_hints": ["Other"], "name": "Other", "desc": "Uncategorized complaints"})
350
- return clusters, dict(cluster_items)
351
- except Exception as e:
352
- print(f" [split] failed: {e}")
353
- return ([{"merged_hints": ["Other"], "name": "Other", "desc": "Uncategorized"}],
354
- {"Other": items})
355
-
356
-
357
- def cluster_new_intents(other_items: list, known_names: list, client, max_new: int = 0) -> tuple:
358
- if not other_items:
359
- return [], {}
360
- hint_groups: dict = defaultdict(list)
361
- for it in other_items:
362
- hint = (it.get("new_l1_hint") or "").strip() or "Other"
363
- hint_groups[hint].append(it)
364
- if len(hint_groups) == 1:
365
- print(" [cluster] single-hint group, switching to split-by-content mode")
366
- return _split_into_groups(other_items, known_names, client)
367
- groups_data = [
368
- {"hint": hint, "count": len(its),
369
- "samples": [i.get("text", "")[:200] for i in its[:3]]}
370
- for hint, its in sorted(hint_groups.items(), key=lambda x: -len(x[1]))
371
- ]
372
- user_msg = (
373
- f"Known L1 intents (do NOT overlap): {', '.join(known_names)}\n\n"
374
- f"Complaint groups to merge:\n{json.dumps(groups_data, ensure_ascii=False, indent=2)}\n\n"
375
- f"Output merged L1 intents as JSON array."
376
- + (f"\n\nIMPORTANT: Produce at most {max_new} clusters total." if max_new > 0 else "")
377
- )
378
- for attempt in range(2):
379
- try:
380
- resp = _api_call(client,
381
- SERVICE_MANAGED_MODEL,
382
- max_tokens=2000,
383
- system=CLUSTER_SYSTEM,
384
- messages=[{"role": "user", "content": user_msg}],
385
- )
386
- clusters = _parse_json_response(resp.content[0].text)
387
- if isinstance(clusters, list) and clusters:
388
- covered = {h for c in clusters for h in c.get("merged_hints", [])}
389
- missing = set(hint_groups.keys()) - covered
390
- if missing:
391
- clusters.append({"merged_hints": list(missing), "name": "Other", "desc": "Uncategorized"})
392
- hint_to_name = {h: c["name"] for c in clusters for h in c.get("merged_hints", [])}
393
- cluster_items: dict = defaultdict(list)
394
- for it in other_items:
395
- hint = (it.get("new_l1_hint") or "").strip() or "Other"
396
- cluster_items[hint_to_name.get(hint, hint)].append(it)
397
- return clusters, dict(cluster_items)
398
- except Exception as e:
399
- print(f" [cluster] attempt {attempt+1} error: {e}")
400
- print(" [cluster] fallback: everything as Other")
401
- return ([{"merged_hints": list(hint_groups.keys()), "name": "Other", "desc": "Uncategorized"}],
402
- {"Other": other_items})
403
-
404
- # ── JSON response parser ──────────────────────────────────────────────────────
405
-
406
- def _parse_json_response(raw: str):
407
- text = raw.strip()
408
- m = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text)
409
- if m:
410
- text = m.group(1).strip()
411
- else:
412
- m = re.search(r'(\{[\s\S]*\}|\[[\s\S]*\])', text)
413
- if m:
414
- text = m.group(1)
415
- return json.loads(text)
416
-
417
-
418
- # ── Stage 1: Classification ───────────────────────────────────────────────────
419
-
420
- def validate_classify_response(raw: str) -> list | None:
421
- valid_l1s = set(TAXONOMY.keys()) | {"Other"}
422
- try:
423
- items = _parse_json_response(raw)
424
- except Exception:
425
- return None
426
- if not isinstance(items, list):
427
- return None
428
- for item in items:
429
- if not isinstance(item, dict):
430
- return None
431
- if not {"id", "l1", "l2"}.issubset(item.keys()):
432
- return None
433
- if item["l1"] not in valid_l1s:
434
- item["l1"] = "Other"
435
- return items
436
-
437
-
438
- def classify_batch(items: list, client) -> list:
439
- def _item_payload(it):
440
- payload = {"id": it["id"], "text": it["text"][:1500]}
441
- if it.get("title"):
442
- payload["title"] = it["title"]
443
- return payload
444
-
445
- user_msg = "Classify these customer messages:\n" + json.dumps(
446
- [_item_payload(it) for it in items],
447
- ensure_ascii=False,
448
- )
449
- for attempt in range(2):
450
- try:
451
- resp = _api_call(client,
452
- SERVICE_MANAGED_MODEL,
453
- max_tokens=2048,
454
- system=CLASSIFY_SYSTEM,
455
- messages=[{"role": "user", "content": user_msg}],
456
- )
457
- result = validate_classify_response(resp.content[0].text)
458
- if result is not None:
459
- return result
460
- print(f" [classify] attempt {attempt+1} parse failed, retrying...")
461
- except Exception as e:
462
- print(f" [classify] attempt {attempt+1} API error: {e}")
463
- print(f" [classify] double failure, marking as Other")
464
- return [{"id": it["id"], "l1": "Other", "l2": "General", "new_l1_hint": "unknown"} for it in items]
465
-
466
-
467
- # ── Checkpoint ────────────────────────────────────────────────────────────────
468
-
469
- def load_checkpoint(results_path: str) -> dict:
470
- if not os.path.exists(results_path):
471
- return {"meta": {}, "raw_items": {}}
472
- with open(results_path) as f:
473
- return json.load(f)
474
-
475
- def save_item(results_path: str, checkpoint: dict, item_id: str, classified: dict):
476
- with _checkpoint_lock:
477
- checkpoint.setdefault("raw_items", {})[item_id] = classified
478
- tmp = results_path + ".tmp"
479
- with open(tmp, "w") as f:
480
- json.dump(checkpoint, f, ensure_ascii=False, indent=2)
481
- os.replace(tmp, results_path)
482
-
483
-
484
- # ── Stage 2: Definition generation (one call per L1) ─────────────────────────
485
-
486
- def _generate_l1_defs(l1_name: str, predefined_l2s: list, new_l2s: list,
487
- samples: list, client) -> dict:
488
- prompt = DEFINITION_PROMPT.format(
489
- l1_name=l1_name,
490
- base_description=FIXED_DESCRIPTIONS.get(l1_name, f"Customer messages related to {l1_name}."),
491
- predefined_l2_json=json.dumps(predefined_l2s),
492
- new_l2_json=json.dumps(new_l2s),
493
- samples_text="\n".join(f"- {s[:200]}" for s in samples[:15]),
494
- )
495
- try:
496
- resp = _api_call(client,
497
- SERVICE_MANAGED_MODEL,
498
- max_tokens=1200,
499
- messages=[{"role": "user", "content": prompt}],
500
- )
501
- return _parse_json_response(resp.content[0].text)
502
- except Exception as e:
503
- print(f" [defs] failed for '{l1_name}': {e}")
504
- return {
505
- "l1_def": FIXED_DESCRIPTIONS.get(l1_name, f"Customer messages related to {l1_name}."),
506
- "l2_defs": {l2: f"Issues related to {l2}." for l2 in predefined_l2s + new_l2s},
507
- "new_l2_normalized": {},
508
- }
509
-
510
-
511
- # ── Stage 3: Assemble results ─────────────────────────────────────────────────
512
-
513
- def _build_intent_entry(name: str, items: list, l1_def: str, l2_defs: dict,
514
- is_new: bool, grand_total: int) -> dict:
515
- predefined = set(TAXONOMY.get(name, []))
516
- max_sub = min(len(predefined) + MAX_NEW_L2_PER_L1, MAX_L2_TOTAL)
517
- sub_groups: dict = defaultdict(list)
518
- for it in items:
519
- l2 = it.get("l2") or ""
520
- if l2:
521
- sub_groups[l2].append(it.get("text", ""))
522
- total = len(items)
523
- sorted_subs = sorted(sub_groups.items(), key=lambda x: -len(x[1]))
524
- top_subs = sorted_subs[:max_sub]
525
- overflow_subs = sorted_subs[max_sub:]
526
- sub_intents = []
527
- for sub_name, sub_samps in top_subs:
528
- sub_count = len(sub_samps)
529
- sub_intents.append({
530
- "name": sub_name,
531
- "definition": l2_defs.get(sub_name, ""),
532
- "count": sub_count,
533
- "pct": round(100 * sub_count / total, 1),
534
- "samples": sub_samps[:5],
535
- "is_new": sub_name not in predefined,
536
- })
537
- if overflow_subs:
538
- overflow_count = sum(len(s) for _, s in overflow_subs)
539
- overflow_samples = [t for _, samps in overflow_subs for t in samps][:5]
540
- sub_intents.append({
541
- "name": "Other", "definition": "", "count": overflow_count,
542
- "pct": round(100 * overflow_count / total, 1),
543
- "samples": overflow_samples, "is_new": False,
544
- })
545
- no_l2_count = sum(1 for it in items if not it.get("l2"))
546
- if no_l2_count:
547
- for si in sub_intents:
548
- if si["name"] == "Other":
549
- si["count"] += no_l2_count
550
- si["pct"] = round(100 * si["count"] / total, 1)
551
- break
552
- else:
553
- sub_intents.append({
554
- "name": "Other", "definition": "", "count": no_l2_count,
555
- "pct": round(100 * no_l2_count / total, 1),
556
- "samples": [], "is_new": False,
557
- })
558
- return {
559
- "name": name, "definition": l1_def,
560
- "count": total, "pct": round(100 * total / grand_total, 1) if grand_total else 0.0,
561
- "sub_intents": sub_intents,
562
- "samples": [it.get("text", "") for it in items][:5],
563
- "is_new": is_new,
564
- }
565
-
566
-
567
- def assemble_results(raw_items: dict, all_items: list, client,
568
- run_id: str, items_path: str) -> dict:
569
- buckets: dict = defaultdict(list)
570
- for item in all_items:
571
- classified = raw_items.get(item["id"], {})
572
- l1 = classified.get("l1") or "Other"
573
- buckets[l1].append(dict(item, **classified))
574
-
575
- grand_total = len(all_items)
576
- if buckets.get("Other"):
577
- other_items = buckets.pop("Other")
578
- other_pct = len(other_items) / grand_total if grand_total else 0
579
- print(f" [assemble] Other: {len(other_items)} ({other_pct*100:.1f}%) — clustering up to {MAX_NEW_L1} new L1s...")
580
- if MAX_NEW_L1 > 0:
581
- clusters, cluster_items = cluster_new_intents(other_items, list(TAXONOMY.keys()), client, max_new=MAX_NEW_L1)
582
- for cluster in clusters:
583
- name = cluster["name"]
584
- items_for_cluster = cluster_items.get(name, [])
585
- if name != "Other" and items_for_cluster:
586
- buckets[name] = items_for_cluster
587
- print(f" [assemble] New L1 discovered: '{name}' ({len(items_for_cluster)} items)")
588
- if cluster_items.get("Other"):
589
- buckets["Other"] = cluster_items["Other"]
590
- else:
591
- buckets["Other"] = other_items
592
-
593
- # Generate definitions in parallel
594
- print("[assemble] Generating definitions...")
595
- l1_def_results: dict = {}
596
-
597
- def _def_task(l1_name):
598
- items = buckets[l1_name]
599
- predefined_l2s = TAXONOMY.get(l1_name, [])
600
- new_l2s = list({it.get("l2") or ""
601
- for it in items
602
- if (it.get("l2") or "") and (it.get("l2") or "") not in predefined_l2s} - {""})
603
- samples = [it.get("text", "") for it in items[:20]]
604
- return l1_name, _generate_l1_defs(l1_name, predefined_l2s, new_l2s, samples, client)
605
-
606
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
607
- futs = {ex.submit(_def_task, name): name for name in buckets if buckets[name]}
608
- for fut in as_completed(futs):
609
- name, result = fut.result()
610
- l1_def_results[name] = result
611
-
612
- known_entries = []
613
- new_entries = []
614
- ordered = [k for k in TAXONOMY if k in buckets]
615
- ordered += [k for k in buckets if k not in TAXONOMY]
616
-
617
- for l1_name in ordered:
618
- items = buckets.get(l1_name, [])
619
- if not items:
620
- continue
621
- defs = l1_def_results.get(l1_name, {})
622
- norm_map = defs.get("new_l2_normalized", {})
623
- if norm_map:
624
- for it in items:
625
- l2 = it.get("l2") or ""
626
- if l2 in norm_map:
627
- it["l2"] = norm_map[l2]
628
- l1_def = defs.get("l1_def", FIXED_DESCRIPTIONS.get(l1_name, f"Customer messages related to {l1_name}."))
629
- l2_defs = defs.get("l2_defs", {})
630
- is_new = l1_name not in TAXONOMY
631
- entry = _build_intent_entry(l1_name, items, l1_def, l2_defs, is_new, grand_total)
632
- if is_new:
633
- new_entries.append(entry)
634
- else:
635
- known_entries.append(entry)
636
-
637
- total_classified = sum(e["count"] for e in known_entries + new_entries)
638
-
639
- # Save per-item detail for Excel export
640
- all_known_l1s = set(TAXONOMY.keys())
641
- item_rows = []
642
- for l1_name in ordered:
643
- items = buckets.get(l1_name, [])
644
- is_new_l1 = l1_name not in all_known_l1s
645
- predefined = set(TAXONOMY.get(l1_name, []))
646
- for it in items:
647
- l2 = it.get("l2") or ""
648
- item_rows.append({
649
- "id": it.get("id", ""),
650
- "title": it.get("title", ""),
651
- "first_message": it.get("first_message", "") or it.get("text", ""),
652
- "messages": it.get("messages", ""),
653
- "text": it.get("text", ""),
654
- "l1": l1_name, "l2": l2,
655
- "is_new_l1": is_new_l1,
656
- "is_new_l2": bool(l2 and l2 not in predefined),
657
- })
658
- with open(items_path, "w") as f:
659
- json.dump(item_rows, f, ensure_ascii=False, indent=2)
660
- print(f"[assemble] Items detail -> {items_path}")
661
-
662
- return {
663
- "meta": {
664
- "run_id": run_id,
665
- "total": grand_total, "classified": total_classified,
666
- "n_known": len(known_entries), "n_new": len(new_entries),
667
- },
668
- "intents": sorted(known_entries, key=lambda x: -x["count"]),
669
- "new_intents": sorted(new_entries, key=lambda x: -x["count"]),
670
- }
671
-
672
-
673
- # ── No-excel mode: synthetic results from fixed TAXONOMY ─────────────────────
674
-
675
- def generate_no_excel_results(run_id: str) -> dict:
676
- intents = []
677
- for name, l2s in TAXONOMY.items():
678
- sub_intents = [
679
- {"name": l2, "definition": "", "count": 0, "pct": 0.0, "samples": [], "is_new": False}
680
- for l2 in l2s
681
- ]
682
- intents.append({
683
- "name": name,
684
- "definition": FIXED_DESCRIPTIONS.get(name, ""),
685
- "count": 0, "pct": 0.0,
686
- "sub_intents": sub_intents,
687
- "samples": [], "is_new": False,
688
- })
689
- return {
690
- "meta": {
691
- "run_id": run_id,
692
- "total": 0, "classified": 0,
693
- "n_known": len(intents), "n_new": 0,
694
- },
695
- "intents": intents,
696
- "new_intents": [],
697
- }
698
-
699
-
700
- # ── Main ──────────────────────────────────────────────────────────────────────
701
-
702
- def main():
703
- parser = argparse.ArgumentParser(description="Shared intent classifier")
704
- parser.add_argument("--owner", choices=["poc", "delivery", "solvea-kit"], default="solvea-kit",
705
- help="Business owner for workspace state/output paths")
706
- parser.add_argument("--xlsx", default=None, help="Path to complaints Excel file")
707
- parser.add_argument("--no-excel", action="store_true", help="Use fixed taxonomy, skip LLM classification")
708
- parser.add_argument("--max-rows", type=int, default=2000)
709
- parser.add_argument("--run-id", default=None)
710
- parser.add_argument("--title-col", default=None,
711
- help="工单标题列名(可选)")
712
- parser.add_argument("--messages-col", default=None,
713
- help="完整对话历史列名(必填,支持 JSON comments 数组或纯文本)")
714
- args = parser.parse_args()
715
-
716
- if not args.xlsx and not args.no_excel:
717
- parser.error("Must provide --xlsx <path> or --no-excel")
718
- if args.xlsx and not args.messages_col:
719
- parser.error("--messages-col is required when using --xlsx")
720
-
721
- global OUTPUTS_DIR, STATE_DIR
722
- if args.owner != "solvea-kit":
723
- OUTPUTS_DIR = WORKSPACE / "outputs" / args.owner
724
- STATE_DIR = WORKSPACE / "state" / args.owner
725
-
726
- run_id = args.run_id or timestamp()
727
- ts = run_id
728
- OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
729
- results_path = str(OUTPUTS_DIR / f"results_{ts}.json")
730
- items_path = str(OUTPUTS_DIR / f"items_{ts}.json")
731
-
732
- if args.no_excel:
733
- final = generate_no_excel_results(run_id)
734
- with open(results_path, "w") as f:
735
- json.dump(final, f, ensure_ascii=False, indent=2)
736
- _sf = STATE_DIR / "progress.json"
737
- _sf.parent.mkdir(parents=True, exist_ok=True)
738
- _sf.write_text(json.dumps({
739
- "status": "no_excel",
740
- "run_id": run_id,
741
- "results_path": results_path,
742
- "updated_at": timestamp(),
743
- }, ensure_ascii=False, indent=2), encoding="utf-8")
744
- print(f"[classify] No-excel mode: {len(final['intents'])} fixed intents")
745
- print(f"[classify] Results -> {results_path}")
746
- for intent in final["intents"]:
747
- l2_names = [s["name"] for s in intent["sub_intents"]]
748
- l2_str = ", ".join(l2_names) if l2_names else "(no predefined L2)"
749
- print(f" - {intent['name']}: {l2_str}")
750
- return
751
-
752
- client = OpenAI(timeout=60.0)
753
-
754
- _sf = STATE_DIR / "progress.json"
755
- _sf.parent.mkdir(parents=True, exist_ok=True)
756
- _sf.write_text(json.dumps({
757
- "status": "classifying",
758
- "run_id": run_id,
759
- "results_path": results_path,
760
- "updated_at": timestamp(),
761
- }, ensure_ascii=False, indent=2), encoding="utf-8")
762
-
763
- print(f"[classify] Loading {args.xlsx}...")
764
- all_items = load_xlsx(args.xlsx, max_rows=args.max_rows,
765
- messages_col=args.messages_col,
766
- title_col=args.title_col)
767
- print(f"[classify] {len(all_items)} items loaded")
768
-
769
- checkpoint = load_checkpoint(results_path)
770
- done_ids = set(checkpoint.get("raw_items", {}).keys())
771
- remaining = [it for it in all_items if it["id"] not in done_ids]
772
- print(f"[classify] {len(done_ids)} already done, {len(remaining)} remaining")
773
-
774
- batches = [remaining[i:i + BATCH_SIZE] for i in range(0, len(remaining), BATCH_SIZE)]
775
- print(f"[classify] {len(batches)} batches x {BATCH_SIZE}, {MAX_WORKERS} workers")
776
-
777
- def _process_batch(idx_batch):
778
- idx, batch = idx_batch
779
- results = classify_batch(batch, client)
780
- for classified in results:
781
- save_item(results_path, checkpoint, classified["id"], classified)
782
- print(f" [batch {idx+1}/{len(batches)}] done ({len(batch)} items)")
783
-
784
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
785
- list(executor.map(_process_batch, enumerate(batches)))
786
-
787
- # Re-classify Other items if they exceed the threshold (up to MAX_RESEARCH_ROUNDS)
788
- def _make_reprocess(round_num: int, round_batches: list):
789
- def _inner(idx_batch):
790
- idx, batch = idx_batch
791
- results = classify_batch(batch, client)
792
- for classified in results:
793
- save_item(results_path, checkpoint, classified["id"], classified)
794
- print(f" [re-round {round_num+1} batch {idx+1}/{len(round_batches)}] done ({len(batch)} items)")
795
- return _inner
796
-
797
- for _round in range(MAX_RESEARCH_ROUNDS):
798
- other_ids = {iid for iid, r in checkpoint["raw_items"].items() if r.get("l1") == "Other"}
799
- if not other_ids:
800
- break
801
- other_pct = len(other_ids) / len(all_items)
802
- if other_pct <= OTHER_THRESHOLD:
803
- break
804
- print(f"[classify] Other {other_pct*100:.1f}% > {OTHER_THRESHOLD*100:.0f}% — re-classify round {_round+1}/{MAX_RESEARCH_ROUNDS}...")
805
- round_items = [it for it in all_items if it["id"] in other_ids]
806
- round_batches = [round_items[i:i + BATCH_SIZE] for i in range(0, len(round_items), BATCH_SIZE)]
807
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
808
- list(executor.map(_make_reprocess(_round, round_batches), enumerate(round_batches)))
809
-
810
- print("[classify] Assembling results...")
811
- final = assemble_results(checkpoint["raw_items"], all_items, client,
812
- run_id, items_path)
813
-
814
- final["meta"]["usage"] = _tracker.summary()
815
-
816
- with open(results_path, "w") as f:
817
- json.dump(final, f, ensure_ascii=False, indent=2)
818
-
819
- _sf = STATE_DIR / "progress.json"
820
- _sf.parent.mkdir(parents=True, exist_ok=True)
821
- _sf.write_text(json.dumps({
822
- "status": "ready",
823
- "run_id": run_id,
824
- "results_path": results_path,
825
- "items_path": items_path,
826
- "updated_at": timestamp(),
827
- }, ensure_ascii=False, indent=2), encoding="utf-8")
828
-
829
- meta = final["meta"]
830
- total = meta["total"]
831
- print(f"[classify] Done. {meta['n_known']} known + {meta['n_new']} new intents, "
832
- f"{total} total items")
833
- print(f"[classify] Intent distribution:")
834
- for e in final.get("intents", []):
835
- tag = "[new]" if e.get("is_new") else " "
836
- print(f" {tag} {e['name']}: {e['count']} ({e['pct']:.1f}%)")
837
- for e in final.get("new_intents", []):
838
- print(f" [new] {e['name']}: {e['count']} ({e['pct']:.1f}%)")
839
- print(f"[classify] Results -> {results_path}")
840
-
841
-
842
- if __name__ == "__main__":
843
- main()