Spaces:
Running
Running
Song
commited on
Commit
·
8eeadb9
1
Parent(s):
764caf7
hi
Browse files- .gitattributes +1 -0
- Dockerfile +36 -0
- app.py +677 -0
- bm25.pkl +3 -0
- drug_sentences.index +3 -0
- drug_sentences.pkl +3 -0
- 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
|