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 生命週期與路由 --- @asynccontextmanager async def lifespan(app: FastAPI): # 啟動時執行 load_local_data() asyncio.create_task(update_data_from_api()) yield # 關閉時可在此釋放資源 # 初始化 app 並帶入 lifespan app = FastAPI(lifespan=lifespan) @app.get("/api/get_funds") 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"] } @app.get("/", response_class=HTMLResponse) def index(): if os.path.exists("index.html"): with open("index.html", "r", encoding="utf-8") as f: return f.read() return "

Error: index.html 檔案未找到,請確保該檔案位於程式碼相同目錄。

" @app.post("/api/analyze") 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""" 以下是特種基金的相關法條作為參考知識: {req.context} 請根據上述法條,回答以下使用者問題: {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)