taiwan-invoice-skill 2.0.0 → 2.2.0

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 (30) hide show
  1. package/README.md +131 -0
  2. package/assets/taiwan-invoice/SKILL.md +452 -0
  3. package/assets/taiwan-invoice/data/error-codes.csv +41 -0
  4. package/assets/taiwan-invoice/data/field-mappings.csv +27 -0
  5. package/assets/taiwan-invoice/data/operations.csv +11 -0
  6. package/assets/taiwan-invoice/data/providers.csv +4 -0
  7. package/assets/taiwan-invoice/data/tax-rules.csv +9 -0
  8. package/assets/taiwan-invoice/data/troubleshooting.csv +17 -0
  9. package/assets/taiwan-invoice/scripts/__pycache__/core.cpython-312.pyc +0 -0
  10. package/assets/taiwan-invoice/scripts/core.py +304 -0
  11. package/assets/taiwan-invoice/scripts/generate-invoice-service.py +642 -128
  12. package/assets/taiwan-invoice/scripts/recommend.py +340 -0
  13. package/assets/taiwan-invoice/scripts/search.py +201 -0
  14. package/assets/templates/base/quick-reference.md +85 -0
  15. package/assets/templates/platforms/{antigravity.json → agent.json} +6 -3
  16. package/assets/templates/platforms/claude.json +5 -2
  17. package/assets/templates/platforms/codebuddy.json +5 -2
  18. package/assets/templates/platforms/codex.json +5 -2
  19. package/assets/templates/platforms/continue.json +5 -2
  20. package/assets/templates/platforms/copilot.json +5 -2
  21. package/assets/templates/platforms/cursor.json +5 -2
  22. package/assets/templates/platforms/gemini.json +5 -2
  23. package/assets/templates/platforms/kiro.json +5 -2
  24. package/assets/templates/platforms/opencode.json +5 -2
  25. package/assets/templates/platforms/qoder.json +5 -2
  26. package/assets/templates/platforms/roocode.json +5 -2
  27. package/assets/templates/platforms/trae.json +5 -2
  28. package/assets/templates/platforms/windsurf.json +5 -2
  29. package/dist/index.js +265 -60
  30. package/package.json +3 -2
