Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,7 +1,601 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
高雄市統計智慧查詢應用程式
|
| 4 |
+
版本:Hugging Face Spaces 適用版
|
| 5 |
+
最後修改日期:2025-08-12
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
# =======================================================================
|
| 9 |
+
# 1. 匯入必要函式庫
|
| 10 |
+
# =======================================================================
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import json
|
| 14 |
+
import math
|
| 15 |
+
import jieba
|
| 16 |
+
import torch
|
| 17 |
import gradio as gr
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import google.generativeai as genai
|
| 20 |
+
from typing import Type
|
| 21 |
+
|
| 22 |
+
from collections import defaultdict, OrderedDict
|
| 23 |
+
from typing import List, Dict
|
| 24 |
+
|
| 25 |
+
# LangChain & SentenceTransformers
|
| 26 |
+
from langchain_community.vectorstores import FAISS
|
| 27 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 28 |
+
from sentence_transformers import SentenceTransformer
|
| 29 |
+
from langchain.retrievers import BM25Retriever
|
| 30 |
+
from langchain.schema import Document
|
| 31 |
+
from langchain_core.tools import tool
|
| 32 |
+
|
| 33 |
+
# =======================================================================
|
| 34 |
+
# 2. 初始設定與資料庫載入
|
| 35 |
+
# =======================================================================
|
| 36 |
+
|
| 37 |
+
# --- 全域變數與模型設定 ---
|
| 38 |
+
EMBEDDING_MODEL_NAME = 'intfloat/multilingual-e5-base'
|
| 39 |
+
DB_JB_PATH = "yearbook_contents_jb_db_base5"
|
| 40 |
+
DB_SIM_PATH = "yearbook_contents_simple_db_base5"
|
| 41 |
+
EXCEL_FILE_PATH = "合併檔案.xlsx"
|
| 42 |
+
_df_cache = None
|
| 43 |
+
|
| 44 |
+
# --- Custom Embedding Class ---
|
| 45 |
+
class CustomE5Embedding(HuggingFaceEmbeddings):
|
| 46 |
+
def embed_documents(self, texts):
|
| 47 |
+
texts = [f"passage: {t}" for t in texts]
|
| 48 |
+
return super().embed_documents(texts)
|
| 49 |
+
|
| 50 |
+
def embed_query(self, text):
|
| 51 |
+
return super().embed_query(f"query: {text}")
|
| 52 |
+
|
| 53 |
+
# --- 載入模型與向量資料庫 ---
|
| 54 |
+
print("載入嵌入模型中...")
|
| 55 |
+
embedding_model_st = SentenceTransformer(EMBEDDING_MODEL_NAME)
|
| 56 |
+
embedding_model_lc = CustomE5Embedding(model_name=EMBEDDING_MODEL_NAME)
|
| 57 |
+
|
| 58 |
+
print("載入向量資料庫中...")
|
| 59 |
+
try:
|
| 60 |
+
db_jb = FAISS.load_local(DB_JB_PATH, embedding_model_lc, allow_dangerous_deserialization=True)
|
| 61 |
+
db_sim = FAISS.load_local(DB_SIM_PATH, embedding_model_lc, allow_dangerous_deserialization=True)
|
| 62 |
+
print("✅ 向量資料庫載入成功。")
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"❌ 載入向量資料庫失敗,請確認檔案路徑是否正確: {e}")
|
| 65 |
+
db_jb, db_sim = None, None
|
| 66 |
+
|
| 67 |
+
# =======================================================================
|
| 68 |
+
# 3. 核心查詢與處理函式
|
| 69 |
+
# =======================================================================
|
| 70 |
+
|
| 71 |
+
def chinese_tokenizer(text: str) -> list[str]:
|
| 72 |
+
return list(jieba.cut(text))
|
| 73 |
+
|
| 74 |
+
def extract_project_name_from_content(content: str) -> str:
|
| 75 |
+
cleaned_content = re.sub(r"[\s\u3000]", "", content)
|
| 76 |
+
match = re.search(r"項目[::]([^。]+)", cleaned_content)
|
| 77 |
+
if match:
|
| 78 |
+
raw_name = match.group(1)
|
| 79 |
+
final_name = re.sub(r"^\d+", "", raw_name)
|
| 80 |
+
return final_name.strip()
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
def extract_project_names_from_rag_manual_mix(query: str, db_jb, db_sim, top_k: int = 4) -> List[str]:
|
| 84 |
+
if not db_jb or not db_sim:
|
| 85 |
+
return []
|
| 86 |
+
k_bm25 = math.ceil(top_k / 2)
|
| 87 |
+
k_faiss = math.floor(top_k / 2)
|
| 88 |
+
|
| 89 |
+
split_docs = list(db_jb.docstore._dict.values())
|
| 90 |
+
bm25 = BM25Retriever.from_documents(split_docs, tokenizer=chinese_tokenizer)
|
| 91 |
+
bm25.k = 20
|
| 92 |
+
bm25_docs = bm25.get_relevant_documents(" ".join(jieba.cut(query)))
|
| 93 |
+
bm25_names = [name for doc in bm25_docs if (name := extract_project_name_from_content(doc.page_content))]
|
| 94 |
+
unique_bm25_names = list(OrderedDict.fromkeys(bm25_names))[:k_bm25]
|
| 95 |
+
|
| 96 |
+
prefixed_query = f"query: {query}"
|
| 97 |
+
vector_docs_with_scores = db_sim.similarity_search_with_score(prefixed_query, k=20)
|
| 98 |
+
faiss_names = [name for doc, score in vector_docs_with_scores if (name := extract_project_name_from_content(doc.page_content))]
|
| 99 |
+
unique_faiss_names = list(OrderedDict.fromkeys(faiss_names))[:k_faiss]
|
| 100 |
+
|
| 101 |
+
combined_names = unique_bm25_names + unique_faiss_names
|
| 102 |
+
return list(OrderedDict.fromkeys(combined_names))[:top_k]
|
| 103 |
+
|
| 104 |
+
def load_data(file_path: str = EXCEL_FILE_PATH) -> pd.DataFrame:
|
| 105 |
+
global _df_cache
|
| 106 |
+
if _df_cache is not None:
|
| 107 |
+
return _df_cache
|
| 108 |
+
try:
|
| 109 |
+
print(f"讀取Excel檔案中... ({file_path})")
|
| 110 |
+
_df_cache = pd.read_excel(file_path)
|
| 111 |
+
print("✅ Excel 資料載入成功。")
|
| 112 |
+
return _df_cache
|
| 113 |
+
except FileNotFoundError:
|
| 114 |
+
print(f"❌ 錯誤:找不到檔案 {file_path}")
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
def batch_find_relevant_tables(api_key: str, sub_queries: list[str], top_k: int = 1) -> dict:
|
| 118 |
+
"""
|
| 119 |
+
(結構化版) 為每個子問題獨立查找候選表,並將完整的配對結構交由 Gemini 判斷。
|
| 120 |
+
"""
|
| 121 |
+
print("🧠 (結構化模式) 正在為每個子問題獨立查找其專屬候選表...")
|
| 122 |
+
|
| 123 |
+
# Step 1: 為每個子問題獨立獲取候選表,並存入字典
|
| 124 |
+
query_to_candidates_map = {}
|
| 125 |
+
for query in sub_queries:
|
| 126 |
+
print(f" -> 正在處理: '{query}'")
|
| 127 |
+
# 為每個子問題找回 10 個最相關的候選表
|
| 128 |
+
candidates_per_query = extract_project_names_from_rag_manual_mix(query, db_jb, db_sim, top_k=10)
|
| 129 |
+
# test
|
| 130 |
+
print(candidates_per_query)
|
| 131 |
+
|
| 132 |
+
if candidates_per_query: # 只加入有找到候選表的查詢
|
| 133 |
+
query_to_candidates_map[query] = candidates_per_query
|
| 134 |
+
|
| 135 |
+
if not query_to_candidates_map:
|
| 136 |
+
print("⚠️ RAG 步驟未找到任何候選資料表,終止批次匹配。")
|
| 137 |
+
return {}
|
| 138 |
+
|
| 139 |
+
print(f"✅ 已為 {len(query_to_candidates_map)} 個問題找到專屬候選表。")
|
| 140 |
+
|
| 141 |
+
# --- Step 2: 動態建構一個新的、結構化的 Prompt ---
|
| 142 |
+
|
| 143 |
+
# 建立一個清晰的任務描述文字區塊
|
| 144 |
+
tasks_text_parts = []
|
| 145 |
+
for i, (query, candidates) in enumerate(query_to_candidates_map.items()):
|
| 146 |
+
# 將候選表列表格式化
|
| 147 |
+
candidate_list_str = "\n".join(f" - {c}" for c in candidates)
|
| 148 |
+
task_block = f"""
|
| 149 |
+
---
|
| 150 |
+
[任務 {i+1}]
|
| 151 |
+
問題: "{query}"
|
| 152 |
+
此問題的候選資料表清單:
|
| 153 |
+
{candidate_list_str}
|
| 154 |
+
"""
|
| 155 |
+
tasks_text_parts.append(task_block)
|
| 156 |
+
|
| 157 |
+
tasks_text = "".join(tasks_text_parts)
|
| 158 |
+
|
| 159 |
+
# 任務描述
|
| 160 |
+
batch_prompt = f"""
|
| 161 |
+
你是一個專業的數據庫助理。你的任務是從下方的「待處理的配對任務清單」中,根據每一個query,找出最相關的資料表。其餘捨棄。
|
| 162 |
+
你必須依[輸出範例回傳問題及表名,不要有任何多餘的文字、編號、引號或說明。
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
[待處理的配對任務清單]:
|
| 166 |
+
{tasks_text}
|
| 167 |
+
---
|
| 168 |
+
請嚴格遵循以下 JSON 格式輸出,不要有任何多餘的文字或說明。
|
| 169 |
+
輸出的 JSON 物件中,鍵(key)必須是原始問題,值(value)必須是您為該問題選擇的最佳表名。
|
| 170 |
+
|
| 171 |
+
[輸出範例]:
|
| 172 |
+
{{
|
| 173 |
+
"113年三民區總人口數": "高雄市戶數、人口密度及性比例",
|
| 174 |
+
"112年鼓山區出生人數": "高雄市嬰兒出生數"
|
| 175 |
+
}}
|
| 176 |
+
""".strip()
|
| 177 |
+
|
| 178 |
+
# --- Step 3: 呼叫 Gemini 並解析結果---
|
| 179 |
+
try:
|
| 180 |
+
print("--- Structured Batch Prompt to Gemini ---")
|
| 181 |
+
# print(repr(batch_prompt)) # 如果需要偵錯,可以取消註解此行
|
| 182 |
+
print("---------------------------------------")
|
| 183 |
+
|
| 184 |
+
response_text = reply(api_key, "", batch_prompt)
|
| 185 |
+
parsed_json = extract_json(response_text)
|
| 186 |
+
if isinstance(parsed_json, dict):
|
| 187 |
+
print(f"✅ 結構化批次匹配表名成功: {parsed_json}")
|
| 188 |
+
return parsed_json
|
| 189 |
+
return {}
|
| 190 |
+
except Exception as e:
|
| 191 |
+
print(f"❌ 結構化批次匹配表名失敗: {e}")
|
| 192 |
+
return {}
|
| 193 |
+
|
| 194 |
+
def batch_parse_sub_queries_with_gemini(api_key: str, sub_queries: List[str]) -> Dict[str, Dict]:
|
| 195 |
+
"""
|
| 196 |
+
(優化) 一次性批次解析所有子問題,提取時間、地區和查詢項目。
|
| 197 |
+
回傳一個以子問題為鍵(key)的字典。
|
| 198 |
+
"""
|
| 199 |
+
print(f"🤖 正在請求 Gemini 批次解析 {len(sub_queries)} 個子問題...")
|
| 200 |
+
|
| 201 |
+
sub_queries_formatted = "\n".join([f"- {q}" for q in sub_queries])
|
| 202 |
+
|
| 203 |
+
prompt = f"""
|
| 204 |
+
你是一個高效率的數據查詢代理,請使用繁體中文回答。
|
| 205 |
+
你的任務是分析使用者問題,並根據內容清晰度決定如何回應。
|
| 206 |
+
**情境一:問題清晰,可進行查詢**
|
| 207 |
+
如果問題中明確包含「時間」和「查詢項目」,請依照以下格式輸出:
|
| 208 |
+
time_query: <時間文字>
|
| 209 |
+
district_query: <地點文字>
|
| 210 |
+
item_query: <地點文字>+<查詢項目文字>
|
| 211 |
+
**重要規則:**
|
| 212 |
+
1. **時間正規化**:當使用者輸入的時間包含 "年底"、"年中"、"年初"、"年度" 等描述時,請將 `time_query` 正規化為年份。例如,"113年底" 應轉換為 "113年"。
|
| 213 |
+
2. **時間校正**:當使用者輸入的時間包含 "年底"、"年中"、"年初" 等描述時,如該問題是有關學校類型(國中小、補習班等概況),請將 `time_query` 修正為學年。例如,"113年" 應轉換為 "113學年"。
|
| 214 |
+
3. `district_query` 為可選項目,若無則設為"高雄市全區"。如為"高雄市"或"高雄"等泛指整體者,亦設為"高雄市全區"
|
| 215 |
+
4. 當使用者問題為高雄市或未指定行政區時,item_query: 「總計」+ (time_query轉為西元表示的時間) + <查詢項目文字>
|
| 216 |
+
4. 請勿遺漏使用者輸入的任何關鍵詞。
|
| 217 |
+
|
| 218 |
+
**情境二:問題模糊,無法查詢**
|
| 219 |
+
如果問題中缺少「時間」或「查詢項目」任一資訊,導致無法進行查詢,請進行以下操作:
|
| 220 |
+
1. 直接回應以下文字,**並且不要生成 time_query 等欄位**。
|
| 221 |
+
`我不太了解你的意思,請重新定義問題(包含資料時間及統計指標)`
|
| 222 |
+
---
|
| 223 |
+
請嚴格遵循以上所有情境與規則,禁止加入多餘說明。
|
| 224 |
+
|
| 225 |
+
[子問題列表]:
|
| 226 |
+
{sub_queries_formatted}
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
[規則]:
|
| 230 |
+
1. **時間正規化**:當使用者輸入的時間���含 "年底"、"年中"、"年初"、"年度" 等描述時,請將 `time_query` 正規化為年份。例如,"113年底" 應轉換為 "113年"。
|
| 231 |
+
2. **時間校正**:當使用者輸入的時間包含 "年底"、"年中"、"年初" 等描述時,如該問題是有關教育類型,請將 `time_query` 修正為學年。例如,"113年" 應轉換為 "113學年"。
|
| 232 |
+
3. `district_query` 為可選項目,若無則設為空值。如為"高雄市"亦設為空值。
|
| 233 |
+
4. 請勿遺漏使用者輸入的任何關鍵詞。
|
| 234 |
+
---
|
| 235 |
+
[輸出格式]:
|
| 236 |
+
請嚴格遵循以下 JSON 格式輸出,不要有任何多餘的文字或說明。
|
| 237 |
+
輸出的 JSON 物件中,鍵(key)必須是「子問題列表」中的原始問題,值(value)是一個包含解析參數的物件。
|
| 238 |
+
|
| 239 |
+
[輸出範例]:
|
| 240 |
+
{{
|
| 241 |
+
"113年三民區總人口數": {{
|
| 242 |
+
"time_query": "113年",
|
| 243 |
+
"district_query": "三民區",
|
| 244 |
+
"item_query": "三民區總人口數"
|
| 245 |
+
}},
|
| 246 |
+
"112年鼓山區出生人數": {{
|
| 247 |
+
"time_query": "112年",
|
| 248 |
+
"district_query": "鼓山區",
|
| 249 |
+
"item_query": "鼓山區出生人數"
|
| 250 |
+
}}
|
| 251 |
+
}}
|
| 252 |
+
""".strip()
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
response_text = reply(api_key, "", prompt)
|
| 256 |
+
parsed_json = extract_json(response_text)
|
| 257 |
+
if isinstance(parsed_json, dict):
|
| 258 |
+
print("✅ 子問題批次解析成功。")
|
| 259 |
+
return parsed_json
|
| 260 |
+
return {}
|
| 261 |
+
except Exception as e:
|
| 262 |
+
print(f"❌ 批次解析子問題失敗: {e}")
|
| 263 |
+
return {}
|
| 264 |
+
|
| 265 |
+
# --- 動態查詢工具 ---
|
| 266 |
+
def semantic_query_logic(time_query: str, item_query: str, project_name: str, district_query: str = "") -> str:
|
| 267 |
+
"""
|
| 268 |
+
(最終優化版) 直接接收已匹配好的表名,專注於 RAG 檢索排序。
|
| 269 |
+
"""
|
| 270 |
+
print(f"--- 執行查詢: 表名='{project_name}', 時間='{time_query}', 地區='{district_query}', 項目='{item_query}' ---")
|
| 271 |
+
df = load_data()
|
| 272 |
+
if df is None: return "[]"
|
| 273 |
+
|
| 274 |
+
# 步驟 1: (優化) 先用精確的表名進行篩選,大幅縮小範圍
|
| 275 |
+
filtered_df = df[df['表名'] == project_name].copy()
|
| 276 |
+
|
| 277 |
+
if filtered_df.empty:
|
| 278 |
+
# 如果光是表名就找不到任何資料,直接返回
|
| 279 |
+
return "[]"
|
| 280 |
+
|
| 281 |
+
# 步驟 2: 在已縮小的範圍內,進行時間和地區的篩選
|
| 282 |
+
conditions = []
|
| 283 |
+
if time_query:
|
| 284 |
+
conditions.append(filtered_df['表側資訊'].astype(str).str.contains(time_query, na=False) | filtered_df['表首資訊'].astype(str).str.contains(time_query, na=False))
|
| 285 |
+
if district_query:
|
| 286 |
+
conditions.append(filtered_df['表側資訊'].astype(str).str.contains(district_query, na=False) | filtered_df['表首資訊'].astype(str).str.contains(district_query, na=False))
|
| 287 |
+
|
| 288 |
+
if conditions:
|
| 289 |
+
combined_condition = pd.concat([cond.rename(i) for i, cond in enumerate(conditions)], axis=1).all(axis=1)
|
| 290 |
+
filtered_df = filtered_df[combined_condition]
|
| 291 |
+
|
| 292 |
+
if filtered_df.empty: return "[]"
|
| 293 |
+
|
| 294 |
+
# 關鍵安全閥
|
| 295 |
+
MAX_CANDIDATES = 500
|
| 296 |
+
if len(filtered_df) > MAX_CANDIDATES:
|
| 297 |
+
print(f"⚠️ 篩選結果超過{MAX_CANDIDATES}筆({len(filtered_df)}),僅取前{MAX_CANDIDATES}筆進行向量分析以節省資源。")
|
| 298 |
+
filtered_df = filtered_df.head(MAX_CANDIDATES)
|
| 299 |
+
|
| 300 |
+
# 步驟 3: (核心) 對最終篩選出的結果進行向量化與語意比對
|
| 301 |
+
print(f"向量化階段:對 {len(filtered_df)} 筆資料進行向量化...")
|
| 302 |
+
combined_texts = (filtered_df['表名'].astype(str) + " " + filtered_df['表首資訊'].astype(str) + " " + filtered_df['表側資訊'].astype(str)).tolist()
|
| 303 |
+
|
| 304 |
+
# 在送入模型前手動加上前綴
|
| 305 |
+
prefixed_passage_texts = [f"passage: {t}" for t in combined_texts]
|
| 306 |
+
prefixed_query_text = f"query: {item_query}"
|
| 307 |
+
|
| 308 |
+
item_query_embedding = embedding_model_st.encode(prefixed_query_text, convert_to_tensor=True)
|
| 309 |
+
candidate_embeddings = embedding_model_st.encode( prefixed_passage_texts, convert_to_tensor=True)
|
| 310 |
+
semantic_scores = torch.matmul(item_query_embedding, candidate_embeddings.T).tolist()
|
| 311 |
+
print(" ✅ 向量化完成。")
|
| 312 |
+
|
| 313 |
+
# 步驟 4: 根據語意分數排序並產生最終結果
|
| 314 |
+
results = []
|
| 315 |
+
for i, row in enumerate(filtered_df.itertuples(index=False)):
|
| 316 |
+
results.append({**row._asdict(), "語意分數": round(semantic_scores[i], 4)})
|
| 317 |
+
|
| 318 |
+
results.sort(key=lambda x: x['語意分數'], reverse=True)
|
| 319 |
+
|
| 320 |
+
FINAL_K = 80
|
| 321 |
+
top_results = results[:FINAL_K]
|
| 322 |
+
|
| 323 |
+
print("--- semantic_query_logic 執行完畢 ---")
|
| 324 |
+
return json.dumps(top_results, ensure_ascii=False) if top_results else "[]"
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# 2. 從上面的普通函式,明確地建立 LangChain 工具
|
| 328 |
+
from langchain.tools import StructuredTool
|
| 329 |
+
semantic_query_tool = StructuredTool.from_function(
|
| 330 |
+
func=semantic_query_logic,
|
| 331 |
+
name="semantic_query_tool",
|
| 332 |
+
description="(純RAG簡化版) 直接使用向量語意模型進行檢索排序。" # 更新描述
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# =======================================================================
|
| 336 |
+
# 4. 主要執行流程 (Reflect)
|
| 337 |
+
# =======================================================================
|
| 338 |
+
system_reviewer = """
|
| 339 |
+
你是語意分析專家,請將使用者的複雜問題拆解成具體子問題,並判斷每個子問題的查詢類型。
|
| 340 |
+
|
| 341 |
+
⚠️ 拆解前請先檢查以下條件:
|
| 342 |
+
1. 涉及高雄市以外或全國性資料,請直接回傳:「抱歉~我是高雄市查詢機器人,無法查詢高雄以外資料。」
|
| 343 |
+
2. 未提及明確時間(如112年、113年3月),請回傳:「抱歉~請問查詢的資料時間。」
|
| 344 |
+
📌 明確時間=出現「具體年份」、「年月」、「季」或「學年」。模糊詞(平均、近年、目前、歷年等)皆視為未指定。
|
| 345 |
+
3. 問題中的「高雄市」字樣請略過,例如「113年底高雄市人口」視為「113年總人口」。
|
| 346 |
+
|
| 347 |
+
📌 回傳格式(**僅限 JSON 陣列**,不得加上任何文字):
|
| 348 |
+
[
|
| 349 |
+
{
|
| 350 |
+
"sub_query": "子問題內容(不得遺漏任何原始資訊)",
|
| 351 |
+
"type": "direct" 或 "comparison"
|
| 352 |
+
}
|
| 353 |
+
]
|
| 354 |
+
|
| 355 |
+
📌 類型說明:
|
| 356 |
+
- 可直接查詢者為 "direct"
|
| 357 |
+
- 涉及比較、推論、排序者為 "comparison"
|
| 358 |
+
|
| 359 |
+
📍 行政區關鍵詞(出現以下詞應視為涉及全部行政區):
|
| 360 |
+
- 關鍵詞:"各行政區", "所有行政區", "全體行政區", "人口最多", "人口最少"
|
| 361 |
+
- 對應行政區如下:
|
| 362 |
+
kaohsiung_districts = ["鹽埕區", "鼓山區", "左營區", "楠梓區", "三民區", "新興區", "前金區", "苓雅區", "前鎮區", "旗津區", "小港區", "鳳山區", "林園區", "大寮區", "大樹區", "大社區", "仁武區", "鳥松區", "岡山區", "橋頭區", "燕巢區", "田寮區", "阿蓮區", "路竹區", "湖內區", "茄萣區", "永安區", "彌陀區", "梓官區", "旗山區", "美濃區", "六龜區", "甲仙區", "杉林區", "內門區", "茂林區", "桃源區", "那瑪夏區"]
|
| 363 |
+
|
| 364 |
+
📌 子問題拆解規則:
|
| 365 |
+
- 每個子問題必須包含 1 個「地點」、1 個「時間」、1 個「指標」
|
| 366 |
+
- 若同時包含多個時間、地區或指標,請拆成多筆(如:110-113年、1至3月 都要拆開)
|
| 367 |
+
- 若內容有年齡區間如20-24歲,則不必拆分
|
| 368 |
+
- ⛔ 禁止省略使用者輸入中的任何關鍵詞(例如:「人口數合計」的「合計」也不得省略)
|
| 369 |
+
|
| 370 |
+
📤 僅允許輸出:
|
| 371 |
+
- JSON 陣列格式,禁止加說明文字
|
| 372 |
+
- 禁用「...」,子問題必須完整列出
|
| 373 |
+
- 回應必須為繁體中文
|
| 374 |
+
"""
|
| 375 |
+
|
| 376 |
+
system_integration = """
|
| 377 |
+
你是資深資料分析師,擅長回答高雄市統計問題。請依下列規則,根據使用者提問與查詢資料,產出清楚、正確的繁體中文答案。
|
| 378 |
+
### 🎯 使用者問題:
|
| 379 |
+
{user_query}
|
| 380 |
+
### 📊 查詢資料:
|
| 381 |
+
{retrieved_chunks}
|
| 382 |
+
---
|
| 383 |
+
## 🧩 問題類型與處理方式:
|
| 384 |
+
### 一、比較型問題(如「最多」「變化」「排名」「哪區最高」):
|
| 385 |
+
1. **對應條件**:僅使用與問題一致的「時間、地區、指標」。
|
| 386 |
+
2. **缺漏處理**:若資料不齊,請指出缺哪一項與無法比較的原因。
|
| 387 |
+
3. **數值處理**:轉為千分位(例:23,000)、百分比與金額取至小數第 2 位。
|
| 388 |
+
4. **條列推論**:逐項列出比較結果,明確指出最高、最低、差異。
|
| 389 |
+
5. **禁止**:不得使用科學記號、英文、原欄位名稱;不得補資料或推論未查到的年份。
|
| 390 |
+
### 二、一般整合型問題(如「113年底苓雅區人口?」):
|
| 391 |
+
1. **條件驗證**:若資料年份不同,請說明「您問的是 113 年,我找到的是 114 年…」
|
| 392 |
+
2. **缺資料處理**:無資料請說「資料缺乏,無法回答」;不可用其他時間資料代替。
|
| 393 |
+
3. **作答格式**:300 字內、結論先行、條列清楚、千分位數字,不使用科學記號。開頭統一:「關於您提出的問題,綜合參考資料如下:」,結尾列出參考資料表名(參考資料:高雄市原住民戶口數)。
|
| 394 |
+
---
|
| 395 |
+
## 📌 共通禁止事項(適用所有問題):
|
| 396 |
+
- ❌ 不得推論或補未查到的資料
|
| 397 |
+
- ❌ 不可引用不符問題條件的數據
|
| 398 |
+
- ❌ 不可貼欄位原文、英文、代碼、科學記號
|
| 399 |
+
---
|
| 400 |
+
## ✅ 輸出格式:
|
| 401 |
+
- 使用繁體中文
|
| 402 |
+
- 數值一律轉為千分位
|
| 403 |
+
- 每份資料來源僅列一次
|
| 404 |
+
- 若缺資料請誠實說明並結束回答
|
| 405 |
+
請根據以上規則,輸出準確答案。
|
| 406 |
+
"""
|
| 407 |
+
|
| 408 |
+
def reply(api_key: str, system: str, prompt: str, model: str = "gemini-2.0-flash-lite"):
|
| 409 |
+
"""
|
| 410 |
+
(非串流版) 一次性獲取完整的 Gemini 回應。
|
| 411 |
+
"""
|
| 412 |
+
try:
|
| 413 |
+
genai.configure(api_key=api_key)
|
| 414 |
+
if system and system.strip():
|
| 415 |
+
gemini_model = genai.GenerativeModel(model_name=model, system_instruction=system)
|
| 416 |
+
else:
|
| 417 |
+
# 如果 system 是空的,則不傳遞 system_instruction 參數
|
| 418 |
+
gemini_model = genai.GenerativeModel(model_name=model)
|
| 419 |
+
|
| 420 |
+
response = gemini_model.generate_content(prompt, generation_config={'temperature': 0})
|
| 421 |
+
# 直接回傳完整���文字
|
| 422 |
+
return response.text
|
| 423 |
+
|
| 424 |
+
except Exception as e:
|
| 425 |
+
error_message = f"系統錯誤:呼叫 Gemini API 失敗。錯誤詳情: {e}"
|
| 426 |
+
print(error_message)
|
| 427 |
+
return error_message
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def extract_json(text: str) -> list | dict:
|
| 431 |
+
json_block_match = re.search(r'```json\s*([\s\S]*?)\s*```|([\s\S]*)', text)
|
| 432 |
+
if not json_block_match: raise ValueError("在回傳內容中找不到任何可解析的文字。")
|
| 433 |
+
|
| 434 |
+
content = json_block_match.group(1) or json_block_match.group(2)
|
| 435 |
+
start = content.find('[') if content.find('[') != -1 else content.find('{')
|
| 436 |
+
end = content.rfind(']') if content.rfind(']') != -1 else content.rfind('}')
|
| 437 |
+
|
| 438 |
+
if start == -1 or end == -1 or end < start:
|
| 439 |
+
raise ValueError("在回傳內容中找不到有效的 JSON 結構。")
|
| 440 |
+
|
| 441 |
+
json_text = content[start : end + 1]
|
| 442 |
+
json_text = re.sub(r',\s*([\}\]])', r'\1', json_text)
|
| 443 |
+
|
| 444 |
+
try:
|
| 445 |
+
return json.loads(json_text)
|
| 446 |
+
except json.JSONDecodeError as e:
|
| 447 |
+
raise ValueError(f"清理後仍然無法解析 JSON。原始錯誤: {e}")
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def reflect_post(api_key, user_input):
|
| 451 |
+
"""
|
| 452 |
+
(最終優化版 / Gemini批次解析 / 非串流)
|
| 453 |
+
API 呼叫總次數固定為 4 次。
|
| 454 |
+
"""
|
| 455 |
+
# Step 1:拆解子問題 (API Call #1)
|
| 456 |
+
decomposed_text = reply(api_key, system_reviewer, user_input)
|
| 457 |
+
if "抱歉~" in decomposed_text:
|
| 458 |
+
return "", decomposed_text
|
| 459 |
+
try:
|
| 460 |
+
parsed_list = extract_json(decomposed_text)
|
| 461 |
+
if not isinstance(parsed_list, list): raise ValueError("回傳的不是列表格式")
|
| 462 |
+
except Exception as e:
|
| 463 |
+
return "", f"⚠️ 查詢意圖格式錯誤:{e}"
|
| 464 |
+
|
| 465 |
+
direct_queries = [item for item in parsed_list if item.get("type") == "direct"]
|
| 466 |
+
if not direct_queries:
|
| 467 |
+
return "", "⚠️ 無法從輸入問題中擷取有效查詢項目。"
|
| 468 |
+
|
| 469 |
+
all_querys_summary = "🔹 關鍵查詢:\n" + "\n".join(f"- {q['sub_query']}" for q in direct_queries)
|
| 470 |
+
|
| 471 |
+
sub_query_texts = [q["sub_query"] for q in direct_queries]
|
| 472 |
+
|
| 473 |
+
# Step 2:批次匹配表名 (API Call #2)
|
| 474 |
+
table_map = batch_find_relevant_tables(api_key, sub_query_texts)
|
| 475 |
+
if not table_map:
|
| 476 |
+
return all_querys_summary, "⚠️ 系統無法為您的查詢匹配到合適的資料表。"
|
| 477 |
+
|
| 478 |
+
# Step 3: 批次解析所有子問題的參數 (API Call #3)
|
| 479 |
+
params_map = batch_parse_sub_queries_with_gemini(api_key, sub_query_texts)
|
| 480 |
+
if not params_map:
|
| 481 |
+
return all_querys_summary, "⚠️ 系統無法解析您問題中的查詢參數。"
|
| 482 |
+
|
| 483 |
+
# Step 4:逐一執行查詢 (零 API 呼叫)
|
| 484 |
+
context_list = []
|
| 485 |
+
for q in direct_queries:
|
| 486 |
+
sub_query = q["sub_query"]
|
| 487 |
+
table_name = table_map.get(sub_query)
|
| 488 |
+
params = params_map.get(sub_query)
|
| 489 |
+
|
| 490 |
+
if not table_name or not params:
|
| 491 |
+
context_list.append(f"【{sub_query}】\n查詢失敗:未能匹配到資料表或解析參數。")
|
| 492 |
+
continue
|
| 493 |
+
|
| 494 |
+
try:
|
| 495 |
+
# 帶著所有精準參數執行查詢
|
| 496 |
+
result_json = semantic_query_logic(
|
| 497 |
+
time_query=params.get("time_query", ""),
|
| 498 |
+
item_query=params.get("item_query", ""),
|
| 499 |
+
district_query=params.get("district_query", ""),
|
| 500 |
+
project_name=table_name
|
| 501 |
+
)
|
| 502 |
+
result_data = json.loads(result_json)
|
| 503 |
+
if result_data:
|
| 504 |
+
formatted_result = json.dumps(result_data, ensure_ascii=False, indent=2)
|
| 505 |
+
context_list.append(f"【{sub_query}】\n查詢結果:\n{formatted_result}")
|
| 506 |
+
else:
|
| 507 |
+
context_list.append(f"【{sub_query}】\n查無資料。")
|
| 508 |
+
except Exception as e:
|
| 509 |
+
context_list.append(f"【{sub_query}】\n查詢失敗:{e}")
|
| 510 |
+
|
| 511 |
+
combined_context = "\n\n".join(context_list)
|
| 512 |
+
|
| 513 |
+
# Step 5:整合分析 (API Call #4)
|
| 514 |
+
integration_prompt = f"使用者問題:{user_input}\n\n查詢資料如下:\n{combined_context}"
|
| 515 |
+
integration_result = reply(api_key, system_integration, integration_prompt)
|
| 516 |
+
return all_querys_summary, integration_result
|
| 517 |
+
|
| 518 |
+
# =======================================================================
|
| 519 |
+
# 5. Gradio Web UI
|
| 520 |
+
# =======================================================================
|
| 521 |
+
|
| 522 |
+
def gradio_interface(user_input):
|
| 523 |
+
"""Gradio 的主要處理函式"""
|
| 524 |
+
api_key = os.getenv('Gemini')
|
| 525 |
+
if not api_key:
|
| 526 |
+
return "❌ 查詢失敗", "錯誤:未在伺服器環境中設定 'Gemini' API 金鑰。"
|
| 527 |
+
|
| 528 |
+
# 檢查向量資料庫是否成功載入
|
| 529 |
+
if not db_jb or not db_sim:
|
| 530 |
+
return "❌ 系統錯誤", "向量資料庫未成功載入,請檢查伺服器日誌。"
|
| 531 |
+
|
| 532 |
+
try:
|
| 533 |
+
analysis_text, final_result = reflect_post(api_key, user_input)
|
| 534 |
+
return analysis_text, final_result
|
| 535 |
+
except Exception as e:
|
| 536 |
+
# 捕捉預期外的錯誤
|
| 537 |
+
import traceback
|
| 538 |
+
print(traceback.format_exc()) # 在後台印出詳細錯誤
|
| 539 |
+
return "❌ 查詢失敗", f"發生未預期的系統錯誤:{str(e)}"
|
| 540 |
+
|
| 541 |
+
# --- UI 介面定義 ---
|
| 542 |
+
with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="orange")) as demo:
|
| 543 |
+
gr.Markdown(
|
| 544 |
+
"""
|
| 545 |
+
# 🤖 高雄市公務統計資料智慧查詢
|
| 546 |
+
歡迎使用!您可以透過自然語言提出關於高雄市的公務統計問題,系統將盡力為您查找相關資訊。
|
| 547 |
+
"""
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
with gr.Row():
|
| 551 |
+
with gr.Column(scale=1):
|
| 552 |
+
user_input_box = gr.Textbox(
|
| 553 |
+
label="請在此輸入您的問題",
|
| 554 |
+
placeholder="例如:113年底前金區全區人口數?",
|
| 555 |
+
lines=5
|
| 556 |
+
)
|
| 557 |
+
gr.Examples(
|
| 558 |
+
examples=[
|
| 559 |
+
"113年底前金區全區人口數?",
|
| 560 |
+
"110-113年高雄市總人口數趨勢?",
|
| 561 |
+
"110-113年失業率情形",
|
| 562 |
+
"國小一年級學生人數(缺少時間不能查)",
|
| 563 |
+
],
|
| 564 |
+
inputs=user_input_box,
|
| 565 |
+
label="💡 範例問題"
|
| 566 |
+
)
|
| 567 |
+
with gr.Row():
|
| 568 |
+
btn_clear = gr.ClearButton(value="清除")
|
| 569 |
+
btn_submit = gr.Button("送出查詢", variant="primary")
|
| 570 |
+
|
| 571 |
+
with gr.Column(scale=1):
|
| 572 |
+
output_analysis = gr.Textbox(
|
| 573 |
+
label="🌟 問題分析",
|
| 574 |
+
interactive=False,
|
| 575 |
+
lines=6,
|
| 576 |
+
visible=False,
|
| 577 |
+
)
|
| 578 |
+
output_result = gr.Textbox(
|
| 579 |
+
label="🧐 查詢結果",
|
| 580 |
+
interactive=False,
|
| 581 |
+
lines=10,
|
| 582 |
+
)
|
| 583 |
|
| 584 |
+
gr.Markdown(
|
| 585 |
+
"""
|
| 586 |
+
---
|
| 587 |
+
*資料來源:[高雄市政府主計處](https://kcgdg.kcg.gov.tw/StatWebRWD/Page/Default.aspx)*
|
| 588 |
+
*本工具由 AI 驅動,查詢結果僅供參考。*
|
| 589 |
+
"""
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
# --- 事件綁定 ---
|
| 593 |
+
outputs_list = [output_analysis, output_result]
|
| 594 |
+
btn_submit.click(fn=gradio_interface, inputs=user_input_box, outputs=outputs_list)
|
| 595 |
+
user_input_box.submit(fn=gradio_interface, inputs=user_input_box, outputs=outputs_list)
|
| 596 |
+
btn_clear.add([user_input_box] + outputs_list)
|
| 597 |
|
| 598 |
+
# --- 啟動應用程式 ---
|
| 599 |
+
if __name__ == "__main__":
|
| 600 |
+
load_data() # 預先載入 Excel 資料到快取
|
| 601 |
+
demo.launch(debug=True)
|