SiangKai commited on
Commit
33a6add
·
verified ·
1 Parent(s): 291fa20

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +598 -4
app.py CHANGED
@@ -1,7 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
 
 
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)