@@ -0,0 +1,17 @@
1
+ issue,symptom,cause,solution,provider,category,severity
2
+ B2B發票開立失敗,回傳錯誤代碼 6000015,ECPay測試環境B2B需預先註冊統編,使用B2C API加統編 或在正式環境測試,ECPay,開立,HIGH
3
+ 金額驗算錯誤,AllAmount與商品小計不符,AllAmount使用了錯誤的來源欄位,B2C用SalesAmount B2B用TotalAmount: AllAmount=TotalAmount||SalesAmount,SmilePay,開立,HIGH
4
+ orderid格式錯誤,回傳-10084錯誤,訂單編號超過30字元限制,orderid限30字元 data_id可50字元防重複,SmilePay,開立,MEDIUM
5
+ 列印發票空白,彈窗開啟但內容空白,SmilePay用GET但系統用POST提交,判斷method 若為GET則用redirect開啟URL,SmilePay,列印,MEDIUM
6
+ B2C列印需隨機碼,列印失敗或顯示錯誤,未儲存開立時回傳的randomNumber,開立成功後儲存randomNumber到invoiceRandomNum欄位,SmilePay,列印,HIGH
7
+ 必填欄位遺漏,開立失敗 參數錯誤,FreeTaxSalesAmount ZeroTaxSalesAmount TaxRate等未填,即使為0也必須填入 TaxRate填"0.05",Amego,開立,HIGH
8
+ 隨機碼未儲存,列印時出錯,未儲存開立時回傳的random_number,開立成功後儲存randomNumber欄位,Amego,列印,HIGH
9
+ B2B金額計算錯誤,稅額不正確,未正確區分含稅/未稅價格,使用DetailVat區分: B2B=0未稅 B2C=1含稅,Amego,開立,HIGH
10
+ 列印錯誤服務商,查詢不到發票,用其他服務商的API查光貿開的發票,開立時儲存invoiceProvider 列印時優先使用,All,列印,HIGH
11
+ 加密驗證失敗,TransCode非1或code=2,HashKey/HashIV錯誤或加密流程問題,確認加密流程: URL Encode→AES-128-CBC(ECPay) 或 MD5簽章(Amego),ECPay/Amego,認證,HIGH
12
+ 時間戳逾時,10分鐘或60秒誤差,伺服器時間與本地時間不同步,使用API查詢伺服器時間 或同步本地時間,ECPay/Amego,認證,MEDIUM
13
+ 載具與捐贈衝突,不可同時存在,同時設定載具和捐贈,二擇一: 有載具則捐贈=0 有捐贈則載具為空,All,開立,MEDIUM
14
+ B2B不可使用載具,統編發票不可用載具,打統編發票設定了載具,B2B發票移除載具設定,All,開立,MEDIUM
15
+ B2B不可捐贈,統編發票不可捐贈,打統編發票設定了捐贈,B2B發票設定Donation=0,All,開立,MEDIUM
16
+ 發票已作廢,無法重複作廢,發票狀態已是作廢,檢查發票狀態後再操作,All,作廢,LOW
17
+ 折讓超過發票金額,折讓金額大於原發票,折讓總額累計超過發票金額,計算剩餘可折讓金額,All,折讓,MEDIUM
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Taiwan Invoice Skill - BM25 Search Engine
4
+ 基於 UIUX Pro Max 架構,針對電子發票數據優化
5
+
6
+ 無外部依賴,純 Python 實現 BM25 搜索算法
7
+ """
8
+
9
+ import csv
10
+ import math
11
+ import re
12
+ import os
13
+ from typing import List, Dict, Any, Optional, Tuple
14
+
15
+ # 取得 data 目錄路徑
16
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ DATA_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), 'data')
18
+
19
+ # CSV 設定:定義各域的搜索欄位和輸出欄位
20
+ CSV_CONFIG = {
21
+ 'provider': {
22
+ 'file': 'providers.csv',
23
+ 'search_cols': ['provider', 'display_name', 'auth_method', 'features'],
24
+ 'output_cols': ['provider', 'display_name', 'auth_method', 'encryption', 'test_merchant_id', 'features']
25
+ },
26
+ 'operation': {
27
+ 'file': 'operations.csv',
28
+ 'search_cols': ['operation', 'operation_zh', 'notes'],
29
+ 'output_cols': ['operation', 'operation_zh', 'ecpay_b2c_endpoint', 'smilepay_endpoint', 'amego_endpoint', 'required_fields', 'notes']
30
+ },
31
+ 'error': {
32
+ 'file': 'error-codes.csv',
33
+ 'search_cols': ['provider', 'code', 'message_zh', 'message_en', 'category', 'solution'],
34
+ 'output_cols': ['provider', 'code', 'message_zh', 'category', 'solution']
35
+ },
36
+ 'field': {
37
+ 'file': 'field-mappings.csv',
38
+ 'search_cols': ['field_name', 'description', 'ecpay_name', 'smilepay_name', 'amego_name', 'notes'],
39
+ 'output_cols': ['field_name', 'description', 'ecpay_name', 'smilepay_name', 'amego_name', 'type', 'required_b2c', 'required_b2b']
40
+ },
41
+ 'tax': {
42
+ 'file': 'tax-rules.csv',
43
+ 'search_cols': ['invoice_type', 'tax_type', 'notes'],
44
+ 'output_cols': ['invoice_type', 'tax_type', 'tax_rate', 'sales_amount_formula', 'tax_amount_formula', 'example_total', 'example_sales', 'example_tax']
45
+ },
46
+ 'troubleshoot': {
47
+ 'file': 'troubleshooting.csv',
48
+ 'search_cols': ['issue', 'symptom', 'cause', 'solution', 'provider', 'category'],
49
+ 'output_cols': ['issue', 'symptom', 'cause', 'solution', 'provider', 'severity']
50
+ }
51
+ }
52
+
53
+ # 域名自動偵測關鍵字
54
+ DOMAIN_KEYWORDS = {
55
+ 'provider': ['ecpay', '綠界', 'smilepay', '速買配', 'amego', '光貿', 'provider', '加值中心', '服務商'],
56
+ 'operation': ['issue', 'void', 'allowance', '開立', '作廢', '折讓', '列印', 'print', 'query', '查詢', 'endpoint', 'api'],
57
+ 'error': ['error', 'code', '錯誤', '代碼', '失敗', 'fail', '-', '10000', '1001', '2001'],
58
+ 'field': ['field', 'param', '欄位', '參數', 'mapping', '映射', 'merchantid', 'orderid', 'buyername'],
59
+ 'tax': ['tax', 'b2c', 'b2b', '稅', '應稅', '免稅', '零稅率', 'salesamount', 'taxamount', '計算'],
60
+ 'troubleshoot': ['問題', 'issue', 'error', 'fix', '解決', '失敗', '空白', 'troubleshoot', '踩坑']
61
+ }
62
+
63
+
64
+ def tokenize(text: str) -> List[str]:
65
+ """
66
+ 將文字分詞為 token 列表
67
+ 支援中英文混合
68
+ """
69
+ if not text:
70
+ return []
71
+
72
+ text = text.lower()
73
+ # 移除標點符號,保留中文、英文、數字
74
+ text = re.sub(r'[^\w\u4e00-\u9fff\s-]', ' ', text)
75
+ # 分割並過濾長度 < 2 的 token (英文)
76
+ tokens = text.split()
77
+ return [t for t in tokens if len(t) >= 1]
78
+
79
+
80
+ def compute_idf(documents: List[List[str]]) -> Dict[str, float]:
81
+ """
82
+ 計算 IDF (Inverse Document Frequency)
83
+ """
84
+ N = len(documents)
85
+ if N == 0:
86
+ return {}
87
+
88
+ df = {} # document frequency
89
+ for doc in documents:
90
+ unique_terms = set(doc)
91
+ for term in unique_terms:
92
+ df[term] = df.get(term, 0) + 1
93
+
94
+ idf = {}
95
+ for term, freq in df.items():
96
+ idf[term] = math.log((N - freq + 0.5) / (freq + 0.5) + 1)
97
+
98
+ return idf
99
+
100
+
101
+ def bm25_score(query_tokens: List[str], doc_tokens: List[str],
102
+ idf: Dict[str, float], avg_dl: float,
103
+ k1: float = 1.5, b: float = 0.75) -> float:
104
+ """
105
+ 計算 BM25 分數
106
+ """
107
+ if not doc_tokens or not query_tokens:
108
+ return 0.0
109
+
110
+ doc_len = len(doc_tokens)
111
+ score = 0.0
112
+
113
+ # 計算詞頻
114
+ tf = {}
115
+ for token in doc_tokens:
116
+ tf[token] = tf.get(token, 0) + 1
117
+
118
+ for term in query_tokens:
119
+ if term not in tf:
120
+ continue
121
+
122
+ freq = tf[term]
123
+ term_idf = idf.get(term, 0)
124
+
125
+ # BM25 公式
126
+ numerator = freq * (k1 + 1)
127
+ denominator = freq + k1 * (1 - b + b * doc_len / avg_dl) if avg_dl > 0 else freq + k1
128
+ score += term_idf * (numerator / denominator)
129
+
130
+ return score
131
+
132
+
133
+ def _load_csv(filepath: str) -> List[Dict[str, str]]:
134
+ """
135
+ 載入 CSV 檔案
136
+ """
137
+ if not os.path.exists(filepath):
138
+ return []
139
+
140
+ rows = []
141
+ with open(filepath, 'r', encoding='utf-8') as f:
142
+ reader = csv.DictReader(f)
143
+ for row in reader:
144
+ rows.append(row)
145
+ return rows
146
+
147
+
148
+ def _search_csv(query: str, domain: str, max_results: int = 5) -> List[Dict[str, Any]]:
149
+ """
150
+ 對指定域的 CSV 進行 BM25 搜索
151
+ """
152
+ if domain not in CSV_CONFIG:
153
+ return []
154
+
155
+ config = CSV_CONFIG[domain]
156
+ filepath = os.path.join(DATA_DIR, config['file'])
157
+ rows = _load_csv(filepath)
158
+
159
+ if not rows:
160
+ return []
161
+
162
+ # 建立文檔
163
+ documents = []
164
+ for row in rows:
165
+ doc_text = ' '.join(str(row.get(col, '')) for col in config['search_cols'])
166
+ documents.append(tokenize(doc_text))
167
+
168
+ # 計算 IDF 和平均文檔長度
169
+ idf = compute_idf(documents)
170
+ avg_dl = sum(len(doc) for doc in documents) / len(documents) if documents else 1
171
+
172
+ # 計算每個文檔的分數
173
+ query_tokens = tokenize(query)
174
+ scored_results = []
175
+
176
+ for i, (row, doc_tokens) in enumerate(zip(rows, documents)):
177
+ score = bm25_score(query_tokens, doc_tokens, idf, avg_dl)
178
+ if score > 0:
179
+ result = {col: row.get(col, '') for col in config['output_cols']}
180
+ result['_score'] = round(score, 4)
181
+ scored_results.append(result)
182
+
183
+ # 按分數排序
184
+ scored_results.sort(key=lambda x: x['_score'], reverse=True)
185
+
186
+ return scored_results[:max_results]
187
+
188
+
189
+ def detect_domain(query: str) -> str:
190
+ """
191
+ 自動偵測查詢屬於哪個域
192
+ """
193
+ query_lower = query.lower()
194
+
195
+ scores = {domain: 0 for domain in DOMAIN_KEYWORDS}
196
+
197
+ for domain, keywords in DOMAIN_KEYWORDS.items():
198
+ for keyword in keywords:
199
+ if keyword.lower() in query_lower:
200
+ scores[domain] += 1
201
+
202
+ # 找出最高分的域
203
+ best_domain = max(scores, key=scores.get)
204
+
205
+ # 如果沒有匹配,預設為 troubleshoot
206
+ if scores[best_domain] == 0:
207
+ return 'troubleshoot'
208
+
209
+ return best_domain
210
+
211
+
212
+ def search(query: str, domain: Optional[str] = None, max_results: int = 5) -> List[Dict[str, Any]]:
213
+ """
214
+ 主搜索函數
215
+
216
+ Args:
217
+ query: 搜索查詢
218
+ domain: 指定域 (provider, operation, error, field, tax, troubleshoot)
219
+ 如果不指定,會自動偵測
220
+ max_results: 最大結果數
221
+
222
+ Returns:
223
+ 搜索結果列表
224
+ """
225
+ if not domain:
226
+ domain = detect_domain(query)
227
+
228
+ return _search_csv(query, domain, max_results)
229
+
230
+
231
+ def search_all(query: str, max_per_domain: int = 3) -> Dict[str, List[Dict[str, Any]]]:
232
+ """
233
+ 在所有域中搜索
234
+
235
+ Args:
236
+ query: 搜索查詢
237
+ max_per_domain: 每個域的最大結果數
238
+
239
+ Returns:
240
+ 按域分類的搜索結果
241
+ """
242
+ results = {}
243
+ for domain in CSV_CONFIG.keys():
244
+ domain_results = _search_csv(query, domain, max_per_domain)
245
+ if domain_results:
246
+ results[domain] = domain_results
247
+ return results
248
+
249
+
250
+ def get_available_domains() -> List[str]:
251
+ """
252
+ 取得可用的搜索域列表
253
+ """
254
+ return list(CSV_CONFIG.keys())
255
+
256
+
257
+ def get_domain_info(domain: str) -> Optional[Dict[str, Any]]:
258
+ """
259
+ 取得域的設定資訊
260
+ """
261
+ if domain not in CSV_CONFIG:
262
+ return None
263
+
264
+ config = CSV_CONFIG[domain]
265
+ filepath = os.path.join(DATA_DIR, config['file'])
266
+ rows = _load_csv(filepath)
267
+
268
+ return {
269
+ 'domain': domain,
270
+ 'file': config['file'],
271
+ 'search_cols': config['search_cols'],
272
+ 'output_cols': config['output_cols'],
273
+ 'total_records': len(rows)
274
+ }
275
+
276
+
277
+ # CLI 測試
278
+ if __name__ == '__main__':
279
+ import sys
280
+
281
+ if len(sys.argv) < 2:
282
+ print("Usage: python core.py <query> [domain]")
283
+ print("\nAvailable domains:", ', '.join(get_available_domains()))
284
+ sys.exit(1)
285
+
286
+ query = sys.argv[1]
287
+ domain = sys.argv[2] if len(sys.argv) > 2 else None
288
+
289
+ print(f"Query: {query}")
290
+ if domain:
291
+ print(f"Domain: {domain}")
292
+ else:
293
+ detected = detect_domain(query)
294
+ print(f"Auto-detected domain: {detected}")
295
+
296
+ print()
297
+
298
+ results = search(query, domain)
299
+ for i, result in enumerate(results, 1):
300
+ print(f"[{i}] Score: {result.get('_score', 0)}")
301
+ for key, value in result.items():
302
+ if key != '_score' and value:
303
+ print(f" {key}: {value}")
304
+ print()