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