unieai-mcp-accton-rfp 0.0.21__tar.gz

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 (24) hide show
  1. unieai_mcp_accton_rfp-0.0.21/LICENSE +7 -0
  2. unieai_mcp_accton_rfp-0.0.21/PKG-INFO +30 -0
  3. unieai_mcp_accton_rfp-0.0.21/README.md +0 -0
  4. unieai_mcp_accton_rfp-0.0.21/pyproject.toml +30 -0
  5. unieai_mcp_accton_rfp-0.0.21/setup.cfg +51 -0
  6. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/__init__.py +0 -0
  7. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server.py +461 -0
  8. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_old.py +189 -0
  9. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v0.py +337 -0
  10. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v1.py +160 -0
  11. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v12.py +327 -0
  12. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v2.py +245 -0
  13. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v3.py +171 -0
  14. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v4.py +373 -0
  15. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v6.py +349 -0
  16. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v7.py +402 -0
  17. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/server_v9.py +374 -0
  18. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp/test.py +108 -0
  19. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp.egg-info/PKG-INFO +30 -0
  20. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp.egg-info/SOURCES.txt +23 -0
  21. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp.egg-info/dependency_links.txt +1 -0
  22. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp.egg-info/entry_points.txt +2 -0
  23. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp.egg-info/requires.txt +15 -0
  24. unieai_mcp_accton_rfp-0.0.21/src/unieai_mcp_accton_rfp.egg-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ Internal Use License
