Song commited on
Commit
8eeadb9
·
1 Parent(s): 764caf7
Files changed (7) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +36 -0
  3. app.py +677 -0
  4. bm25.pkl +3 -0
  5. drug_sentences.index +3 -0
  6. drug_sentences.pkl +3 -0
  7. requirements.txt +26 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.index filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # ---- System deps ----
4
+ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
5
+ build-essential \
6
+ git \
7
+ curl \
8
+ libgomp1 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # ---- Workdir ----
12
+ WORKDIR /app
13
+
14
+ # ---- Copy requirement & install ----
15
+ COPY requirements.txt /app/requirements.txt
16
+ RUN pip install --no-cache-dir -U pip \
17
+ && pip install --no-cache-dir -r /app/requirements.txt
18
+
19
+ # ---- Runtime cache to /tmp (writeable) ----
20
+ ENV HF_HOME=/tmp/hf \
21
+ SENTENCE_TRANSFORMERS_HOME=/tmp/sentence_transformers \
22
+ XDG_CACHE_HOME=/tmp/.cache
23
+
24
+ # ---- Copy app ----
25
+ COPY . /app
26
+
27
+ # ---- Healthcheck ----
28
+ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
29
+ CMD curl -f http://localhost:7860/ || exit 1
30
+
31
+ # ---- Port & CMD ----
32
+ EXPOSE 7860
33
+ ENV PORT=7860 \
34
+ PYTHONUNBUFFERED=1
35
+
36
+ CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --log-level info"]
app.py ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ DrugQA (ZH) — FastAPI LINE webhook only (/webhook).
5
+
6
+ 僅使用這些 HF 環境變數:
7
+ - CHANNEL_ACCESS_TOKEN
8
+ - CHANNEL_SECRET
9
+ - LITELLM_API_KEY
10
+ - LITELLM_BASE_URL
11
+ - LM_MODEL
12
+
13
+ 優先載入專案根目錄的檔案(drug_sentences.pkl / drug_sentences.index / bm25.pkl),
14
+ 若不存在才退回 /tmp。重建索引時只嘗試寫到 /tmp,避免唯讀權限問題。
15
+ 所有快取統一 /tmp。
16
+ """
17
+
18
+ # ---------- 先設定快取目錄(import transformers 前) ----------
19
+ import os, pathlib, errno
20
+ os.environ.setdefault("HF_HOME", "/tmp/hf")
21
+ os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", "/tmp/sentence_transformers")
22
+ os.environ.setdefault("XDG_CACHE_HOME", "/tmp/.cache")
23
+ os.environ.pop("TRANSFORMERS_CACHE", None) # 已棄用
24
+ for d in (os.getenv("HF_HOME"), os.getenv("SENTENCE_TRANSFORMERS_HOME"), os.getenv("XDG_CACHE_HOME")):
25
+ pathlib.Path(d).mkdir(parents=True, exist_ok=True)
26
+
27
+ # ---------- Imports ----------
28
+ import re, hmac, base64, hashlib, pickle, logging, time, json
29
+ from typing import List, Dict, Any, Optional, Tuple
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ try:
35
+ import torch # 僅用於檢查裝置
36
+ except Exception:
37
+ torch = None
38
+
39
+ try:
40
+ import faiss # type: ignore
41
+ except Exception as e:
42
+ raise RuntimeError(f"faiss not available: {e}")
43
+
44
+ try:
45
+ from sentence_transformers import SentenceTransformer, CrossEncoder # type: ignore
46
+ except Exception:
47
+ SentenceTransformer = None
48
+
49
+ try:
50
+ from rank_bm25 import BM25Okapi # type: ignore
51
+ except Exception:
52
+ BM25Okapi = None
53
+
54
+ try:
55
+ import jieba # type: ignore
56
+ except Exception:
57
+ jieba = None
58
+
59
+ try:
60
+ from fuzzywuzzy import fuzz # type: ignore
61
+ except Exception:
62
+ fuzz = None
63
+
64
+ try:
65
+ import requests # type: ignore
66
+ except Exception:
67
+ requests = None
68
+
69
+ from fastapi import FastAPI, HTTPException, Header, Request
70
+
71
+ # ---------- Logging ----------
72
+ LOG_LEVEL = (os.getenv("LOG_LEVEL") or "INFO").upper()
73
+ logging.basicConfig(level=LOG_LEVEL, format="%(asctime)s - %(levelname)s - %(message)s")
74
+ log = logging.getLogger("app")
75
+
76
+ # ---------- 只讀取你指定的 HF 環境變數 ----------
77
+ CHANNEL_ACCESS_TOKEN = os.getenv("CHANNEL_ACCESS_TOKEN")
78
+ CHANNEL_SECRET = os.getenv("CHANNEL_SECRET")
79
+ LITELLM_API_KEY = os.getenv("LITELLM_API_KEY")
80
+ LITELLM_BASE_URL = os.getenv("LITELLM_BASE_URL")
81
+ LM_MODEL = os.getenv("LM_MODEL")
82
+
83
+ # ---------- 檢索設定(固定常數) ----------
84
+ TOP_K_SENTENCES = 10
85
+ BM25_WEIGHT = 0.6
86
+ SEM_WEIGHT = 0.4
87
+ EMBEDDING_MODEL_ID= "DMetaSoul/Dmeta-embedding-zh"
88
+ RERANKER_MODEL_ID = "BAAI/bge-reranker-v2-m3"
89
+ USE_CPU = True # HF 預設 CPU
90
+ RERANK_THRESHOLD = 0.5
91
+ MAX_CONTEXT_CHARS = 8000
92
+ DISCLAIMER = "*免責聲明:本資訊僅供參考,若有疑問請諮詢醫師或藥師。*"
93
+
94
+ # 藥名映射與停用詞
95
+ DRUG_NAME_MAPPING = {
96
+ "fentanyl patch": "fentanyl",
97
+ "spiriva respimat": "spiriva",
98
+ "augmentin for syrup": "augmentin syrup",
99
+ "nitrostat": "nitroglycerin",
100
+ "ozempic": "ozempic",
101
+ "niflec": "niflec",
102
+ "fosamax": "fosamax",
103
+ "humira": "humira",
104
+ "premarin": "premarin",
105
+ "smecta": "smecta",
106
+ }
107
+ DRUG_STOPWORDS = {"藥", "劑", "錠", "膠囊", "糖漿", "乳膏", "貼片"}
108
+
109
+ # 意圖分類
110
+ INTENT_CATEGORIES = [
111
+ "操作 (Administration)",
112
+ "保存/攜帶 (Storage & Handling)",
113
+ "副作用/異常 (Side Effects / Issues)",
114
+ "劑型相關 (Dosage Form Concerns)",
115
+ "時間/併用 (Timing & Interaction)",
116
+ "劑量調整 (Dosage Adjustment)",
117
+ "禁忌症/適應症 (Contraindications/Indications)"
118
+ ]
119
+
120
+ # 章節權重
121
+ SECTION_WEIGHTS = {
122
+ "用法及用量": 1.0,
123
+ "病人使用須知": 1.0,
124
+ "儲存條件": 1.0,
125
+ "警語及注意事項": 1.0,
126
+ "禁忌": 1.0,
127
+ "副作用": 1.0,
128
+ "藥物交互作用": 1.0,
129
+ "其他": 1.0,
130
+ "包裝及儲存": 1.0,
131
+ "不良反應": 1.0,
132
+ }
133
+
134
+ IMPORTANT_SECTIONS = ["用法及用量", "病人使用須知", "包裝及儲存", "不良反應", "警語及注意事項"]
135
+
136
+ # ---------- 路徑工具 ----------
137
+ def pick_existing_or_tmp(candidates: List[str]) -> str:
138
+ for p in candidates:
139
+ if os.path.exists(p):
140
+ return p
141
+ base = os.path.basename(candidates[0])
142
+ fallback = os.path.join("/tmp", base)
143
+ pathlib.Path(fallback).parent.mkdir(parents=True, exist_ok=True)
144
+ return fallback
145
+
146
+ def safe_pickle_dump(obj: Any, preferred_path: str) -> str:
147
+ try:
148
+ pathlib.Path(preferred_path).parent.mkdir(parents=True, exist_ok=True)
149
+ with open(preferred_path, "wb") as f:
150
+ pickle.dump(obj, f)
151
+ return preferred_path
152
+ except OSError as e:
153
+ if e.errno == errno.EACCES:
154
+ alt = os.path.join("/tmp", os.path.basename(preferred_path))
155
+ try:
156
+ with open(alt, "wb") as f:
157
+ pickle.dump(obj, f)
158
+ log.warning("No write permission for %s, saved to %s instead.", preferred_path, alt)
159
+ return alt
160
+ except Exception as ee:
161
+ log.warning("Failed to save to /tmp as well: %s", ee)
162
+ else:
163
+ log.warning("pickle dump failed: %s", e)
164
+ except Exception as e:
165
+ log.warning("pickle dump failed: %s", e)
166
+ return ""
167
+
168
+ def safe_faiss_write(index, preferred_path: str) -> str:
169
+ try:
170
+ pathlib.Path(preferred_path).parent.mkdir(parents=True, exist_ok=True)
171
+ faiss.write_index(index, preferred_path)
172
+ return preferred_path
173
+ except OSError as e:
174
+ if e.errno == errno.EACCES:
175
+ alt = os.path.join("/tmp", os.path.basename(preferred_path))
176
+ try:
177
+ faiss.write_index(index, alt)
178
+ log.warning("No write permission for %s, saved FAISS to %s instead.", preferred_path, alt)
179
+ return alt
180
+ except Exception as ee:
181
+ log.warning("Failed to save FAISS to /tmp as well: %s", ee)
182
+ else:
183
+ log.warning("faiss write failed: %s", e)
184
+ except Exception as e:
185
+ log.warning("faiss write failed: %s", e)
186
+ return ""
187
+
188
+ # ---------- 檔案路徑(優先專案根目錄,其次 /app,最後 /tmp) ----------
189
+ CWD = os.getcwd()
190
+ SENTENCES_PKL = pick_existing_or_tmp([
191
+ os.path.join(CWD, "drug_sentences.pkl"),
192
+ "/app/drug_sentences.pkl",
193
+ "/tmp/drug_sentences.pkl",
194
+ ])
195
+ FAISS_INDEX = pick_existing_or_tmp([
196
+ os.path.join(CWD, "drug_sentences.index"),
197
+ "/app/drug_sentences.index",
198
+ "/tmp/drug_sentences.index",
199
+ ])
200
+ BM25_PKL = pick_existing_or_tmp([
201
+ os.path.join(CWD, "bm25.pkl"),
202
+ "/app/bm25.pkl",
203
+ "/tmp/bm25.pkl",
204
+ ])
205
+ CSV_PATH = pick_existing_or_tmp([
206
+ os.path.join(CWD, "cleaned_combined.csv"),
207
+ "/app/cleaned_combined.csv",
208
+ "/tmp/cleaned_combined.csv",
209
+ ])
210
+
211
+ # ---------- FastAPI ----------
212
+ app = FastAPI(title="DrugQA (ZH) — LINE Webhook Only")
213
+
214
+ # ---------- Helpers ----------
215
+ _ZH_SPLIT_RE = re.compile(r"[。!?\n]")
216
+
217
+ def split_sentences(text: str) -> List[str]:
218
+ if not isinstance(text, str): return []
219
+ sents = [s.strip() for s in _ZH_SPLIT_RE.split(text) if s.strip()]
220
+ return [s for s in sents if len(s) > 6]
221
+
222
+ def tokenize_zh(s: str) -> List[str]:
223
+ if not isinstance(s, str) or not s: return []
224
+ if jieba is None: return s.strip().split()
225
+ return [t for t in jieba.lcut(s) if t.strip()]
226
+
227
+ class State:
228
+ sentences: List[str] = []
229
+ meta: List[Dict[str, Any]] = []
230
+ emb_model: Optional[Any] = None
231
+ reranker_model: Optional[Any] = None
232
+ faiss_index: Optional[Any] = None
233
+ bm25: Optional[Any] = None
234
+ df_csv: Optional[pd.DataFrame] = None
235
+ user_sessions: Dict[str, Dict[str, Any]] = {} # 簡易 session 快取
236
+
237
+ STATE = State()
238
+
239
+ # ---------- 載入與建立 ----------
240
+ def ensure_sentences_meta() -> Tuple[List[str], List[Dict[str, Any]]]:
241
+ if os.path.exists(SENTENCES_PKL):
242
+ try:
243
+ with open(SENTENCES_PKL, "rb") as f:
244
+ obj = pickle.load(f)
245
+ sents = obj.get("sentences", []) if isinstance(obj, dict) else []
246
+ meta = obj.get("meta", []) if isinstance(obj, dict) else []
247
+ log.info("Loaded sentences/meta: %s (n=%d)", SENTENCES_PKL, len(sents))
248
+ return sents, meta
249
+ except Exception as e:
250
+ log.warning("Failed to load sentences pkl (%s). Corpus will be empty.", e)
251
+ else:
252
+ log.info("Sentences pkl not found: %s", SENTENCES_PKL)
253
+ return [], []
254
+
255
+ def load_embedding_model(model_id: str):
256
+ if SentenceTransformer is None:
257
+ log.warning("sentence-transformers 不可用;僅以 BM25 檢索。")
258
+ return None
259
+ device = "cpu" if (USE_CPU or (torch is None)) else ("cuda" if torch.cuda.is_available() else "cpu")
260
+ log.info("Load SentenceTransformer: %s on %s", model_id, device)
261
+ try:
262
+ return SentenceTransformer(model_id, device=device)
263
+ except Exception as e:
264
+ log.warning("載入 embedding 失敗:%s", e)
265
+ return None
266
+
267
+ def load_reranker_model(model_id: str):
268
+ if CrossEncoder is None:
269
+ log.warning("CrossEncoder 不可用;略過 rerank。")
270
+ return None
271
+ device = "cpu" if (USE_CPU or (torch is None)) else ("cuda" if torch.cuda.is_available() else "cpu")
272
+ log.info("Load CrossEncoder: %s on %s", model_id, device)
273
+ try:
274
+ return CrossEncoder(model_id, device=device)
275
+ except Exception as e:
276
+ log.warning("載入 reranker 失敗:%s", e)
277
+ return None
278
+
279
+ def ensure_faiss(index_path: str, sentences: List[str]):
280
+ if os.path.exists(index_path):
281
+ try:
282
+ idx = faiss.read_index(index_path)
283
+ log.info("Loaded FAISS: %s (ntotal=%d)", index_path, getattr(idx, "ntotal", -1))
284
+ return idx
285
+ except Exception as e:
286
+ log.warning("FAISS 載入失敗(%s)", e)
287
+ if not sentences or STATE.emb_model is None:
288
+ log.warning("缺少語料或嵌入模型,無法建立 FAISS。")
289
+ return None
290
+ try:
291
+ vecs = STATE.emb_model.encode(sentences, show_progress_bar=False, convert_to_numpy=True).astype("float32")
292
+ faiss.normalize_L2(vecs)
293
+ idx = faiss.IndexFlatIP(vecs.shape[1])
294
+ idx.add(vecs)
295
+ safe_faiss_write(idx, index_path)
296
+ return idx
297
+ except Exception as e:
298
+ log.warning("FAISS 建立失敗:%s", e)
299
+ return None
300
+
301
+ def ensure_bm25(path: str, sentences: List[str]):
302
+ if not sentences or BM25Okapi is None:
303
+ return None
304
+ if os.path.exists(path):
305
+ try:
306
+ with open(path, "rb") as f:
307
+ obj = pickle.load(f)
308
+ if isinstance(obj, dict):
309
+ cand = obj.get("bm25")
310
+ if cand is None and obj.get("tokenized"):
311
+ cand = BM25Okapi(obj["tokenized"])
312
+ bm25 = cand if cand is not None else obj
313
+ else:
314
+ bm25 = obj
315
+ if hasattr(bm25, "get_scores"):
316
+ _ = bm25.get_scores(tokenize_zh("測試"))
317
+ log.info("Loaded BM25: %s", path)
318
+ return bm25
319
+ else:
320
+ raise ValueError("bm25 object missing get_scores")
321
+ except Exception as e:
322
+ log.warning("BM25 載入失敗(%s),將用現有 sentences 重建。", e)
323
+ tokenized = [tokenize_zh(s) for s in sentences]
324
+ try:
325
+ bm25 = BM25Okapi(tokenized)
326
+ safe_pickle_dump({"bm25": bm25, "tokenized": tokenized, "sentences": sentences}, path)
327
+ return bm25
328
+ except Exception as e:
329
+ log.warning("BM25 建立失敗:%s", e)
330
+ return None
331
+
332
+ # ---------- 藥名預處理 ----------
333
+ def extract_drug_candidates_from_query(query: str) -> list:
334
+ query = re.sub(r"[A-Za-z]+", lambda m: m.group(0).lower(), query)
335
+ candidates = set()
336
+ parts = query.split(":", 1)
337
+ drug_part = parts[0] if len(parts) > 1 else query
338
+ for m in re.finditer(r"[a-zA-Z]{3,}", drug_part):
339
+ candidates.add(m.group(0))
340
+ for token in re.split(r"[\s,/()()]+", drug_part):
341
+ clean_token = re.sub(r'[a-zA-Z0-9\s]+', '', token).strip()
342
+ if clean_token and clean_token.lower() not in DRUG_STOPWORDS:
343
+ candidates.add(clean_token)
344
+ if drug_part.strip():
345
+ candidates.add(drug_part.strip())
346
+ for query_name, dataset_name in DRUG_NAME_MAPPING.items():
347
+ if query_name in query.lower():
348
+ candidates.add(dataset_name)
349
+ candidates = list(candidates)
350
+ # 自動加空格
351
+ query = re.sub(r'([a-zA-Z])([a-zA-Z0-9\s]*\W)', r'\1 \2', query) # e.g., "Fentanylpatch" -> "Fentanyl patch"
352
+ return [c for c in candidates if len(c) > 1], query
353
+
354
+ def find_drug_ids_from_name(query: str, df: pd.DataFrame) -> List[str]:
355
+ aliases, query = extract_drug_candidates_from_query(query)
356
+ drug_scores = {}
357
+ name_cols = [c for c in ["drug_name_norm", "drug_name", "name", "trade_name"] if c in df.columns]
358
+ id_col = "drug_id" if "drug_id" in df.columns else None
359
+ if not id_col:
360
+ df['temp_drug_id'] = df['chunk_id'].apply(lambda x: str(x).split('_')[0] if pd.notna(x) else None)
361
+ id_col = 'temp_drug_id'
362
+ for _, row in df.iterrows():
363
+ current_drug_id = row.get(id_col)
364
+ if not current_drug_id:
365
+ continue
366
+ name_joined = " ".join([str(row.get(c, "")).lower() for c in name_cols])
367
+ if not name_joined.strip():
368
+ continue
369
+ max_score_for_this_row = 0
370
+ for token in aliases:
371
+ tl = token.lower()
372
+ score = 0
373
+ if tl and tl not in DRUG_STOPWORDS:
374
+ if fuzz.ratio(tl, name_joined) > 80:
375
+ score = 2.0 if re.search(r'[a-zA-Z]', tl) else 1.5
376
+ score *= (1 + len(tl) / 20)
377
+ if score > max_score_for_this_row:
378
+ max_score_for_this_row = score
379
+ if max_score_for_this_row > 0:
380
+ current_max = drug_scores.get(current_drug_id, 0)
381
+ if max_score_for_this_row > current_max:
382
+ drug_scores[current_drug_id] = max_score_for_this_row
383
+ return list(drug_scores.keys())
384
+
385
+ # ---------- 意圖偵測與權重調整 ----------
386
+ def detect_intent(query: str) -> List[str]:
387
+ prompt = f"根據以下問題偵測意圖類別,從 {INTENT_CATEGORIES} 中選1-2個最相關的。以JSON輸出['intents': [...]]。問題:{query}"
388
+ resp = call_llm(prompt, max_tokens=50)
389
+ try:
390
+ data = json.loads(resp)
391
+ return data.get("intents", [])
392
+ except:
393
+ return []
394
+
395
+ def adjust_section_weights(intents: list) -> dict:
396
+ weights = SECTION_WEIGHTS.copy()
397
+ if not intents:
398
+ return weights
399
+ for intent in intents:
400
+ if "操作" in intent or "劑型相關" in intent:
401
+ weights["用法及用量"] *= 1.8
402
+ weights["病人使用須知"] *= 1.5
403
+ elif "保存" in intent:
404
+ weights["儲存條件"] *= 1.8
405
+ weights["包裝及儲存"] *= 1.8
406
+ elif "副作用" in intent:
407
+ weights["副作用"] *= 1.8
408
+ weights["不良反應"] *= 1.8
409
+ weights["警語及注意事項"] *= 1.5
410
+ weights["禁忌"] *= 1.5
411
+ elif "時間/併用" in intent:
412
+ weights["用法及用量"] *= 1.4
413
+ weights["病人使用須知"] *= 1.4
414
+ weights["藥物交互作用"] *= 1.6
415
+ elif "劑量調整" in intent:
416
+ weights["用法及用量"] *= 1.8
417
+ weights["病人使用須知"] *= 1.5
418
+ elif "禁忌症" in intent:
419
+ weights["禁忌"] *= 2.0
420
+ weights["警語及注意事項"] *= 1.8
421
+ # 強制重要章節
422
+ for sec in IMPORTANT_SECTIONS:
423
+ weights[sec] = max(weights.get(sec, 1.0), 1.5)
424
+ return weights
425
+
426
+ # ---------- 檢索與 LLM ----------
427
+ def bm25_search(query: str, bm25, sentences, top_k: int = 50) -> List[int]:
428
+ if bm25 is None: return []
429
+ try:
430
+ toks = tokenize_zh(query)
431
+ scores = bm25.get_scores(toks)
432
+ idxs = np.argsort(-np.asarray(scores))[:top_k]
433
+ return [int(i) for i in idxs]
434
+ except Exception as e:
435
+ log.warning("BM25 搜尋失敗:%s", e)
436
+ return []
437
+
438
+ def semantic_search(query: str, index, emb_model, top_k: int = 50) -> List[Tuple[int, float]]:
439
+ if emb_model is None: return []
440
+ try:
441
+ qv = emb_model.encode([query], convert_to_numpy=True).astype("float32")
442
+ faiss.normalize_L2(qv)
443
+ if index is not None:
444
+ k = min(top_k, getattr(index, "ntotal", 0))
445
+ if k <= 0: return []
446
+ D, I = index.search(qv, k)
447
+ return list(zip(I[0].tolist(), D[0].tolist()))
448
+ return []
449
+ except Exception as e:
450
+ log.warning("Semantic 搜尋失敗:%s", e)
451
+ return []
452
+
453
+ def rerank_results(query: str, candidates: List[Tuple[int, float, float, float]], sentences, reranker, top_k: int, threshold: float) -> List[Dict]:
454
+ if not candidates or reranker is None:
455
+ return [{"idx": c[0], "rerank_score": 0.0, "fused": c[1], "sem": c[2], "bm": c[3], "text": sentences[c[0]]} for c in candidates[:top_k]]
456
+ pairs = [[query, sentences[idx]] for idx, _, _, _ in candidates]
457
+ scores = reranker.predict(pairs, batch_size=8)
458
+ ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
459
+ out = []
460
+ for (idx, fused, sem, bm), sc in ranked:
461
+ if sc > threshold:
462
+ out.append({"idx": idx, "rerank_score": float(sc), "fused": fused, "sem": sem, "bm": bm, "text": sentences[idx]})
463
+ if len(out) >= top_k:
464
+ break
465
+ return out or [{"idx": -1, "rerank_score": 0.0, "fused": 0.0, "sem": 0.0, "bm": 0.0, "text": "無相關資料,請諮詢醫師或藥師。"}]
466
+
467
+ def fuse_and_select(query: str, sentences, meta, bm25, index, emb_model, reranker, top_k: int = TOP_K_SENTENCES) -> List[int]:
468
+ intents = detect_intent(query)
469
+ weights = adjust_section_weights(intents)
470
+ df = STATE.df_csv
471
+ drug_ids = find_drug_ids_from_name(query, df)
472
+ relevant_indices = [i for i, m in enumerate(meta) if m.get("drug_id") in drug_ids]
473
+ if not relevant_indices:
474
+ return []
475
+ relevant_sentences = [sentences[i] for i in relevant_indices]
476
+ relevant_meta = [meta[i] for i in relevant_indices]
477
+ relevant_bm25 = BM25Okapi([tokenize_zh(s) for s in relevant_sentences])
478
+ bm_idx = bm25_search(query, relevant_bm25, relevant_sentences, top_k * 2)
479
+ sem = semantic_search(query, index, emb_model, top_k * 2)
480
+ scores: Dict[int, float] = {}
481
+ for rank, rel_i in enumerate(bm_idx):
482
+ global_i = relevant_indices[rel_i]
483
+ section = relevant_meta[rel_i].get("section", "其他")
484
+ section_weight = weights.get(section, 1.0)
485
+ scores[global_i] = scores.get(global_i, 0.0) + BM25_WEIGHT * (1.0 / (1 + rank)) * section_weight
486
+ for global_i, s in sem:
487
+ if global_i in relevant_indices:
488
+ rel_i = relevant_indices.index(global_i)
489
+ section = relevant_meta[rel_i].get("section", "其他")
490
+ section_weight = weights.get(section, 1.0)
491
+ scores[global_i] = scores.get(global_i, 0.0) + SEM_WEIGHT * float(s) * section_weight
492
+ # 強制追加重要章節
493
+ added = set()
494
+ for sec in IMPORTANT_SECTIONS:
495
+ sec_indices = [i for i in relevant_indices if meta[i].get("section") == sec]
496
+ if sec_indices and not any(i in scores for i in sec_indices):
497
+ scores[sec_indices[0]] = 1.0 # 追加一個
498
+ added.add(sec_indices[0])
499
+ candidates = sorted(scores.items(), key=lambda x: -x[1])[:top_k * 2]
500
+ candidates = [(i, score, 0.0, 0.0) for i, score in candidates] # 簡化為 (idx, fused, sem, bm)
501
+ reranked = rerank_results(query, candidates, sentences, reranker, top_k, RERANK_THRESHOLD)
502
+ idxs = [r["idx"] for r in reranked]
503
+ # 追加重要章節若缺失
504
+ for sec in IMPORTANT_SECTIONS:
505
+ if not any(meta[i].get("section") == sec for i in idxs if i >= 0):
506
+ sec_idx = next((i for i in relevant_indices if meta[i].get("section") == sec), None)
507
+ if sec_idx:
508
+ idxs.append(sec_idx)
509
+ return idxs[:top_k]
510
+
511
+ def build_context(idxs: List[int], sentences: List[str], meta: List[Dict[str, Any]]) -> str:
512
+ ctx_lines, total_len, seen = [], 0, set()
513
+ for i in idxs:
514
+ if i < 0: continue
515
+ text = sentences[i]
516
+ if text in seen: continue
517
+ chunk_id = meta[i].get("chunk_id", "None")
518
+ line = f"[S{chunk_id}]: {text}"
519
+ if total_len + len(line) > MAX_CONTEXT_CHARS: break
520
+ ctx_lines.append(line)
521
+ total_len += len(line) + 1
522
+ seen.add(text)
523
+ return "\n".join(ctx_lines) or "[SNone]: 沒有找到相關資料,請諮詢醫師或藥師。"
524
+
525
+ def build_prompt(query: str, contexts: str, intents: List[str]) -> str:
526
+ trouble_shooting = ""
527
+ if "操作" in " ".join(intents) or "劑型相關" in " ".join(intents):
528
+ trouble_shooting = "檢查組裝:問用戶平時怎麼用,有什麼問題。示範步驟。若不會組裝,建議示範或諮醫。優先藥袋醫囑,其次用法用量/病人使用須知。"
529
+ elif "保存" in " ".join(intents):
530
+ trouble_shooting = "檢查保存:問怎麼存,避免水/熱/潮濕,否則失效。標準:室溫<30°C或冷藏2-8°C [Sxxx]。旅遊:用原瓶避熱。"
531
+ elif "副作用" in " ".join(intents):
532
+ trouble_shooting = "常見:頭痛等 [Sxxx];嚴重:立即停藥諮醫 [Sxxx]。合併不良反應/警語。"
533
+ elif "劑量" in " ".join(intents) or "時間" in " ".join(intents):
534
+ trouble_shooting = "優先藥袋醫囑(如每日1顆,早餐後)。範圍 [Sxxx]。特殊:病人使用須知。"
535
+ return (
536
+ f"你是一位專業、有同理心的藥師。使用下列參考片段回答問題。若片段無相關資訊,請說不知道。{trouble_shooting}\n"
537
+ f"回答用台灣繁中,親切易懂,分2-3小段,每段<150字。末尾加'了解嗎?(回是/否)'。結尾加{DISCLAIMER}\n"
538
+ f"問題:{query}\n"
539
+ f"參考片段:\n{contexts}\n"
540
+ )
541
+
542
+ def call_llm(prompt: str, max_tokens: int = 2048) -> Optional[str]:
543
+ try:
544
+ from openai import OpenAI
545
+ except Exception as e:
546
+ log.warning("openai client 不可用:%s", e)
547
+ return None
548
+ if not (LITELLM_API_KEY and LM_MODEL and LITELLM_BASE_URL):
549
+ log.warning("LLM 未完整設定;略過生成。")
550
+ return None
551
+ client = OpenAI(base_url=LITELLM_BASE_URL, api_key=LITELLM_API_KEY)
552
+ try:
553
+ t0 = time.time()
554
+ resp = client.chat.completions.create(
555
+ model=LM_MODEL,
556
+ messages=[
557
+ {"role": "system", "content": "你是一位專業、有同理心的藥師。回答忠於資料,不可捏造。語言親切,用台灣繁中+英文藥名。"},
558
+ {"role": "user", "content": prompt},
559
+ ],
560
+ temperature=0.2,
561
+ timeout=10,
562
+ max_tokens=max_tokens,
563
+ )
564
+ used = time.time() - t0
565
+ log.info("LLM ok (%.2fs)", used)
566
+ return (resp.choices[0].message.content or "").strip()
567
+ except Exception as e:
568
+ log.warning("LLM 失敗:%s", e)
569
+ return None
570
+
571
+ async def answer_pipeline(query: str, user_id: str) -> str:
572
+ if not query or not isinstance(query, str):
573
+ return "請提供有效問題。"
574
+ if not STATE.sentences:
575
+ return "目前尚未載入語料,請稍後再試。"
576
+ session = STATE.user_sessions.get(user_id, {})
577
+ if "prev_query" in session and query.lower() in ["是", "否"]:
578
+ # 簡易互動
579
+ if query.lower() == "是":
580
+ return "太好了!若還有問題,請告訴我。" + DISCLAIMER
581
+ else:
582
+ return f"抱歉沒說明清楚。關於{session['prev_query']},請再說詳細點,或直接問醫師。" + DISCLAIMER
583
+ intents = detect_intent(query)
584
+ idxs = fuse_and_select(query, STATE.sentences, STATE.meta, STATE.bm25, STATE.faiss_index, STATE.emb_model, STATE.reranker_model, top_k=TOP_K_SENTENCES)
585
+ contexts = build_context(idxs, STATE.sentences, STATE.meta)
586
+ ans = None
587
+ if LM_MODEL and LITELLM_API_KEY and LITELLM_BASE_URL:
588
+ ans = call_llm(build_prompt(query, contexts, intents))
589
+ if not ans:
590
+ ans = (";".join([STATE.sentences[i] for i in idxs[:3] if i >= 0])) if idxs else "抱歉,暫時找不到相關資訊。"
591
+ STATE.user_sessions[user_id] = {"prev_query": query}
592
+ return ans
593
+
594
+ # ---------- LINE 驗簽與回覆 ----------
595
+ def verify_line_signature(body_bytes: bytes, signature: str) -> bool:
596
+ if not CHANNEL_SECRET:
597
+ log.warning("CHANNEL_SECRET 未設定;跳過簽章驗證(僅供測試)。")
598
+ return True
599
+ try:
600
+ mac = hmac.new(CHANNEL_SECRET.encode("utf-8"), body_bytes, hashlib.sha256).digest()
601
+ expected = base64.b64encode(mac).decode("utf-8")
602
+ return hmac.compare_digest(expected, signature)
603
+ except Exception as e:
604
+ log.warning("簽章驗證錯誤:%s", e)
605
+ return False
606
+
607
+ def line_reply(reply_token: str, text: str) -> None:
608
+ if not CHANNEL_ACCESS_TOKEN or requests is None:
609
+ log.warning("缺少 CHANNEL_ACCESS_TOKEN 或 requests;略過回覆。")
610
+ return
611
+ url = "https://api.line.me/v2/bot/message/reply"
612
+ headers = {
613
+ "Content-Type": "application/json",
614
+ "Authorization": f"Bearer {CHANNEL_ACCESS_TOKEN}",
615
+ }
616
+ data = {"replyToken": reply_token, "messages": [{"type": "text", "text": text[:4900]}]}
617
+ try:
618
+ r = requests.post(url, headers=headers, json=data, timeout=10)
619
+ if r.status_code != 200:
620
+ log.warning("LINE 回覆失敗:%s %s", r.status_code, r.text[:200])
621
+ except Exception as e:
622
+ log.warning("LINE 回覆例外:%s", e)
623
+
624
+ # ---------- 只有這一條路由:POST /webhook ----------
625
+ @app.post("/webhook")
626
+ async def webhook(request: Request, x_line_signature: str = Header(default="")):
627
+ body = await request.body()
628
+ if not verify_line_signature(body, x_line_signature):
629
+ raise HTTPException(status_code=401, detail="Invalid LINE signature")
630
+ try:
631
+ payload = await request.json()
632
+ except Exception:
633
+ raise HTTPException(status_code=400, detail="Invalid JSON body")
634
+
635
+ events = payload.get("events", [])
636
+ for ev in events:
637
+ if ev.get("type") == "message" and ev.get("message", {}).get("type") == "text":
638
+ reply_token = ev.get("replyToken")
639
+ user_id = ev.get("source", {}).get("userId", "unknown")
640
+ user_text = (ev.get("message", {}).get("text") or "").strip()
641
+ try:
642
+ answer = await answer_pipeline(user_text, user_id)
643
+ except Exception as e:
644
+ log.warning("Pipeline 失敗:%s", e)
645
+ answer = "抱歉,系統暫時無法回覆。"
646
+ if reply_token:
647
+ line_reply(reply_token, answer)
648
+ return {"ok": True}
649
+
650
+ # ---------- 啟動 ----------
651
+ @app.on_event("startup")
652
+ async def _startup():
653
+ log.info("===== Application Startup =====")
654
+ try:
655
+ if torch is not None:
656
+ log.info("PyTorch version %s available.", torch.__version__)
657
+ except Exception:
658
+ pass
659
+ # 載入語料與索引
660
+ STATE.sentences, STATE.meta = ensure_sentences_meta()
661
+ STATE.emb_model = load_embedding_model(EMBEDDING_MODEL_ID)
662
+ STATE.reranker_model = load_reranker_model(RERANKER_MODEL_ID)
663
+ STATE.faiss_index = ensure_faiss(FAISS_INDEX, STATE.sentences)
664
+ STATE.bm25 = ensure_bm25(BM25_PKL, STATE.sentences)
665
+ if os.path.exists(CSV_PATH):
666
+ STATE.df_csv = pd.read_csv(CSV_PATH, dtype=str)
667
+ log.info("LLM via LiteLLM: base=%s model=%s", str(LITELLM_BASE_URL), str(LM_MODEL))
668
+ log.info("Startup complete.")
669
+
670
+ @app.get("/")
671
+ async def health():
672
+ return {"status": "healthy"}
673
+
674
+ if __name__ == "__main__":
675
+ import uvicorn
676
+ port = int(os.getenv("PORT", "7860"))
677
+ uvicorn.run("app:app", host="0.0.0.0", port=port, log_level=LOG_LEVEL.lower(), reload=False)
bm25.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ad46c08ef71f81d8e8dc06257fbfc3e01ab86eaa20433ce53d9b4dcbb4c856f5
3
+ size 1916642
drug_sentences.index ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6556d88b7fae8e0707c56d4ec69aab976dcf803a0a5f00b752144785ded7c760
3
+ size 2245677
drug_sentences.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:20462391beeb1b905dafa790dce37f959769cfc5731189683af7e679cc80fcf5
3
+ size 609283
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web server
2
+ fastapi
3
+ uvicorn[standard]
4
+ gunicorn # Optional: for better concurrency if needed
5
+
6
+ # LINE Bot SDK(固定版本,避免 API 變動)
7
+ line-bot-sdk==3.11.0
8
+
9
+ # NLP / RAG
10
+ numpy
11
+ pandas
12
+ jieba
13
+ rank-bm25
14
+ fuzzywuzzy
15
+ python-Levenshtein
16
+
17
+ # 向量索引與嵌入
18
+ faiss-cpu
19
+ sentence-transformers==3.0.1
20
+ torch --extra-index-url https://download.pytorch.org/whl/cpu
21
+
22
+ # OpenAI client (連到 LiteLLM gateway)
23
+ openai
24
+
25
+ # HTTP 請求(LINE 回覆)
26
+ requests