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.
- unieai_mcp_accton_rfp/__init__.py +0 -0
- unieai_mcp_accton_rfp/server.py +375 -0
- unieai_mcp_accton_rfp/server_old.py +189 -0
- unieai_mcp_accton_rfp/server_v0.py +337 -0
- unieai_mcp_accton_rfp/server_v02.py +327 -0
- unieai_mcp_accton_rfp/server_v1.py +160 -0
- unieai_mcp_accton_rfp/server_v2.py +245 -0
- unieai_mcp_accton_rfp/server_v3.py +171 -0
- unieai_mcp_accton_rfp/server_v4.py +373 -0
- unieai_mcp_accton_rfp/server_v6.py +349 -0
- unieai_mcp_accton_rfp/server_v7.py +367 -0
- unieai_mcp_accton_rfp/test.py +108 -0
- unieai_mcp_accton_rfp-0.0.11.dist-info/METADATA +30 -0
- unieai_mcp_accton_rfp-0.0.11.dist-info/RECORD +18 -0
- unieai_mcp_accton_rfp-0.0.11.dist-info/WHEEL +5 -0
- unieai_mcp_accton_rfp-0.0.11.dist-info/entry_points.txt +2 -0
- unieai_mcp_accton_rfp-0.0.11.dist-info/licenses/LICENSE +7 -0
- unieai_mcp_accton_rfp-0.0.11.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,373 @@
|
|
|
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
|
+
load_dotenv()
|
|
25
|
+
|
|
26
|
+
logging.basicConfig(level=logging.INFO)
|
|
27
|
+
logger = logging.getLogger("ExcelProcessor")
|
|
28
|
+
|
|
29
|
+
app = FastMCP("ExcelProcessor")
|
|
30
|
+
semaphore = asyncio.Semaphore(10)
|
|
31
|
+
|
|
32
|
+
# LLM 初始化 (LangChain 1.x)
|
|
33
|
+
llm = ChatOpenAI(
|
|
34
|
+
model=os.getenv("UNIEAI_MODEL"),
|
|
35
|
+
base_url=os.getenv("UNIEAI_API_URL"),
|
|
36
|
+
api_key=os.getenv("UNIEAI_API_KEY"),
|
|
37
|
+
temperature=0,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Appwrite ENV
|
|
41
|
+
APPWRITE_PROJECT_ID = os.getenv("APPWRITE_PROJECT_ID")
|
|
42
|
+
APPWRITE_API_KEY = os.getenv("APPWRITE_API_KEY")
|
|
43
|
+
APPWRITE_ENDPOINT = os.getenv("APPWRITE_ENDPOINT", "https://sgp.cloud.appwrite.io/v1")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ==============================
|
|
47
|
+
# 🧩 Helper Functions
|
|
48
|
+
# ==============================
|
|
49
|
+
|
|
50
|
+
def _extract_json(text: str) -> Dict[str, Any]:
|
|
51
|
+
"""從 LLM 回應中擷取第一個 JSON 區塊"""
|
|
52
|
+
match = re.search(r"\{[\s\S]*\}", text)
|
|
53
|
+
if match:
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(match.group(0))
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"JSON 解析失敗: {e}")
|
|
58
|
+
return {"Result": "解析錯誤", "Reference": text.strip()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_appwrite_url(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
62
|
+
"""解析 Appwrite URL → bucketId / fileId"""
|
|
63
|
+
pattern = r"/storage/buckets/([^/]+)/files/([^/]+)"
|
|
64
|
+
m = re.search(pattern, url)
|
|
65
|
+
if not m:
|
|
66
|
+
return None, None
|
|
67
|
+
return m.group(1), m.group(2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _generate_new_filename(original_name: str) -> str:
|
|
71
|
+
"""自動生成新檔名(加 _processed + timestamp)"""
|
|
72
|
+
base, ext = os.path.splitext(original_name)
|
|
73
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
74
|
+
return f"{base}_processed_{timestamp}{ext}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ==============================
|
|
78
|
+
# 🤖 LLM Logic
|
|
79
|
+
# ==============================
|
|
80
|
+
|
|
81
|
+
async def _call_llm(prompt: str, user_message: str, row_id: int) -> Dict[str, Any]:
|
|
82
|
+
"""非同步呼叫 LLM(新版 LangChain API)"""
|
|
83
|
+
try:
|
|
84
|
+
async with semaphore:
|
|
85
|
+
logger.info(f"🔄 呼叫 LLM (Row {row_id})")
|
|
86
|
+
|
|
87
|
+
response = await llm.ainvoke([
|
|
88
|
+
SystemMessage(content=prompt),
|
|
89
|
+
HumanMessage(content=user_message)
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
cleaned = (response.content or "").strip()
|
|
93
|
+
logger.info(f"🔍 LLM Response (Row {row_id}): {cleaned}")
|
|
94
|
+
|
|
95
|
+
return _extract_json(cleaned)
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.error(f"❌ LLM 失敗 (Row {row_id}): {e}")
|
|
99
|
+
return {"Result": "Error", "Reference": f"LLM 失敗: {e}"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ==============================
|
|
103
|
+
# 📘 Prompt 建構
|
|
104
|
+
# ==============================
|
|
105
|
+
|
|
106
|
+
def _build_system_prompt() -> str:
|
|
107
|
+
return """
|
|
108
|
+
你是一位專業的「RFP(Request for Proposal,提案請求書)需求符合性分析專家」。
|
|
109
|
+
你的任務是根據客戶提供的 RFP 需求清單,從公司內部的產品規格文件(已上傳至知識庫)中,逐條分析並判斷產品是否符合該需求。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### 🧭 分析任務說明
|
|
114
|
+
請根據產品規格書內容,對每一條 RFP 需求判斷符合性,並輸出標準化 JSON 結果。
|
|
115
|
+
|
|
116
|
+
#### 符合性判斷標準:
|
|
117
|
+
- Conform:完全符合,產品文件中明確記載該功能或規格。
|
|
118
|
+
- Half Conform:部分符合,產品提供類似功能但未完全滿足需求,或需額外設定 / 模組才能實現。
|
|
119
|
+
- Not Conform:不符合,文件中未提及該功能,或明確不支援。
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### 📦 輸出格式要求
|
|
124
|
+
請針對每一條 RFP 需求,輸出以下 JSON 結構,並以陣列形式回傳:
|
|
125
|
+
|
|
126
|
+
{{
|
|
127
|
+
"Requirement": "客戶的需求原文",
|
|
128
|
+
"Result": "Conform / Half Conform / Not Conform",
|
|
129
|
+
"Reference": "說明依據哪一份產品文件、哪一段內容、章節或頁碼(請包含檔名),並以中文簡短描述對應依據",
|
|
130
|
+
"Comment": "若部分不符,請說明缺少哪些功能或差異之處"
|
|
131
|
+
}}
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
### 📘 資料來源
|
|
136
|
+
你可使用的資料為知識庫中所包含的多份產品文件(如規格書、設計手冊、功能清單、測試報告等)。
|
|
137
|
+
請務必引用具體依據(文件名稱與段落),不得臆測或編造。
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### 📄 輸入資料
|
|
142
|
+
以下為客戶的 RFP 需求清單,請依據產品文件進行逐項比對與分析:
|
|
143
|
+
|
|
144
|
+
{{RFP_CONTENT}}
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_user_message(a: str, b: str, c: str, d: str) -> str:
|
|
149
|
+
logger.info(f"🟢 _build_user_message : {a}, {b}, {c}, {d}")
|
|
150
|
+
return f"""
|
|
151
|
+
{a}, {b}, {c}, {d}
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ==============================
|
|
157
|
+
# 📊 Excel Processing Core
|
|
158
|
+
# ==============================
|
|
159
|
+
|
|
160
|
+
async def _process_excel_logic(url: str) -> Dict[str, Any]:
|
|
161
|
+
logger.info(f"🟢 開始處理 Excel:{url}")
|
|
162
|
+
|
|
163
|
+
# -------------------------
|
|
164
|
+
# Step 1: Download / Load
|
|
165
|
+
# -------------------------
|
|
166
|
+
source_type = ""
|
|
167
|
+
local_path = None
|
|
168
|
+
appwrite_info = (None, None)
|
|
169
|
+
bucket_id = None
|
|
170
|
+
|
|
171
|
+
if url.startswith("file:///"):
|
|
172
|
+
local_path = url.replace("file:///", "")
|
|
173
|
+
file_path = local_path
|
|
174
|
+
source_type = "local"
|
|
175
|
+
logger.info(f"📁 源於本機檔案:{local_path}")
|
|
176
|
+
|
|
177
|
+
elif url.startswith("http"):
|
|
178
|
+
logger.info("🌐 下載遠端 Excel...")
|
|
179
|
+
resp = requests.get(url)
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp:
|
|
182
|
+
tmp.write(resp.content)
|
|
183
|
+
file_path = tmp.name
|
|
184
|
+
|
|
185
|
+
# check Appwrite
|
|
186
|
+
bucket_id, file_id = _parse_appwrite_url(url)
|
|
187
|
+
if bucket_id:
|
|
188
|
+
source_type = "appwrite"
|
|
189
|
+
appwrite_info = (bucket_id, file_id)
|
|
190
|
+
logger.info(f"☁️ Appwrite 檔案來源:bucket={bucket_id}")
|
|
191
|
+
else:
|
|
192
|
+
source_type = "remote_readonly"
|
|
193
|
+
logger.info("🌐 一般遠端 URL(無法寫回)")
|
|
194
|
+
|
|
195
|
+
else:
|
|
196
|
+
raise ValueError("❌ 不支援檔案來源")
|
|
197
|
+
|
|
198
|
+
# -------------------------
|
|
199
|
+
# Step 2: Open Excel
|
|
200
|
+
# -------------------------
|
|
201
|
+
wb = load_workbook(file_path)
|
|
202
|
+
ws = wb.active
|
|
203
|
+
|
|
204
|
+
# 驗證欄位
|
|
205
|
+
header = {cell.value: idx for idx, cell in enumerate(ws[1], 1)}
|
|
206
|
+
for col in ["itemA", "itemB", "itemC", "itemD", "Result", "Reference"]:
|
|
207
|
+
if col not in header:
|
|
208
|
+
raise ValueError(f"❌ Excel 缺少欄位:{col}")
|
|
209
|
+
|
|
210
|
+
# -------------------------
|
|
211
|
+
# Step 3: Build Tasks
|
|
212
|
+
# -------------------------
|
|
213
|
+
tasks = []
|
|
214
|
+
rows_for_llm = []
|
|
215
|
+
system_prompt = _build_system_prompt()
|
|
216
|
+
|
|
217
|
+
for row in ws.iter_rows(min_row=2, values_only=False):
|
|
218
|
+
row_id = row[0].row
|
|
219
|
+
a = row[header["itemA"] - 1].value or ""
|
|
220
|
+
b = row[header["itemB"] - 1].value or ""
|
|
221
|
+
c = row[header["itemC"] - 1].value or ""
|
|
222
|
+
d = row[header["itemD"] - 1].value or ""
|
|
223
|
+
|
|
224
|
+
if not any([a, b, c, d]):
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
rows_for_llm.append(row)
|
|
228
|
+
user_msg = _build_user_message(str(a), str(b), str(c), str(d))
|
|
229
|
+
logger.info(f"☁️ user_msg : {user_msg}")
|
|
230
|
+
tasks.append(_call_llm(system_prompt, user_msg, row_id))
|
|
231
|
+
|
|
232
|
+
# -------------------------
|
|
233
|
+
# Step 4: Run LLM
|
|
234
|
+
# -------------------------
|
|
235
|
+
results = await asyncio.gather(*tasks)
|
|
236
|
+
|
|
237
|
+
# -------------------------
|
|
238
|
+
# Step 5: Write back to Excel
|
|
239
|
+
# -------------------------
|
|
240
|
+
for row, result in zip(rows_for_llm, results):
|
|
241
|
+
r = row[0].row
|
|
242
|
+
ws.cell(r, header["Result"], result.get("Result"))
|
|
243
|
+
ws.cell(r, header["Reference"], result.get("Reference"))
|
|
244
|
+
|
|
245
|
+
# -------------------------
|
|
246
|
+
# Step 6: Output (Local / Appwrite / Remote)
|
|
247
|
+
# -------------------------
|
|
248
|
+
|
|
249
|
+
# -------- ALWAYS SAVE LOCAL DEBUG COPY --------
|
|
250
|
+
local_debug_dir = r"D:\TempExcelDebug"
|
|
251
|
+
os.makedirs(local_debug_dir, exist_ok=True)
|
|
252
|
+
|
|
253
|
+
local_debug_filename = _generate_new_filename("debug_output.xlsx")
|
|
254
|
+
local_debug_path = os.path.join(local_debug_dir, local_debug_filename)
|
|
255
|
+
|
|
256
|
+
wb.save(local_debug_path)
|
|
257
|
+
logger.info(f"📝 本機 debug 檔案已輸出:{local_debug_path}")
|
|
258
|
+
# ------------------------------------------------
|
|
259
|
+
|
|
260
|
+
# ---- local ----
|
|
261
|
+
if source_type == "local":
|
|
262
|
+
wb.save(local_path)
|
|
263
|
+
return {
|
|
264
|
+
"status": "success",
|
|
265
|
+
"location_type": "local",
|
|
266
|
+
"output_path": local_path
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# ---- Appwrite new file ----
|
|
270
|
+
# 2) Appwrite:POST 新檔案,避免覆蓋原本的 fileId
|
|
271
|
+
# ---- Appwrite createFile() (multipart) ----
|
|
272
|
+
if source_type == "appwrite":
|
|
273
|
+
bucket_id, _ = appwrite_info
|
|
274
|
+
|
|
275
|
+
if not bucket_id:
|
|
276
|
+
raise RuntimeError("❌ 無法從 URL 解析 bucketId")
|
|
277
|
+
|
|
278
|
+
if not APPWRITE_PROJECT_ID or not APPWRITE_API_KEY:
|
|
279
|
+
raise RuntimeError("❌ APPWRITE_PROJECT_ID 或 APPWRITE_API_KEY 未設定")
|
|
280
|
+
|
|
281
|
+
# -------- Save Excel to a local file --------
|
|
282
|
+
tmp_out_path = os.path.join(
|
|
283
|
+
tempfile.gettempdir(),
|
|
284
|
+
_generate_new_filename("upload.xlsx")
|
|
285
|
+
)
|
|
286
|
+
wb.save(tmp_out_path)
|
|
287
|
+
|
|
288
|
+
size = os.path.getsize(tmp_out_path)
|
|
289
|
+
logger.info(f"📄 上傳檔案大小:{size} bytes")
|
|
290
|
+
if size == 0:
|
|
291
|
+
raise RuntimeError("❌ Excel 內容為空,無法上傳")
|
|
292
|
+
|
|
293
|
+
# -------- Use Appwrite createFile API (multipart 'file') --------
|
|
294
|
+
new_file_id = f"processed_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
295
|
+
new_file_name = f"{new_file_id}.xlsx"
|
|
296
|
+
|
|
297
|
+
upload_url = f"{APPWRITE_ENDPOINT}/storage/buckets/{bucket_id}/files"
|
|
298
|
+
|
|
299
|
+
headers = {
|
|
300
|
+
"X-Appwrite-Project": APPWRITE_PROJECT_ID,
|
|
301
|
+
"X-Appwrite-Key": APPWRITE_API_KEY,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
files = {
|
|
305
|
+
"file": (
|
|
306
|
+
new_file_name,
|
|
307
|
+
open(tmp_out_path, "rb"),
|
|
308
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
data = {
|
|
313
|
+
"fileId": new_file_id,
|
|
314
|
+
# If you want permissions:
|
|
315
|
+
# "permissions[]": ['read("any")', 'write("any")']
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
logger.info(f"📤 Appwrite createFile() 上傳新檔案: {upload_url}")
|
|
319
|
+
|
|
320
|
+
resp = requests.post(upload_url, headers=headers, files=files, data=data)
|
|
321
|
+
|
|
322
|
+
print("RAW ERROR:", resp.text)
|
|
323
|
+
|
|
324
|
+
resp.raise_for_status()
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
"status": "success",
|
|
328
|
+
"location_type": "appwrite_new_file",
|
|
329
|
+
"file_id": new_file_id,
|
|
330
|
+
"file_name": new_file_name,
|
|
331
|
+
"upload_response": resp.json(),
|
|
332
|
+
"download_url": f"{APPWRITE_ENDPOINT}/storage/buckets/{bucket_id}/files/{new_file_id}/view?project={APPWRITE_PROJECT_ID}"
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---- fallback remote ----
|
|
339
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp_out:
|
|
340
|
+
wb.save(tmp_out.name)
|
|
341
|
+
fallback = tmp_out.name
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"status": "success",
|
|
345
|
+
"location_type": "remote_readonly",
|
|
346
|
+
"output_path": fallback,
|
|
347
|
+
"message": "無法寫回遠端,只能輸出本機暫存檔"
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ==============================
|
|
352
|
+
# 🔧 MCP Tool
|
|
353
|
+
# ==============================
|
|
354
|
+
|
|
355
|
+
@app.tool()
|
|
356
|
+
async def process_excel(url: str):
|
|
357
|
+
return await _process_excel_logic(url)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ==============================
|
|
361
|
+
# 🚀 CLI Test
|
|
362
|
+
# ==============================
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
test_url = (
|
|
366
|
+
#"https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/"
|
|
367
|
+
#"files/691894e30027b282e721/view?project=6901b22e0036150b66d3"
|
|
368
|
+
"https://sgp.cloud.appwrite.io/v1/storage/buckets/6904374b00056677a970/files/693688910039911a5d5c/view?project=6901b22e0036150b66d3&mode=admin"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
print("🚀 測試開始...")
|
|
372
|
+
result = asyncio.run(_process_excel_logic(test_url))
|
|
373
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|