2
+
3
+ Copyright (c) 2025 UnieAI
4
+
5
+ This software is proprietary and confidential. It is intended solely for internal use within UnieAI. No part of this software may be copied, modified, distributed, or used outside UnieAI without prior written consent.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL UNIEAI BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY...
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: unieai_mcp_accton_rfp
3
+ Version: 0.0.21
4
+ Summary: Internal MCP server for Accton RFP automation (UnieAI)
5
+ Home-page: https://www.unieai.com/
6
+ Author: UnieAI
7
+ Author-email: contact@unieai.com
8
+ License: Proprietary
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: fastmcp>=0.3.0
16
+ Requires-Dist: fastapi>=0.104.0
17
+ Requires-Dist: pydantic>=2.0.0
18
+ Requires-Dist: python-dotenv>=1.0.1
19
+ Requires-Dist: requests>=2.31.0
20
+ Requires-Dist: openpyxl>=3.1.5
21
+ Requires-Dist: httpx>=0.24.0
22
+ Requires-Dist: openai>=1.35.10
23
+ Requires-Dist: langchain>=0.3.7
24
+ Requires-Dist: langchain-core>=0.3.9
25
+ Requires-Dist: langchain-openai>=0.2.3
26
+ Requires-Dist: aiohttp>=3.9.5
27
+ Requires-Dist: tiktoken>=0.7.0
28
+ Requires-Dist: rich>=13.7.1
29
+ Requires-Dist: tenacity>=8.3.0
30
+ Dynamic: license-file
File without changes
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.poetry]
6
+ name = "unieai-mcp-accton-rfp"
7
+ version = "0.0.21"
8
+ description = "UnieAI MCP server for Accton RFP automation"
9
+ authors = ["UnieAI <contact@unieai.com>"]
10
+ license = "Proprietary"
11
+ readme = "README.md"
12
+ homepage = "https://www.unieai.com/"
13
+ packages = [
14
+ { include = "unieai_mcp_accton_rfp", from = "src" } # 修正 packages 路徑
15
+ ]
16
+
17
+ [tool.poetry.dependencies]
18
+ python = "^3.8"
19
+
20
+ # === MCP Core Framework ===
21
+ fastmcp = "^0.3.0"
22
+ fastapi = "^0.100.0" # 修正版本
23
+ # 其他依賴保持不變
24
+
25
+ [tool.poetry.group.dev.dependencies]
26
+ black = "^24.3.0"
27
+ pytest = "^8.2.0"
28
+
29
+ [tool.poetry.scripts]
30
+ unieai-mcp-accton-rfp = "unieai_mcp_accton_rfp.server:main" # 確保 server.py 中有 main 函數
@@ -0,0 +1,51 @@
1
+ [metadata]
2
+ name = unieai_mcp_accton_rfp
3
+ version = 0.0.21
4
+ author = UnieAI
5
+ author_email = contact@unieai.com
6
+ description = Internal MCP server for Accton RFP automation (UnieAI)
7
+ long_description = file: README.md
8
+ long_description_content_type = text/markdown
9
+ url = https://www.unieai.com/
10
+ license = Proprietary
11
+ classifiers =
12
+ Programming Language :: Python :: 3
13
+ License :: Other/Proprietary License
14
+ Operating System :: OS Independent
15
+
16
+ [options]
17
+ package_dir =
18
+ = src
19
+ packages = find:
20
+ install_requires =
21
+ fastmcp >= 0.3.0
22
+ fastapi >= 0.104.0
23
+
24
+ pydantic >= 2.0.0
25
+ python-dotenv >= 1.0.1
26
+ requests >= 2.31.0
27
+ openpyxl >= 3.1.5
28
+ httpx >= 0.24.0
29
+
30
+ openai >= 1.35.10
31
+ langchain >= 0.3.7
32
+ langchain-core >= 0.3.9
33
+ langchain-openai >= 0.2.3
34
+ aiohttp >= 3.9.5
35
+ tiktoken >= 0.7.0
36
+
37
+ rich >= 13.7.1
38
+ tenacity >= 8.3.0
39
+ python_requires = >=3.8
40
+
41
+ [options.packages.find]
42
+ where = src
43
+
44
+ [options.entry_points]
45
+ console_scripts =
46
+ unieai-mcp-accton-rfp = unieai_mcp_accton_rfp.server:main
47
+
48
+ [egg_info]
49
+ tag_build =
50
+ tag_date = 0
51
+
@@ -0,0 +1,461 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+ import tempfile
7
+ from typing import Any, Dict, Tuple, List, Optional
8
+ from datetime import datetime
9
+
10
+ import requests
11
+ from dotenv import load_dotenv
12
+ from fastmcp import FastMCP
13
+ from openpyxl import load_workbook
14
+ from openpyxl.worksheet.worksheet import Worksheet
15
+
16
+ # LangChain (1.x API)
17
+ from langchain_openai import ChatOpenAI
18
+ from langchain_core.messages import HumanMessage, SystemMessage
19
+
20
+ # ==============================
21
+ # 🎛 Environment & Logging
22
+ # ==============================
23
+
24
+ logging.basicConfig(level=logging.INFO)
25
+ logger = logging.getLogger("unieai-mcp-accton-rfp")
26
+
27
+ # 限制同時運行的 LLM 請求數量,防止 API 速率限制
28
+ semaphore = asyncio.Semaphore(100)
29
+
30
+ # LLM 初始化 (LangChain 1.x)
31
+ # 注意:ChatOpenAI 不直接接受 top_k,需放入 model_kwargs
32
+ llm = ChatOpenAI(
33
+ model="Qwen3-30B-A3B-Instruct-2507-20251210-accton",
34
+ base_url="https://api.unieai.com/v1",
35
+ api_key="sk-XQvLNVMNTxWGxIQM3J8LYFvg3F2bYayYg0G40D4PddvhnDa6",
36
+ temperature=0.1, # 微小隨機性,提高準確度和流暢性
37
+ max_tokens=500, # 限制輸出長度
38
+ top_p=1.0
39
+ )
40
+
41
+ # Appwrite ENV (建議使用 os.environ.get() 讀取)
42
+ APPWRITE_PROJECT_ID = "6901b22e0036150b66d3"
43
+ APPWRITE_API_KEY = "standard_b1462cfd2cd0b6e5b5f305a10799444e009b880adf74e4b578e96222b148da57e17d57957fe3ffba9c7bfa2f6443b66fbcb851b8fbae0b91dc908139ca1d8e54c1bcba9034449d579449fc2abcdb1d9fdca3cc67bdb15140d8f5df1193264bd070e0f738bc3b13fd94de0d4aee3e2075f6b2124b803470d82f9501e806d16ffd"
44
+ APPWRITE_ENDPOINT = "https://sgp.cloud.appwrite.io/v1"
45
+
46
+ BATCH_SIZE = 100 # 每次並行處理的 Excel 行數
47
+
48
+
49
+ # ==============================
50
+ # 🔧 MCP Tool
51
+ # ==============================
52
+
53
+ def main():
54
+
55
+ mcp = FastMCP("unieai-mcp-accton-rfp")
56
+
57
+ @mcp.tool()
58
+ async def process_excel(url: str):
59
+ """
60
+ Accton RFP 需求符合性分析
61
+
62
+ 參數說明:
63
+ - url (str): Excel 檔案 URL
64
+
65
+ 使用範例:
66
+ process_excel(
67
+ url="https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/files/6937a7fb00180f83ab67/view?project=6901b22e0036150b66d3&mode=admin"
68
+ )
69
+
70
+ 返回:
71
+ - status: 成功或失敗
72
+ - location_type: 檔案來源類型(local, appwrite_new_file, remote_readonly)
73
+ - output_path: 本機暫存檔案路徑(僅 local 類型)
74
+ - file_id: 上傳後的 Appwrite 檔案 ID(僅 appwrite 類型)
75
+ - file_name: 上傳後的 Appwrite 檔案名稱(僅 appwrite 類型)
76
+ - upload_response: Appwrite 上傳回應(僅 appwrite 類型)
77
+ - download_url: Appwrite 檔案預覽 URL(僅 appwrite 類型)
78
+ - message: 其他訊息(僅 remote_readonly 類型)
79
+
80
+ version = 0.0.21
81
+ """
82
+ return await _process_excel_logic(url)
83
+
84
+ mcp.run()
85
+
86
+
87
+ # ==============================
88
+ # 🧩 Helper Functions
89
+ # ==============================
90
+
91
+ def _extract_json(text: str) -> Dict[str, Any]:
92
+ """擷取 JSON 區塊"""
93
+ match = re.search(r"\{[\s\S]*\}", text)
94
+ if match:
95
+ try:
96
+ return json.loads(match.group(0))
97
+ except Exception as e:
98
+ logger.warning(f"JSON 解析失敗: {e}")
99
+ return {"Result": "解析錯誤", "Reference": text.strip()}
100
+
101
+
102
+ def _parse_appwrite_url(url: str) -> Tuple[Optional[str], Optional[str]]:
103
+ pattern = r"/storage/buckets/([^/]+)/files/([^/]+)"
104
+ m = re.search(pattern, url)
105
+ if not m:
106
+ return None, None
107
+ return m.group(1), m.group(2)
108
+
109
+
110
+ def _generate_new_filename(original_name: str) -> str:
111
+ base, ext = os.path.splitext(original_name)
112
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
113
+ return f"{base}_processed_{timestamp}{ext}"
114
+
115
+
116
+ # ==============================
117
+ # 🤖 LLM Logic
118
+ # ==============================
119
+
120
+ async def _call_llm_raw(prompt: str, user_message: str, request_id: str):
121
+ """返回 LLM 純文字內容,並印出 ID 追蹤"""
122
+
123
+ logger.info(f"➡️ [ID:{request_id}] LLM 呼叫開始...")
124
+
125
+ try:
126
+ async with semaphore:
127
+ response = await llm.ainvoke([
128
+ SystemMessage(content=prompt),
129
+ HumanMessage(content=user_message)
130
+ ])
131
+
132
+ content = (response.content or "").strip()
133
+ logger.info(f"✅ [ID:{request_id}] LLM 呼叫完成。")
134
+ return content
135
+
136
+ except Exception as e:
137
+ logger.error(f"❌ [ID:{request_id}] LLM Error: {e}")
138
+ return f"LLM Error: {e}"
139
+
140
+
141
+ def _extract_result_json(text: str):
142
+ """解析第二階段 JSON"""
143
+ """
144
+ 使用非貪婪匹配來安全地提取 JSON。
145
+ r'\{[\s\S]*?\}' 中的 ? 表示非貪婪匹配,只匹配到第一個 }
146
+ """
147
+ try:
148
+ # 1. 使用非貪婪匹配提取可能的 JSON 結構
149
+ match = re.search(r'\{[\s\S]*?\}', text)
150
+
151
+ if not match:
152
+ return {"Result": "Error"}
153
+
154
+ json_str = match.group(0)
155
+
156
+ # 2. **【關鍵修正區塊】**:徹底清理字串
157
+ json_str_temp = json_str.strip()
158
+
159
+ # 移除常見的非標準空白,例如不間斷空格 (U+00A0, U+200B)
160
+ # 並將所有換行和 Tab 替換為標準空格,然後再 strip 一次
161
+ json_str_cleaned = (
162
+ json_str_temp
163
+ .replace('\u00A0', ' ') # 替換不間斷空格
164
+ .replace('\u200B', '') # 移除零寬度空格
165
+ .replace('\n', '') # 移除所有換行符
166
+ .replace('\r', '') # 移除所有回車符
167
+ .replace('\t', '') # 移除所有 Tab 符號
168
+ .strip() # 最後再 strip 一次,確保沒有殘留的 ASCII 空白
169
+ )
170
+
171
+ # print(f"除錯:最終嘗試解析的字串內容(無換行):{json_str_cleaned}")
172
+
173
+ # 3. 嘗試解析 JSON
174
+ return json.loads(json_str_cleaned)
175
+
176
+ except json.JSONDecodeError as e:
177
+ # print(f"JSON 解析錯誤: {e}")
178
+ return {"Result": "Error"}
179
+ except Exception as e:
180
+ # print(f"發生其他異常: {e}")
181
+ return {"Result": "Error"}
182
+
183
+
184
+ # ==============================
185
+ # 📘 Prompt 建構
186
+ # ==============================
187
+
188
+ def _build_reference_prompt() -> str:
189
+ # 保持原有的 prompt 結構
190
+ return """
191
+ 你是一位嚴謹的產品經理助理,專門負責將內部產品規格(知識庫)與客戶的需求單(RFP)進行比對和符合性分析。
192
+
193
+ 任務指示:
194
+ 1. 你將收到客戶的產品需求單 (RFP) 作為輸入。
195
+ 2. 你的知識庫已包含你公司產品的完整說明文件。
196
+ 3. 請仔細閱讀 RFP 中的每一條具體需求,並利用你的產品知識庫內容進行嚴格比對。
197
+
198
+ 比對規則:
199
+ 1. Conform (完全符合):公司的產品規格能完整且無條件地滿足 RFP 中的該項需求。
200
+ 2. Half Conform (部分符合):公司的產品規格只能滿足 RFP 中該項需求的部分內容,或者需要透過變通、額外配置或未來規劃才能滿足。
201
+ 3. Not Conform (不符合):公司的產品規格無法滿足 RFP 中的該項需求。
202
+
203
+ 輸出格式要求(嚴禁使用星號 *):
204
+ 你必須以條列式清晰地輸出分析結果,每一條結果必須包含:
205
+ 1. RFP 中的原始需求描述 (簡短摘錄或編號)。
206
+ 2. 符合程度 : (只能是:Conform, Half Conform, Not Conform 三者之一)。
207
+ 3. 參考依據 : (說明做出判斷的依據,需明確引用知識庫中的相關產品說明的關鍵資訊或段落,例如:知識庫中「功能A」的描述支持此判斷)。
208
+
209
+ 請針對 RFP 中的每一條主要需求逐一進行分析。
210
+ 注意:全文禁止出現任何星號符號。
211
+ 「請以純文字格式輸出,嚴禁使用 Markdown 語法(尤其是粗體星號)。」
212
+ """
213
+
214
+
215
+ def _build_result_prompt() -> str:
216
+ return """
217
+ 請依據以下 Reference 文本,判斷其符合性:
218
+ - Conform:完全符合
219
+ - Half Conform:部分符合
220
+ - Not Conform:不符合
221
+
222
+ 請僅輸出以下 JSON 格式:
223
+ {
224
+ "Result": "Conform / Half Conform / Not Conform"
225
+ }
226
+ """
227
+
228
+
229
+ def _build_user_message(a: str, b: str, c: str, d: str) -> str:
230
+ return f"""
231
+ {a}, {b}, {c}, {d}
232
+ """
233
+
234
+
235
+ def chunk_list(data, size):
236
+ """將 list 切成固定大小的區塊"""
237
+ for i in range(0, len(data), size):
238
+ yield data[i:i + size]
239
+
240
+
241
+ # ==============================
242
+ # 📊 Excel Processing Core
243
+ # ==============================
244
+
245
+ async def _process_excel_logic(url: str) -> Dict[str, Any]:
246
+ logger.info(f"🟢 開始處理 Excel:{url}")
247
+
248
+ # -------------------------
249
+ # Step 1: Download / Load (保持不變)
250
+ # -------------------------
251
+ source_type = ""
252
+ local_path = None
253
+ appwrite_info = (None, None)
254
+ bucket_id = None
255
+
256
+ if url.startswith("file:///"):
257
+ local_path = url.replace("file:///", "")
258
+ file_path = local_path
259
+ source_type = "local"
260
+
261
+ elif url.startswith("http"):
262
+ resp = requests.get(url)
263
+ resp.raise_for_status()
264
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
265
+ tmp.write(resp.content)
266
+ file_path = tmp.name
267
+
268
+ bucket_id, file_id = _parse_appwrite_url(url)
269
+ if bucket_id:
270
+ source_type = "appwrite"
271
+ appwrite_info = (bucket_id, file_id)
272
+ else:
273
+ source_type = "remote_readonly"
274
+
275
+ else:
276
+ raise ValueError("❌ 不支援檔案來源")
277
+
278
+ # -------------------------
279
+ # Step 2: Open Excel (保持不變)
280
+ # -------------------------
281
+ wb = load_workbook(file_path)
282
+ ws = wb.active
283
+
284
+ header = {cell.value: idx for idx, cell in enumerate(ws[1], 1)}
285
+ for col in ["itemA", "itemB", "itemC", "itemD", "Result", "Reference"]:
286
+ if col not in header:
287
+ raise ValueError(f"❌ Excel 缺少欄位:{col}")
288
+
289
+ # -------------------------
290
+ # Step 3: Two-stage LLM (Batch + Parallel + Tracking)
291
+ # -------------------------
292
+
293
+ rows_for_llm = []
294
+ request_counter = 0 # 🌟 追蹤總 API 呼叫次數
295
+
296
+ for row in ws.iter_rows(min_row=2, values_only=False):
297
+ if any([cell.value for cell in row]):
298
+ rows_for_llm.append(row)
299
+
300
+ reference_prompt = _build_reference_prompt()
301
+ result_prompt = _build_result_prompt()
302
+
303
+ total_llm_calls = len(rows_for_llm) * 2
304
+ logger.info(f"總共需要處理 {len(rows_for_llm)} 行,預計發出 {total_llm_calls} 次 LLM API 呼叫。")
305
+
306
+
307
+ # 批次處理
308
+ for batch_rows in chunk_list(rows_for_llm, BATCH_SIZE):
309
+
310
+ # -------- Stage 1: Reference (parallel inside batch) --------
311
+
312
+ ref_tasks = []
313
+ user_messages = []
314
+
315
+ for row in batch_rows:
316
+ r = row[0].row
317
+
318
+ # 建立 User Message
319
+ a = row[header["itemA"] - 1].value or ""
320
+ b = row[header["itemB"] - 1].value or ""
321
+ c = row[header["itemC"] - 1].value or ""
322
+ d = row[header["itemD"] - 1].value or ""
323
+ user_msg = _build_user_message(str(a), str(b), str(c), str(d))
324
+ user_messages.append(user_msg)
325
+
326
+ # 產生第一階段 ID
327
+ request_counter += 1
328
+ ref_id = f"R{r}-Ref-{request_counter}"
329
+
330
+ ref_tasks.append(_call_llm_raw(reference_prompt, user_msg, ref_id))
331
+
332
+ reference_results = await asyncio.gather(*ref_tasks)
333
+
334
+ # 寫入每列 Reference
335
+ for row, ref_text in zip(batch_rows, reference_results):
336
+ r = row[0].row
337
+ ws.cell(r, header["Reference"], ref_text)
338
+
339
+ # -------- Stage 2: Result 判斷 (parallel inside batch) --------
340
+
341
+ result_tasks = []
342
+
343
+ # 使用第一階段的結果作為輸入
344
+ for row, ref_text in zip(batch_rows, reference_results):
345
+ r = row[0].row
346
+
347
+ # 產生第二階段 ID
348
+ request_counter += 1
349
+ res_id = f"R{r}-Res-{request_counter}"
350
+
351
+ result_tasks.append(_call_llm_raw(result_prompt, ref_text, res_id))
352
+
353
+ raw_result_outputs = await asyncio.gather(*result_tasks)
354
+
355
+ # 寫入 Result
356
+ for row, raw_result in zip(batch_rows, raw_result_outputs):
357
+ r = row[0].row
358
+ parsed = _extract_result_json(raw_result)
359
+ ws.cell(r, header["Result"], parsed.get("Result", "Error"))
360
+
361
+ logger.info(f"🔥 完成一批 {len(batch_rows)} 筆 LLM 分析。當前總 API 呼叫次數: {request_counter}")
362
+
363
+
364
+ # -------------------------
365
+ # Step 4: Save local debug copy (保持不變)
366
+ # -------------------------
367
+ local_debug_dir = r"D:\TempExcelDebug"
368
+ os.makedirs(local_debug_dir, exist_ok=True)
369
+
370
+ local_debug_filename = _generate_new_filename("debug_output.xlsx")
371
+ local_debug_path = os.path.join(local_debug_dir, local_debug_filename)
372
+
373
+ wb.save(local_debug_path)
374
+ logger.info(f"📝 本機 debug 檔案已輸出:{local_debug_path}")
375
+
376
+ # -------------------------
377
+ # Step 5: Write back according to source (保持不變)
378
+ # -------------------------
379
+
380
+ # local
381
+ if source_type == "local":
382
+ wb.save(local_path)
383
+ return {
384
+ "status": "success",
385
+ "location_type": "local",
386
+ "output_path": local_path
387
+ }
388
+
389
+ # Appwrite
390
+ if source_type == "appwrite":
391
+ bucket_id, _ = appwrite_info
392
+
393
+ tmp_out_path = os.path.join(
394
+ tempfile.gettempdir(),
395
+ _generate_new_filename("upload.xlsx")
396
+ )
397
+ wb.save(tmp_out_path)
398
+
399
+ new_file_id = f"processed_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
400
+ new_file_name = f"{new_file_id}.xlsx"
401
+
402
+ upload_url = f"{APPWRITE_ENDPOINT}/storage/buckets/{bucket_id}/files"
403
+
404
+ headers = {
405
+ "X-Appwrite-Project": APPWRITE_PROJECT_ID,
406
+ "X-Appwrite-Key": APPWRITE_API_KEY,
407
+ }
408
+
409
+ files = {
410
+ "file": (
411
+ new_file_name,
412
+ open(tmp_out_path, "rb"),
413
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
414
+ )
415
+ }
416
+
417
+ data = { "fileId": new_file_id }
418
+
419
+ resp = requests.post(upload_url, headers=headers, files=files, data=data)
420
+ resp.raise_for_status()
421
+
422
+ return {
423
+ "status": "success",
424
+ "location_type": "appwrite_new_file",
425
+ "file_id": new_file_id,
426
+ "file_name": new_file_name,
427
+ "upload_response": resp.json(),
428
+ "download_url": f"{APPWRITE_ENDPOINT}/storage/buckets/{bucket_id}/files/{new_file_id}/view?project={APPWRITE_PROJECT_ID}"
429
+ }
430
+
431
+ # remote (can't write back)
432
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp_out:
433
+ wb.save(tmp_out.name)
434
+ fallback = tmp_out.name
435
+
436
+ return {
437
+ "status": "success",
438
+ "location_type": "remote_readonly",
439
+ "output_path": fallback,
440
+ "message": "無法寫回遠端,只能輸出本機暫存檔"
441
+ }
442
+
443
+
444
+ # ==============================
445
+ # 🚀 CLI Test
446
+ # ==============================
447
+
448
+ if __name__ == "__main__":
449
+ # 載入環境變數(如果存在 .env 檔案)
450
+ load_dotenv()
451
+
452
+ # ⚠️ 請替換成您自己的測試 URL
453
+ test_url = (
454
+ "https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/files/693a7d86001c14e00cd3/view?project=6901b22e0036150b66d3&mode=admin"
455
+ )
456
+ print("🚀 測試開始...")
457
+ try:
458
+ result = asyncio.run(_process_excel_logic(test_url))
459
+ print(json.dumps(result, ensure_ascii=False, indent=2))
460
+ except Exception as e:
461
+ logger.error(f"❌ 程式執行失敗: {e}")