unieai-mcp-accton-rfp 0.0.11__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,375 @@
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 fastmcp import FastMCP
12
+ from openpyxl import load_workbook
13
+ from openpyxl.worksheet.worksheet import Worksheet
14
+
15
+ # LangChain (1.x API)
16
+ from langchain_openai import ChatOpenAI
17
+ from langchain_core.messages import HumanMessage, SystemMessage
18
+
19
+
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger("unieai-mcp-accton-rfp")
22
+
23
+ semaphore = asyncio.Semaphore(10)
24
+
25
+ # LLM 初始化 (LangChain 1.x)
26
+ llm = ChatOpenAI(
27
+ model="Qwen3-30B-A3B-Thinking-2507-20251209-accton",
28
+ base_url="https://api.unieai.com/v1",
29
+ api_key="sk-XQvLNVMNTxWGxIQM3J8LYFvg3F2bYayYg0G40D4PddvhnDa6",
30
+ temperature=0,
31
+ max_tokens=32768
32
+ )
33
+
34
+ # Appwrite ENV
35
+ APPWRITE_PROJECT_ID = "6901b22e0036150b66d3"
36
+ APPWRITE_API_KEY = "standard_b1462cfd2cd0b6e5b5f305a10799444e009b880adf74e4b578e96222b148da57e17d57957fe3ffba9c7bfa2f6443b66fbcb851b8fbae0b91dc908139ca1d8e54c1bcba9034449d579449fc2abcdb1d9fdca3cc67bdb15140d8f5df1193264bd070e0f738bc3b13fd94de0d4aee3e2075f6b2124b803470d82f9501e806d16ffd"
37
+ APPWRITE_ENDPOINT = "https://sgp.cloud.appwrite.io/v1"
38
+
39
+
40
+
41
+ # ==============================
42
+ # 🔧 MCP Tool
43
+ # ==============================
44
+
45
+ def main():
46
+
47
+ # ==============================
48
+ # 🎛 Environment & Logging
49
+ # ==============================
50
+
51
+
52
+
53
+ mcp = FastMCP("unieai-mcp-accton-rfp")
54
+
55
+
56
+
57
+ """
58
+ Accton RFP 需求符合性分析
59
+
60
+ 參數說明:
61
+ - url (str): Excel 檔案 URL
62
+
63
+ 使用範例:
64
+ process_excel(
65
+ url="https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/files/6937a7fb00180f83ab67/view?project=6901b22e0036150b66d3&mode=admin"
66
+ )
67
+
68
+ 返回:
69
+ - status: 成功或失敗
70
+ - location_type: 檔案來源類型(local, appwrite_new_file, remote_readonly)
71
+ - output_path: 本機暫存檔案路徑(僅 local 類型)
72
+ - file_id: 上傳後的 Appwrite 檔案 ID(僅 appwrite 類型)
73
+ - file_name: 上傳後的 Appwrite 檔案名稱(僅 appwrite 類型)
74
+ - upload_response: Appwrite 上傳回應(僅 appwrite 類型)
75
+ - download_url: Appwrite 檔案預覽 URL(僅 appwrite 類型)
76
+ - message: 其他訊息(僅 remote_readonly 類型)
77
+
78
+ version = 0.0.6
79
+ """
80
+
81
+ @mcp.tool()
82
+ async def process_excel(url: str):
83
+ return await _process_excel_logic(url)
84
+ mcp.run()
85
+
86
+
87
+
88
+ # ==============================
89
+ # 🧩 Helper Functions
90
+ # ==============================
91
+
92
+ def _extract_json(text: str) -> Dict[str, Any]:
93
+ """擷取 JSON 區塊"""
94
+ match = re.search(r"\{[\s\S]*\}", text)
95
+ if match:
96
+ try:
97
+ return json.loads(match.group(0))
98
+ except Exception as e:
99
+ logger.warning(f"JSON 解析失敗: {e}")
100
+ return {"Result": "解析錯誤", "Reference": text.strip()}
101
+
102
+
103
+ def _parse_appwrite_url(url: str) -> Tuple[Optional[str], Optional[str]]:
104
+ pattern = r"/storage/buckets/([^/]+)/files/([^/]+)"
105
+ m = re.search(pattern, url)
106
+ if not m:
107
+ return None, None
108
+ return m.group(1), m.group(2)
109
+
110
+
111
+ def _generate_new_filename(original_name: str) -> str:
112
+ base, ext = os.path.splitext(original_name)
113
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
114
+ return f"{base}_processed_{timestamp}{ext}"
115
+
116
+
117
+ # ==============================
118
+ # 🤖 LLM Logic(新增兩階段)
119
+ # ==============================
120
+
121
+ async def _call_llm_raw(prompt: str, user_message: str):
122
+ """返回 LLM 純文字內容"""
123
+ try:
124
+ async with semaphore:
125
+ response = await llm.ainvoke([
126
+ SystemMessage(content=prompt),
127
+ HumanMessage(content=user_message),
128
+ ])
129
+ return (response.content or "").strip()
130
+ except Exception as e:
131
+ return f"LLM Error: {e}"
132
+
133
+
134
+ def _extract_result_json(text: str):
135
+ """解析第二階段 JSON"""
136
+ try:
137
+ return json.loads(re.search(r"\{[\s\S]*\}", text).group(0))
138
+ except:
139
+ return {"Result": "Error"}
140
+
141
+
142
+ # ==============================
143
+ # 📘 Prompt 建構(新:兩個 prompt)
144
+ # ==============================
145
+
146
+ #def _build_reference_prompt() -> str:
147
+ # return """
148
+ # 你是一位專業的「RFP(Request for Proposal,提案請求書)需求符合性分析專家」。
149
+ # 你的任務是根據客戶提供的 RFP 需求清單,從公司內部的產品規格文件(已上傳至知識庫)中,逐條分析並判斷產品是否符合該需求。
150
+ # 請依據輸入的內容,輸出分析說明(Reference 欄位內容)。
151
+ # 請只輸出自然語言說明,不要進行符合性判斷,也不要輸出 JSON。
152
+ #"""
153
+
154
+ def _build_reference_prompt() -> str:
155
+ return """
156
+ 你是一位嚴謹的產品經理助理,專門負責將內部產品規格(知識庫)與客戶的需求單(RFP)進行比對和符合性分析。
157
+
158
+ **任務指示:**
159
+ 1. 你將收到客戶的產品需求單 (RFP) 作為輸入。
160
+ 2. 你的知識庫已包含你公司產品的完整說明文件。
161
+ 3. 請仔細閱讀 RFP 中的每一條具體需求,並利用你的產品知識庫內容進行嚴格比對。
162
+
163
+ **比對規則:**
164
+ * **Conform (完全符合):** 公司的產品規格能**完整且無條件地**滿足 RFP 中的該項需求。
165
+ * **Half Conform (部分符合):** 公司的產品規格**只能滿足** RFP 中該項需求的**部分內容**,或者需要透過**變通、額外配置或未來規劃**才能滿足。
166
+ * **Not Conform (不符合):** 公司的產品規格**無法滿足** RFP 中的該項需求。
167
+
168
+ **輸出格式要求:**
169
+ 你必須以條列式清晰地輸出分析結果,**每一條結果必須包含**:
170
+ 1. RFP 中的**原始需求描述** (簡短摘錄或編號)。
171
+ 2. **符合程度** (只能是:Conform, Half Conform, Not Conform 三者之一)。
172
+ 3. **參考依據** (說明做出判斷的依據,需明確引用知識庫中**相關產品說明**的關鍵資訊或段落,例如:知識庫中「功能A」的描述支持此判斷)。
173
+
174
+ 請針對 RFP 中的每一條主要需求逐一進行分析。
175
+ """
176
+
177
+
178
+ def _build_result_prompt() -> str:
179
+ return """
180
+ 請依據以下 Reference 文本,判斷其符合性:
181
+ - Conform:完全符合
182
+ - Half Conform:部分符合
183
+ - Not Conform:不符合
184
+
185
+ 請僅輸出以下 JSON 格式:
186
+ {
187
+ "Result": "Conform / Half Conform / Not Conform"
188
+ }
189
+ """
190
+
191
+
192
+ def _build_user_message(a: str, b: str, c: str, d: str) -> str:
193
+ logger.info(f"🟢 _build_user_message : {a}, {b}, {c}, {d}")
194
+ return f"""
195
+ {a}, {b}, {c}, {d}
196
+
197
+ """
198
+
199
+
200
+ # ==============================
201
+ # 📊 Excel Processing Core
202
+ # ==============================
203
+
204
+ async def _process_excel_logic(url: str) -> Dict[str, Any]:
205
+ logger.info(f"🟢 開始處理 Excel:{url}")
206
+
207
+ # -------------------------
208
+ # Step 1: Download / Load
209
+ # -------------------------
210
+ source_type = ""
211
+ local_path = None
212
+ appwrite_info = (None, None)
213
+ bucket_id = None
214
+
215
+ if url.startswith("file:///"):
216
+ local_path = url.replace("file:///", "")
217
+ file_path = local_path
218
+ source_type = "local"
219
+
220
+ elif url.startswith("http"):
221
+ resp = requests.get(url)
222
+ resp.raise_for_status()
223
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
224
+ tmp.write(resp.content)
225
+ file_path = tmp.name
226
+
227
+ bucket_id, file_id = _parse_appwrite_url(url)
228
+ if bucket_id:
229
+ source_type = "appwrite"
230
+ appwrite_info = (bucket_id, file_id)
231
+ else:
232
+ source_type = "remote_readonly"
233
+
234
+ else:
235
+ raise ValueError("❌ 不支援檔案來源")
236
+
237
+ # -------------------------
238
+ # Step 2: Open Excel
239
+ # -------------------------
240
+ wb = load_workbook(file_path)
241
+ ws = wb.active
242
+
243
+ header = {cell.value: idx for idx, cell in enumerate(ws[1], 1)}
244
+ for col in ["itemA", "itemB", "itemC", "itemD", "Result", "Reference"]:
245
+ if col not in header:
246
+ raise ValueError(f"❌ Excel 缺少欄位:{col}")
247
+
248
+ # -------------------------
249
+ # Step 3: Two-stage LLM
250
+ # -------------------------
251
+ rows_for_llm = []
252
+
253
+ for row in ws.iter_rows(min_row=2, values_only=False):
254
+ if any([cell.value for cell in row]):
255
+ rows_for_llm.append(row)
256
+
257
+ for row in rows_for_llm:
258
+ r = row[0].row
259
+ a = row[header["itemA"] - 1].value or ""
260
+ b = row[header["itemB"] - 1].value or ""
261
+ c = row[header["itemC"] - 1].value or ""
262
+ d = row[header["itemD"] - 1].value or ""
263
+
264
+ # ----------- 第 1 次 LLM:生成 Reference -----------
265
+ user_msg_ref = _build_user_message(str(a), str(b), str(c), str(d))
266
+ ref_prompt = _build_reference_prompt()
267
+
268
+ reference_text = await _call_llm_raw(ref_prompt, user_msg_ref)
269
+ logger.info(f"🟢 reference_text : {reference_text}")
270
+ ws.cell(r, header["Reference"], reference_text)
271
+
272
+ # ----------- 第 2 次 LLM:用 Reference 判斷 Result ---
273
+ result_prompt = _build_result_prompt()
274
+ judgement_raw = await _call_llm_raw(result_prompt, reference_text)
275
+ logger.info(f"🟢 judgement_raw : {judgement_raw}")
276
+ judgement_json = _extract_result_json(judgement_raw)
277
+ ws.cell(r, header["Result"], judgement_json.get("Result", "Error"))
278
+
279
+ # -------------------------
280
+ # Step 4: Save local debug copy
281
+ # -------------------------
282
+ local_debug_dir = r"D:\TempExcelDebug"
283
+ os.makedirs(local_debug_dir, exist_ok=True)
284
+
285
+ local_debug_filename = _generate_new_filename("debug_output.xlsx")
286
+ local_debug_path = os.path.join(local_debug_dir, local_debug_filename)
287
+
288
+ wb.save(local_debug_path)
289
+ logger.info(f"📝 本機 debug 檔案已輸出:{local_debug_path}")
290
+
291
+ # -------------------------
292
+ # Step 5: Write back according to source
293
+ # -------------------------
294
+
295
+ # local
296
+ if source_type == "local":
297
+ wb.save(local_path)
298
+ return {
299
+ "status": "success",
300
+ "location_type": "local",
301
+ "output_path": local_path
302
+ }
303
+
304
+ # Appwrite
305
+ if source_type == "appwrite":
306
+ bucket_id, _ = appwrite_info
307
+
308
+ tmp_out_path = os.path.join(
309
+ tempfile.gettempdir(),
310
+ _generate_new_filename("upload.xlsx")
311
+ )
312
+ wb.save(tmp_out_path)
313
+
314
+ new_file_id = f"processed_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
315
+ new_file_name = f"{new_file_id}.xlsx"
316
+
317
+ upload_url = f"{APPWRITE_ENDPOINT}/storage/buckets/{bucket_id}/files"
318
+
319
+ headers = {
320
+ "X-Appwrite-Project": APPWRITE_PROJECT_ID,
321
+ "X-Appwrite-Key": APPWRITE_API_KEY,
322
+ }
323
+
324
+ files = {
325
+ "file": (
326
+ new_file_name,
327
+ open(tmp_out_path, "rb"),
328
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
329
+ )
330
+ }
331
+
332
+ data = { "fileId": new_file_id }
333
+
334
+ resp = requests.post(upload_url, headers=headers, files=files, data=data)
335
+ resp.raise_for_status()
336
+
337
+ return {
338
+ "status": "success",
339
+ "location_type": "appwrite_new_file",
340
+ "file_id": new_file_id,
341
+ "file_name": new_file_name,
342
+ "upload_response": resp.json(),
343
+ "download_url": f"{APPWRITE_ENDPOINT}/storage/buckets/{bucket_id}/files/{new_file_id}/view?project={APPWRITE_PROJECT_ID}"
344
+ }
345
+
346
+ # remote (can't write back)
347
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp_out:
348
+ wb.save(tmp_out.name)
349
+ fallback = tmp_out.name
350
+
351
+ return {
352
+ "status": "success",
353
+ "location_type": "remote_readonly",
354
+ "output_path": fallback,
355
+ "message": "無法寫回遠端,只能輸出本機暫存檔"
356
+ }
357
+
358
+
359
+
360
+
361
+
362
+
363
+
364
+ # ==============================
365
+ # 🚀 CLI Test
366
+ # ==============================
367
+
368
+ if __name__ == "__main__":
369
+ main()
370
+ #test_url = (
371
+ # "https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/files/6937a7fb00180f83ab67/view?project=6901b22e0036150b66d3&mode=admin"
372
+ #)
373
+ #print("🚀 測試開始...")
374
+ #result = asyncio.run(_process_excel_logic(test_url))
375
+ #print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,189 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+ import tempfile
7
+ import requests
8
+ from dotenv import load_dotenv
9
+ from fastmcp import FastMCP
10
+ from openpyxl import load_workbook
11
+
12
+ # LangChain modules
13
+ from langchain_openai import ChatOpenAI
14
+ from langchain_core.messages import HumanMessage, SystemMessage
15
+
16
+ # 初始化環境變數
17
+ load_dotenv()
18
+
19
+ # 初始化 MCP Server
20
+ app = FastMCP("ExcelProcessor")
21
+
22
+ # 設定 Logging
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # 同時最多 10 個請求
27
+ semaphore = asyncio.Semaphore(10)
28
+
29
+ # ======== 🔧 初始化 LLM ========
30
+ llm = ChatOpenAI(
31
+ model=os.getenv("UNIEAI_MODEL"), # 你的模型名稱
32
+ base_url=os.getenv("UNIEAI_API_URL"), # 你的 API endpoint
33
+ api_key=os.getenv("UNIEAI_API_KEY"), # 從 .env 讀取金鑰
34
+ temperature=0,
35
+ )
36
+
37
+ # ======== 🧠 從文字中提取 JSON 區塊 ========
38
+ def _extract_json(text: str) -> dict:
39
+ match = re.search(r"\{[\s\S]*\}", text)
40
+ if match:
41
+ try:
42
+ return json.loads(match.group(0))
43
+ except json.JSONDecodeError:
44
+ pass
45
+ return {"Result": "解析錯誤", "Reference": text.strip()}
46
+
47
+ # ======== 🤖 呼叫 LLM ========
48
+ async def _call_llm(prompt: str, user_message: str, row_id: int) -> dict:
49
+ try:
50
+ async with semaphore:
51
+ logger.info(f"🔄 發送請求給 LLM (ID: {row_id})")
52
+
53
+ response = await llm.ainvoke([
54
+ SystemMessage(content= prompt ),
55
+ HumanMessage(content= user_message)
56
+ ])
57
+
58
+ text = response.content.strip()
59
+ logger.info(f"🔄 text : {text}")
60
+ logger.info(f"🔄 _extract_json(text) : _extract_json(text)")
61
+ return _extract_json(text)
62
+
63
+ except Exception as e:
64
+ logger.error(f"❌ LLM 呼叫失敗 (ID: {row_id}): {e}")
65
+ return {"Result": "Error", "Reference": f"LLM 呼叫失敗: {e}"}
66
+
67
+ # ======== 📊 Excel 處理邏輯 ========
68
+ async def _process_excel_logic(url: str):
69
+ print(f"🟢 開始處理檔案: {url}")
70
+
71
+ # 下載或載入 Excel
72
+ if url.startswith("file:///"):
73
+ file_path = url.replace("file:///", "")
74
+ elif url.startswith(("http://", "https://")):
75
+ resp = requests.get(url)
76
+ resp.raise_for_status()
77
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
78
+ tmp.write(resp.content)
79
+ file_path = tmp.name
80
+ else:
81
+ raise ValueError(f"❌ 不支援的檔案來源: {url}")
82
+
83
+ wb = load_workbook(file_path)
84
+ ws = wb.active
85
+
86
+ # 驗證欄位
87
+ header = {cell.value: idx for idx, cell in enumerate(ws[1], start=1)}
88
+ required = ["itemA", "itemB", "itemC", "itemD", "Result", "Reference"]
89
+ for r in required:
90
+ if r not in header:
91
+ raise ValueError(f"❌ 缺少欄位: {r}")
92
+
93
+ # 準備 LLM 任務
94
+ tasks = []
95
+ for row in ws.iter_rows(min_row=2, values_only=False):
96
+ row_id = row[0].row
97
+ a, b, c, d = [row[header[k]-1].value or "" for k in ["itemA", "itemB", "itemC", "itemD"]]
98
+ if not any([a, b, c, d]):
99
+ continue
100
+
101
+ prompt = f"""
102
+ 你是一位專業的「RFP(Request for Proposal,提案請求書)需求符合性分析專家」。
103
+ 你的任務是根據客戶提供的 RFP 需求清單,從公司內部的產品規格文件(已上傳至知識庫)中,逐條分析並判斷產品是否符合該需求。
104
+
105
+ ---
106
+
107
+ ### 🧭 分析任務說明
108
+ 請根據產品規格書內容,對每一條 RFP 需求判斷符合性,並輸出標準化 JSON 結果。
109
+
110
+ #### 符合性判斷標準:
111
+ - Conform:完全符合,產品文件中明確記載該功能或規格。
112
+ - Half Conform:部分符合,產品提供類似功能但未完全滿足需求,或需額外設定 / 模組才能實現。
113
+ - Not Conform:不符合,文件中未提及該功能,或明確不支援。
114
+
115
+ ---
116
+
117
+ ### 📦 輸出格式要求
118
+ 請針對每一條 RFP 需求,輸出以下 JSON 結構,並以陣列形式回傳:
119
+
120
+ {{
121
+ "Requirement": "客戶的需求原文",
122
+ "Result": "Conform / Half Conform / Not Conform",
123
+ "Reference": "說明依據哪一份產品文件、哪一段內容、章節或頁碼(請包含檔名),並以中文簡短描述對應依據",
124
+ "Comment": "若部分不符,請說明缺少哪些功能或差異之處"
125
+ }}
126
+
127
+ ---
128
+
129
+ ### 📘 資料來源
130
+ 你可使用的資料為知識庫中所包含的多份產品文件(如規格書、設計手冊、功能清單、測試報告等)。
131
+ 請務必引用具體依據(文件名稱與段落),不得臆測或編造。
132
+
133
+ ---
134
+
135
+ ### 📄 輸入資料
136
+ 以下為客戶的 RFP 需求清單,請依據產品文件進行逐項比對與分析:
137
+
138
+ {{RFP_CONTENT}}
139
+
140
+ """
141
+
142
+ user_message = f"""
143
+
144
+ ### 📊 來自 Excel 的輔助欄位資料
145
+ itemA: {a}
146
+ itemB: {b}
147
+ itemC: {c}
148
+ itemD: {d}
149
+
150
+ 請將以上欄位作為補充資訊一併參考,用以協助判斷與引用正確的產品文件內容。
151
+
152
+ """
153
+
154
+ tasks.append(_call_llm(prompt, user_message, row_id))
155
+
156
+ # 並發執行
157
+ results = await asyncio.gather(*tasks)
158
+
159
+ # 更新 Excel
160
+ for row, result_json in zip(ws.iter_rows(min_row=2, values_only=False), results):
161
+ ws.cell(row=row[0].row, column=header["Result"], value=result_json.get("Result"))
162
+ ws.cell(row=row[0].row, column=header["Reference"], value=result_json.get("Reference"))
163
+
164
+ # 儲存輸出檔
165
+ out_path = os.path.join(tempfile.gettempdir(), f"updated_{os.path.basename(file_path)}")
166
+ wb.save(out_path)
167
+ print(f"✅ Excel 已處理完成,輸出檔案:{out_path}")
168
+
169
+ return {
170
+ "status": "success",
171
+ "output_path": out_path,
172
+ "message": "Excel 已更新完成"
173
+ }
174
+
175
+ # ======== 🔧 MCP 工具入口 ========
176
+ @app.tool()
177
+ async def process_excel(url: str):
178
+ return await _process_excel_logic(url)
179
+
180
+ # ======== 🚀 CLI 測試模式 ========
181
+ if __name__ == "__main__":
182
+ test_path = r"C:\Users\Evan\Downloads\test_excel.xlsx"
183
+ #test_url = f"file:///{test_path}"
184
+
185
+ test_url = "https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/files/691894e30027b282e721/view?project=6901b22e0036150b66d3&mode=admin"
186
+
187
+
188
+ print("🚀 開始測試 Excel 檔案 ...")
189
+ asyncio.run(_process_excel_logic(test_url))