Spaces:
Running
Running
| import json | |
| import zipfile | |
| import os | |
| import requests | |
| import re | |
| import asyncio | |
| import tempfile | |
| import urllib3 | |
| # 關閉忽略 SSL 驗證時產生的警告,保持 Log 乾淨 | |
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | |
| from datetime import datetime | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import HTMLResponse | |
| from pydantic import BaseModel | |
| import httpx | |
| # --- 初始化與設定 --- | |
| # 根據 2026 最新 Swagger 文件更新路徑 | |
| MOJ_API_URLS = { | |
| "chlaw": "https://law.moj.gov.tw/api/ch/law/json", | |
| "chorder": "https://law.moj.gov.tw/api/ch/order/json" | |
| } | |
| LOCAL_FILES = ["chlaw.json", "chorder.json"] | |
| MAPPING_FILE = "fund_mapping.json" | |
| CACHE = { | |
| "data": [], | |
| "last_update": "初始化中...", | |
| "source": "等待載入...", | |
| "is_updating": False | |
| } | |
| UPDATE_LOCK = asyncio.Lock() | |
| class AnalyzeRequest(BaseModel): | |
| model_provider: str | |
| model_name: str | |
| api_key: str | |
| prompt: str | |
| context: str | |
| # --- 工具函數 --- | |
| def load_fund_mapping(): | |
| if os.path.exists(MAPPING_FILE): | |
| try: | |
| with open(MAPPING_FILE, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| except: return {} | |
| return {} | |
| def parse_taiwan_date(date_str): | |
| if not date_str: return "未找到" | |
| date_str = str(date_str).strip() | |
| try: | |
| # 處理格式如 20240101 或 1130101 | |
| if len(date_str) == 8: | |
| return f"民國 {int(date_str[:4]) - 1911} 年 {date_str[4:6]} 月 {date_str[6:]} 日" | |
| elif len(date_str) == 7: | |
| return f"民國 {int(date_str[:3])} 年 {date_str[3:5]} 月 {date_str[5:]} 日" | |
| return date_str | |
| except: return date_str | |
| def clean_text(text): | |
| if not text: return "" | |
| return re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', '', str(text))).strip() | |
| def clean_article_no(art_no): | |
| if not art_no: return "" | |
| cleaned = str(art_no).replace("第", "").replace("條", "").strip().replace(".", "-") | |
| return f"第 {cleaned} 條" | |
| def clean_authority(auth_str): | |
| """ | |
| 配合行政院組織改造,清洗並更新各主管機關名稱為現行版本 | |
| """ | |
| if not auth_str: return "未找到" | |
| auth_str = str(auth_str).strip() | |
| # 1. 金管會 (2012年更名) | |
| auth_str = auth_str.replace("行政院金融監督管理委員會", "金融監督管理委員會") | |
| # 2. 其他常見部會更名 | |
| auth_str = auth_str.replace("行政院勞工委員會", "勞動部") | |
| auth_str = auth_str.replace("行政院衛生署", "衛生福利部") | |
| auth_str = auth_str.replace("行政院環境保護署", "環境部") | |
| auth_str = auth_str.replace("行政院農業委員會", "農業部") | |
| auth_str = auth_str.replace("科技部", "國家科學及技術委員會") | |
| return auth_str | |
| def process_raw_data(laws_list): | |
| processed = [] | |
| target_keyword = "收支保管及運用辦法" | |
| mapping = load_fund_mapping() | |
| for law in laws_list: | |
| law_name = law.get('LawName', '') | |
| # 篩選特定基金法規 | |
| if target_keyword in law_name: | |
| fund_name = law_name.replace("收支保管及運用辦法", "").replace("收支保管運用辦法", "") | |
| modified_date = parse_taiwan_date(law.get('LawModifiedDate')) | |
| law_history = clean_text(law.get('LawHistory', '')) | |
| is_abolished = (law.get('LawAbandon') == '廢') | |
| law_status = "廢止法規" if is_abolished else "現行法規" | |
| sort_weight = mapping.get(fund_name, 999999) | |
| for art in law.get('LawArticles', []): | |
| processed.append({ | |
| "基金名稱": fund_name, | |
| "規定名稱": law_name, | |
| "最近發布日期": modified_date, | |
| "條次": clean_article_no(art.get('ArticleNo', '')), | |
| "規定": clean_text(art.get('ArticleContent', '')), | |
| "立法理由": clean_text(art.get('ArticleReason', '')), | |
| "法規沿革": law_history, | |
| "法規狀態": law_status, | |
| "主管機關": clean_authority(law.get('LawCategory', '未找到')), | |
| "sort_weight": sort_weight | |
| }) | |
| return processed | |
| # --- 資料載入與 API 更新 --- | |
| def load_local_data(): | |
| print(">>> [啟動] 正在檢查本地 JSON 檔案...") | |
| all_local_data = [] | |
| has_file = False | |
| for filename in LOCAL_FILES: | |
| if os.path.exists(filename): | |
| try: | |
| with open(filename, 'r', encoding='utf-8-sig') as f: | |
| raw_json = json.load(f) | |
| laws = raw_json.get('Laws', []) | |
| all_local_data.extend(process_raw_data(laws)) | |
| has_file = True | |
| except Exception as e: | |
| print(f" - 讀取 {filename} 失敗: {e}") | |
| if has_file and all_local_data: | |
| all_local_data.sort(key=lambda x: (x['法規狀態'] == '廢止法規', x['sort_weight'], x['基金名稱'], x['條次'])) | |
| CACHE["data"] = all_local_data | |
| CACHE["last_update"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| CACHE["source"] = "本地備份 (快速啟動)" | |
| print(f">>> [啟動] 本地資料載入完成,共 {len(all_local_data)} 筆。") | |
| else: | |
| print(">>> [啟動] 本地無可用資料,等待 API 更新。") | |
| def download_file_with_retry(url, retries=3): | |
| headers = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' | |
| } | |
| for i in range(retries): | |
| try: | |
| # 加入 verify=False 忽略 SSL 憑證問題 | |
| with requests.get(url, stream=True, timeout=180, headers=headers, verify=False) as r: | |
| r.raise_for_status() | |
| with tempfile.NamedTemporaryFile(delete=False) as tmp_file: | |
| for chunk in r.iter_content(chunk_size=8192): | |
| if chunk: tmp_file.write(chunk) | |
| return tmp_file.name | |
| except Exception as e: | |
| print(f" - 下載嘗試 {i+1} 失敗: {e}") | |
| if i == retries - 1: return None | |
| return None | |
| async def update_data_from_api(): | |
| async with UPDATE_LOCK: | |
| if CACHE["is_updating"]: return | |
| print(">>> [背景] 開始請求全台法規最新資料...") | |
| CACHE["is_updating"] = True | |
| new_api_data = [] | |
| download_success = False | |
| for key, url in MOJ_API_URLS.items(): | |
| tmp_path = None | |
| try: | |
| tmp_path = download_file_with_retry(url) | |
| if tmp_path: | |
| with zipfile.ZipFile(tmp_path, 'r') as z: | |
| # 自動尋找壓縮檔內的 json 檔案 | |
| json_files = [n for n in z.namelist() if n.endswith('.json')] | |
| if not json_files: continue | |
| with z.open(json_files[0]) as f: | |
| raw_json = json.load(f) | |
| processed = process_raw_data(raw_json.get('Laws', [])) | |
| new_api_data.extend(processed) | |
| # 更新本地備份 | |
| with open(f"{key}.json", 'w', encoding='utf-8') as local_f: | |
| json.dump(raw_json, local_f, ensure_ascii=False) | |
| download_success = True | |
| except Exception as e: | |
| print(f" - API 處理錯誤 [{key}]: {e}") | |
| finally: | |
| if tmp_path and os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| if download_success and new_api_data: | |
| new_api_data.sort(key=lambda x: (x['法規狀態'] == '廢止法規', x['sort_weight'], x['基金名稱'], x['條次'])) | |
| CACHE["data"] = new_api_data | |
| CACHE["last_update"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| CACHE["source"] = "全國法規資料庫 API (最新)" | |
| print(f">>> [背景] API 更新完成,共 {len(new_api_data)} 筆。") | |
| CACHE["is_updating"] = False | |
| # --- FastAPI 生命週期與路由 --- | |
| async def lifespan(app: FastAPI): | |
| # 啟動時執行 | |
| load_local_data() | |
| asyncio.create_task(update_data_from_api()) | |
| yield | |
| # 關閉時可在此釋放資源 | |
| # 初始化 app 並帶入 lifespan | |
| app = FastAPI(lifespan=lifespan) | |
| async def get_funds_data(): | |
| return { | |
| "status": "updating" if CACHE["is_updating"] else "ready", | |
| "source": CACHE["source"], | |
| "update_time": CACHE["last_update"], | |
| "count": len(CACHE["data"]), | |
| "data": CACHE["data"] | |
| } | |
| def index(): | |
| if os.path.exists("index.html"): | |
| with open("index.html", "r", encoding="utf-8") as f: | |
| return f.read() | |
| return "<h3>Error: index.html 檔案未找到,請確保該檔案位於程式碼相同目錄。</h3>" | |
| async def analyze_data(req: AnalyzeRequest): | |
| if not req.api_key: | |
| raise HTTPException(status_code=400, detail="請提供 API Key") | |
| provider = req.model_provider.lower() | |
| # 組合完整的 Prompt | |
| full_prompt = f""" | |
| 以下是特種基金的相關法條作為參考知識: | |
| <fund_rules> | |
| {req.context} | |
| </fund_rules> | |
| 請根據上述法條,回答以下使用者問題: | |
| {req.prompt} | |
| """ | |
| if provider == "openai": | |
| url = "https://api.openai.com/v1/chat/completions" | |
| headers = { | |
| "Authorization": f"Bearer {req.api_key}", | |
| "Content-Type": "application/json" | |
| } | |
| payload = { | |
| "model": req.model_name or "gpt-4o", | |
| "messages": [ | |
| {"role": "system", "content": "你是一個專業的台灣法規分析助手,請根據提供的條文內容給出精確、客觀的分析。"}, | |
| {"role": "user", "content": full_prompt} | |
| ], | |
| "temperature": 0.2 | |
| } | |
| async with httpx.AsyncClient() as client: | |
| try: | |
| response = await client.post(url, headers=headers, json=payload, timeout=60.0) | |
| response.raise_for_status() | |
| data = response.json() | |
| return {"result": data["choices"][0]["message"]["content"]} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"OpenAI API 請求失敗: {str(e)}") | |
| elif provider == "gemini": | |
| # Google Gemini API REST call | |
| model = req.model_name or "gemini-1.5-pro" | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={req.api_key}" | |
| headers = { | |
| "Content-Type": "application/json" | |
| } | |
| payload = { | |
| "contents": [{ | |
| "parts": [{"text": full_prompt}] | |
| }], | |
| "systemInstruction": { | |
| "parts": [{"text": "你是一個專業的台灣法規分析助手,請根據提供的條文內容給出精確、客觀的分析。"}] | |
| }, | |
| "generationConfig": { | |
| "temperature": 0.2 | |
| } | |
| } | |
| async with httpx.AsyncClient() as client: | |
| try: | |
| response = await client.post(url, headers=headers, json=payload, timeout=60.0) | |
| response.raise_for_status() | |
| data = response.json() | |
| text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") | |
| if not text: | |
| raise Exception("API 返回格式無法解析或被安全機制阻擋") | |
| return {"result": text} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Gemini API 請求失敗: {str(e)}") | |
| else: | |
| raise HTTPException(status_code=400, detail="不支援的模型提供者 (目前僅支援 openai 或 gemini)") | |
| if __name__ == "__main__": | |
| import uvicorn | |
| # 配合 Hugging Face Space 的預設 Port 通常為 7860 | |
| uvicorn.run(app, host="0.0.0.0", port=7860) |