fund-law-api / app.py
cormort's picture
Update app.py
7313ce8 verified
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 "<h3>Error: index.html 檔案未找到,請確保該檔案位於程式碼相同目錄。</h3>"
@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"""
以下是特種基金的相關法條作為參考知識:
<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)