Commit
·
cd9bca9
1
Parent(s):
df0c8ab
first commit
Browse files- .env.example +18 -0
- API_KEYS_GUIDE.md +215 -0
- BUSINESS_QUERY_GUIDE.md +225 -0
- Dockerfile +14 -0
- OPENROUTER_INTEGRATION.md +273 -0
- POSTGRESQL_MIGRATION_GUIDE.md +342 -0
- README.md +32 -5
- alembic.ini +115 -0
- another_proj_schemas.py +492 -0
- app.py +7 -0
- backend/__init__.py +0 -0
- backend/config.py +36 -0
- backend/database/__init__.py +0 -0
- backend/database/connection.py +59 -0
- backend/database/init_db.py +155 -0
- backend/database/models.py +226 -0
- backend/main.py +99 -0
- backend/models/__init__.py +0 -0
- backend/models/schemas.py +66 -0
- backend/services/__init__.py +0 -0
- backend/services/business_query_service.py +169 -0
- backend/services/database_service.py +765 -0
- backend/services/line_bot_service.py +246 -0
- backend/services/nlp_service.py +653 -0
- backend/services/openrouter_service.py +241 -0
- backend/utils/__init__.py +0 -0
- backend/utils/database_init.py +149 -0
- backend/utils/line_utils.py +155 -0
- requirements.txt +13 -0
- setup_guide.md +178 -0
- test_api.py +103 -0
.env.example
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# LINE Bot 設定
|
2 |
+
LINE_CHANNEL_ACCESS_TOKEN=your_line_channel_access_token_here
|
3 |
+
LINE_CHANNEL_SECRET=your_line_channel_secret_here
|
4 |
+
|
5 |
+
# PostgreSQL 資料庫設定
|
6 |
+
DB_HOST=your_database_host_here
|
7 |
+
DB_PORT=6543
|
8 |
+
DB_NAME=your_database_name_here
|
9 |
+
DB_USER=your_database_user_here
|
10 |
+
DB_PASSWORD=your_database_password_here
|
11 |
+
|
12 |
+
# OpenRouter 設定 (可選,用於進階 NLP)
|
13 |
+
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
14 |
+
OPENROUTER_MODEL=anthropic/claude-3-haiku
|
15 |
+
|
16 |
+
# 其他設定
|
17 |
+
DEBUG=False
|
18 |
+
LOG_LEVEL=INFO
|
API_KEYS_GUIDE.md
ADDED
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 🔑 API Keys 取得指南
|
2 |
+
|
3 |
+
本文件詳細說明如何取得所有必要的 API Keys 來運行 LINE Bot 系統。
|
4 |
+
|
5 |
+
## 📋 必要的 API Keys
|
6 |
+
|
7 |
+
### 1. LINE Bot API Keys
|
8 |
+
|
9 |
+
#### 步驟 1: 建立 LINE Developers 帳號
|
10 |
+
1. 前往 [LINE Developers Console](https://developers.line.biz/)
|
11 |
+
2. 使用 LINE 帳號登入
|
12 |
+
3. 同意開發者條款
|
13 |
+
|
14 |
+
#### 步驟 2: 建立 Provider
|
15 |
+
1. 點擊 "Create a new provider"
|
16 |
+
2. 輸入 Provider 名稱 (例如: "我的公司")
|
17 |
+
3. 點擊 "Create"
|
18 |
+
|
19 |
+
#### 步驟 3: 建立 Messaging API Channel
|
20 |
+
1. 在 Provider 頁面點擊 "Create a Messaging API channel"
|
21 |
+
2. 填寫以下資訊:
|
22 |
+
- **Channel name**: 您的 Bot 名稱
|
23 |
+
- **Channel description**: Bot 描述
|
24 |
+
- **Category**: 選擇適合的類別
|
25 |
+
- **Subcategory**: 選擇子類別
|
26 |
+
3. 上傳 Channel icon (可選)
|
27 |
+
4. 同意條款並點擊 "Create"
|
28 |
+
|
29 |
+
#### 步驟 4: 取得 API Keys
|
30 |
+
1. 進入剛建立的 Channel
|
31 |
+
2. 前往 "Basic settings" 頁籤:
|
32 |
+
- 複製 **Channel secret** → 這是您的 `LINE_CHANNEL_SECRET`
|
33 |
+
3. 前往 "Messaging API" 頁籤:
|
34 |
+
- 點擊 "Issue" 按鈕產生 Channel access token
|
35 |
+
- 複製 **Channel access token** → 這是您的 `LINE_CHANNEL_ACCESS_TOKEN`
|
36 |
+
|
37 |
+
#### 步驟 5: 設定 Webhook (部署後執行)
|
38 |
+
1. 在 "Messaging API" 頁籤中
|
39 |
+
2. 設定 **Webhook URL**: `https://你的用戶名-你的空間名稱.hf.space/webhook`
|
40 |
+
3. 啟用 "Use webhook"
|
41 |
+
4. 關閉 "Auto-reply messages" (可選)
|
42 |
+
|
43 |
+
### 2. Supabase API Keys
|
44 |
+
|
45 |
+
#### 步驟 1: 建立 Supabase 帳號
|
46 |
+
1. 前往 [Supabase](https://supabase.com/)
|
47 |
+
2. 點擊 "Start your project"
|
48 |
+
3. 使用 GitHub 或 Google 帳號註冊
|
49 |
+
|
50 |
+
#### 步驟 2: 建立新專案
|
51 |
+
1. 點擊 "New project"
|
52 |
+
2. 選擇組織 (或建立新組織)
|
53 |
+
3. 填寫專案資訊:
|
54 |
+
- **Name**: 專案名稱
|
55 |
+
- **Database Password**: 設定強密碼
|
56 |
+
- **Region**: 選擇最近的區域 (建議: Southeast Asia)
|
57 |
+
4. 點擊 "Create new project"
|
58 |
+
5. 等待專案建立完成 (約 2-3 分鐘)
|
59 |
+
|
60 |
+
#### 步驟 3: 取得 API Keys
|
61 |
+
1. 在專案 Dashboard 中
|
62 |
+
2. 前往左側選單的 "Settings" → "API"
|
63 |
+
3. 複製以下資訊:
|
64 |
+
- **URL** → 這是您的 `SUPABASE_URL`
|
65 |
+
- **anon public** key → 這是您的 `SUPABASE_KEY`
|
66 |
+
|
67 |
+
#### 步驟 4: 建立資料表
|
68 |
+
1. 前往左側選單的 "SQL Editor"
|
69 |
+
2. 複製 `setup_guide.md` 中的 SQL 語句
|
70 |
+
3. 點擊 "RUN" 執行
|
71 |
+
|
72 |
+
## 🚀 可選的 API Keys
|
73 |
+
|
74 |
+
### 3. OpenRouter API Keys (進階 NLP 功能)
|
75 |
+
|
76 |
+
#### 步驟 1: 建立 OpenRouter 帳號
|
77 |
+
1. 前往 [OpenRouter](https://openrouter.ai/)
|
78 |
+
2. 點擊 "Sign Up"
|
79 |
+
3. 使用 Google 或 GitHub 帳號註冊
|
80 |
+
|
81 |
+
#### 步驟 2: 充值帳戶
|
82 |
+
1. 前往 [Credits 頁面](https://openrouter.ai/credits)
|
83 |
+
2. 點擊 "Add Credits"
|
84 |
+
3. 選擇充值金額 (建議先充值 $5-10 測試)
|
85 |
+
4. 完成付款
|
86 |
+
|
87 |
+
#### 步驟 3: 建立 API Key
|
88 |
+
1. 前往 [API Keys 頁面](https://openrouter.ai/keys)
|
89 |
+
2. 點擊 "Create Key"
|
90 |
+
3. 輸入 Key 名稱 (例如: "LINE Bot")
|
91 |
+
4. 複製產生的 API Key → 這是您的 `OPENROUTER_API_KEY`
|
92 |
+
|
93 |
+
#### 步驟 4: 選擇模型
|
94 |
+
根據您的需求和預算選擇模型:
|
95 |
+
|
96 |
+
| 模型 | 成本 | 適用場景 | 設定值 |
|
97 |
+
|------|------|----------|--------|
|
98 |
+
| Claude 3 Haiku | 最低 | 日常查詢 | `anthropic/claude-3-haiku` |
|
99 |
+
| GPT-3.5 Turbo | 中等 | 平衡使用 | `openai/gpt-3.5-turbo` |
|
100 |
+
| Claude 3 Sonnet | 較高 | 複雜查詢 | `anthropic/claude-3-sonnet` |
|
101 |
+
|
102 |
+
## 🔧 環境變數設定
|
103 |
+
|
104 |
+
### Hugging Face Spaces 設定
|
105 |
+
|
106 |
+
1. 前往您的 Hugging Face Spaces 專案
|
107 |
+
2. 點擊 "Settings" 頁籤
|
108 |
+
3. 在 "Repository secrets" 區域新增以下變數:
|
109 |
+
|
110 |
+
```bash
|
111 |
+
# 必要設定
|
112 |
+
LINE_CHANNEL_ACCESS_TOKEN=你的_LINE_Channel_Access_Token
|
113 |
+
LINE_CHANNEL_SECRET=你的_LINE_Channel_Secret
|
114 |
+
SUPABASE_URL=你的_Supabase_專案_URL
|
115 |
+
SUPABASE_KEY=你的_Supabase_Anon_Key
|
116 |
+
|
117 |
+
# 可選設定 (進階 NLP)
|
118 |
+
OPENROUTER_API_KEY=你的_OpenRouter_API_Key
|
119 |
+
OPENROUTER_MODEL=anthropic/claude-3-haiku
|
120 |
+
|
121 |
+
# 其他設定
|
122 |
+
DEBUG=False
|
123 |
+
LOG_LEVEL=INFO
|
124 |
+
```
|
125 |
+
|
126 |
+
### 本地開發設定
|
127 |
+
|
128 |
+
建立 `.env` 檔案:
|
129 |
+
|
130 |
+
```bash
|
131 |
+
cp .env.example .env
|
132 |
+
```
|
133 |
+
|
134 |
+
編輯 `.env` 檔案並填入您的 API Keys。
|
135 |
+
|
136 |
+
## ✅ 驗證設定
|
137 |
+
|
138 |
+
### 1. 測試 LINE Bot
|
139 |
+
```bash
|
140 |
+
# 檢查 LINE Channel 設定
|
141 |
+
curl -H "Authorization: Bearer YOUR_CHANNEL_ACCESS_TOKEN" \
|
142 |
+
https://api.line.me/v2/bot/info
|
143 |
+
```
|
144 |
+
|
145 |
+
### 2. 測試 Supabase
|
146 |
+
```bash
|
147 |
+
# 檢查 Supabase 連線
|
148 |
+
curl "YOUR_SUPABASE_URL/rest/v1/" \
|
149 |
+
-H "apikey: YOUR_SUPABASE_KEY"
|
150 |
+
```
|
151 |
+
|
152 |
+
### 3. 測試 OpenRouter
|
153 |
+
```bash
|
154 |
+
# 檢查 OpenRouter API
|
155 |
+
curl https://openrouter.ai/api/v1/models \
|
156 |
+
-H "Authorization: Bearer YOUR_OPENROUTER_KEY"
|
157 |
+
```
|
158 |
+
|
159 |
+
## 🔒 安全性注意事項
|
160 |
+
|
161 |
+
1. **絕不在程式碼中硬編碼 API Keys**
|
162 |
+
2. **使用環境變數儲存敏感資訊**
|
163 |
+
3. **定期輪換 API Keys**
|
164 |
+
4. **監控 API 使用量和成本**
|
165 |
+
5. **設定適當的權限和限制**
|
166 |
+
|
167 |
+
## 💰 成本估算
|
168 |
+
|
169 |
+
### LINE Bot
|
170 |
+
- **免費額度**: 每月 1,000 則訊息
|
171 |
+
- **付費方案**: 超過免費額度後按量計費
|
172 |
+
|
173 |
+
### Supabase
|
174 |
+
- **免費方案**:
|
175 |
+
- 500MB 資料庫
|
176 |
+
- 50MB 檔案儲存
|
177 |
+
- 2GB 頻寬
|
178 |
+
- **Pro 方案**: $25/月起
|
179 |
+
|
180 |
+
### OpenRouter
|
181 |
+
- **按使用量計費**
|
182 |
+
- **Claude 3 Haiku**: ~$0.25/1M input tokens
|
183 |
+
- **建議預算**: $5-20/月 (中小型應用)
|
184 |
+
|
185 |
+
## 🆘 常見問題
|
186 |
+
|
187 |
+
### Q: LINE Bot 無法回應訊息
|
188 |
+
A: 檢查以下項目:
|
189 |
+
1. Channel Access Token 是否正確
|
190 |
+
2. Webhook URL 是否設定正確
|
191 |
+
3. 應用程式是否正常運行
|
192 |
+
|
193 |
+
### Q: Supabase 連線失敗
|
194 |
+
A: 檢查以下項目:
|
195 |
+
1. URL 和 API Key 是否正確
|
196 |
+
2. 資料表是否已建立
|
197 |
+
3. 網路連線是否正常
|
198 |
+
|
199 |
+
### Q: OpenRouter 回應錯誤
|
200 |
+
A: 檢查以下項目:
|
201 |
+
1. API Key 是否有效
|
202 |
+
2. 帳戶餘額是否充足
|
203 |
+
3. 模型名稱是否正確
|
204 |
+
|
205 |
+
## 📞 技術支援
|
206 |
+
|
207 |
+
如果您在設定過程中遇到問題:
|
208 |
+
|
209 |
+
1. **LINE Bot**: [LINE Developers 文件](https://developers.line.biz/en/docs/)
|
210 |
+
2. **Supabase**: [Supabase 文件](https://supabase.com/docs)
|
211 |
+
3. **OpenRouter**: [OpenRouter 文件](https://openrouter.ai/docs)
|
212 |
+
|
213 |
+
---
|
214 |
+
|
215 |
+
**提醒**: 請妥善保管您的 API Keys,避免洩露給他人使用。
|
BUSINESS_QUERY_GUIDE.md
ADDED
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 業務查詢系統使用指南
|
2 |
+
|
3 |
+
## 概述
|
4 |
+
|
5 |
+
本系統為您的 LINE 官方帳號提供了智能業務查詢功能,用戶可以透過自然語言詢問商品、庫存、訂單等業務資訊。系統整合了 Pydantic AI 進行語意分析,並提供友善的中文回應。
|
6 |
+
|
7 |
+
## 系統架構
|
8 |
+
|
9 |
+
```
|
10 |
+
LINE 用戶訊息 → LINE Bot Service → Business Query Service → NLP Service + Database Service → 回應用戶
|
11 |
+
```
|
12 |
+
|
13 |
+
### 核心組件
|
14 |
+
|
15 |
+
1. **NLP Service** (`backend/services/nlp_service.py`)
|
16 |
+
- 自然語言處理和意圖識別
|
17 |
+
- 實體提取和信心度計算
|
18 |
+
- 支援業務相關的查詢模式
|
19 |
+
|
20 |
+
2. **Database Service** (`backend/services/database_service.py`)
|
21 |
+
- 資料庫查詢操作
|
22 |
+
- 業務專用查詢方法
|
23 |
+
- 支援商品、庫存、訂單等查詢
|
24 |
+
|
25 |
+
3. **Business Query Service** (`backend/services/business_query_service.py`)
|
26 |
+
- 整合 NLP 和資料庫服務
|
27 |
+
- 統一的查詢處理入口
|
28 |
+
- 格式化回應訊息
|
29 |
+
|
30 |
+
4. **LINE Bot Service** (`backend/services/line_bot_service.py`)
|
31 |
+
- 處理 LINE 平台的訊息
|
32 |
+
- 用戶管理和對話流程
|
33 |
+
- 快速回覆按鈕生成
|
34 |
+
|
35 |
+
## 支援的查詢類型
|
36 |
+
|
37 |
+
### 1. 商品查詢
|
38 |
+
**用戶可以這樣問:**
|
39 |
+
- "查詢商品 iPhone"
|
40 |
+
- "有什麼筆記型電腦"
|
41 |
+
- "商品價格查詢"
|
42 |
+
- "找找看有沒有滑鼠"
|
43 |
+
|
44 |
+
**系統回應範例:**
|
45 |
+
```
|
46 |
+
找到商品:
|
47 |
+
名稱:iPhone 14 Pro
|
48 |
+
描述:最新款智慧型手機
|
49 |
+
價格:$35,900
|
50 |
+
類別:手機
|
51 |
+
```
|
52 |
+
|
53 |
+
### 2. 庫存查詢
|
54 |
+
**用戶可以這樣問:**
|
55 |
+
- "庫存查詢 iPhone"
|
56 |
+
- "筆記型電腦還有多少"
|
57 |
+
- "查詢存貨狀況"
|
58 |
+
- "iPhone 的庫存"
|
59 |
+
|
60 |
+
**系統回應範例:**
|
61 |
+
```
|
62 |
+
庫存資訊:
|
63 |
+
商品:iPhone 14 Pro
|
64 |
+
目前庫存:25 件
|
65 |
+
類別:手機
|
66 |
+
價格:$35,900
|
67 |
+
```
|
68 |
+
|
69 |
+
### 3. 訂單查詢
|
70 |
+
**用戶可以這樣問:**
|
71 |
+
- "我的訂單"
|
72 |
+
- "查詢訂單狀態"
|
73 |
+
- "訂單編號 ORD001"
|
74 |
+
- "購買記錄"
|
75 |
+
|
76 |
+
**系統回應範例:**
|
77 |
+
```
|
78 |
+
找到 3 筆訂單:
|
79 |
+
1. ORD001 - 已出貨 - $15,000
|
80 |
+
2. ORD002 - 處理中 - $8,500
|
81 |
+
3. ORD003 - 已完成 - $12,300
|
82 |
+
```
|
83 |
+
|
84 |
+
### 4. 低庫存警告
|
85 |
+
**用戶可以這樣問:**
|
86 |
+
- "低庫存商品"
|
87 |
+
- "缺貨商品查詢"
|
88 |
+
- "庫存不足的商品"
|
89 |
+
|
90 |
+
**系統回應範例:**
|
91 |
+
```
|
92 |
+
⚠️ 發現 3 個低庫存商品:
|
93 |
+
1. iPhone 13 - 剩餘:5 件
|
94 |
+
2. MacBook Air - 剩餘:2 件
|
95 |
+
3. AirPods Pro - 剩餘:8 件
|
96 |
+
```
|
97 |
+
|
98 |
+
### 5. 業務統計
|
99 |
+
**用戶可以這樣問:**
|
100 |
+
- "業務摘要"
|
101 |
+
- "統計報表"
|
102 |
+
- "總計資料"
|
103 |
+
- "業務狀況"
|
104 |
+
|
105 |
+
**系統回應範例:**
|
106 |
+
```
|
107 |
+
📊 業務摘要:
|
108 |
+
商品總數:156 個
|
109 |
+
訂單總數:89 筆
|
110 |
+
用戶總數:45 人
|
111 |
+
低庫存商品:3 個
|
112 |
+
統計時間:2024-01-15T10:30:00
|
113 |
+
```
|
114 |
+
|
115 |
+
## 特殊指令
|
116 |
+
|
117 |
+
### 幫助指令
|
118 |
+
- `help`, `幫助`, `說明`, `指令`
|
119 |
+
|
120 |
+
### 選單指令
|
121 |
+
- `menu`, `選單`, `功能`
|
122 |
+
|
123 |
+
## 快速開始
|
124 |
+
|
125 |
+
### 1. 測試業務查詢功能
|
126 |
+
```bash
|
127 |
+
python tmp_rovodev_test_business_query.py
|
128 |
+
```
|
129 |
+
|
130 |
+
### 2. 測試 LINE Bot 整合
|
131 |
+
```bash
|
132 |
+
python tmp_rovodev_test_line_integration.py
|
133 |
+
```
|
134 |
+
|
135 |
+
### 3. 在您的應用中使用
|
136 |
+
|
137 |
+
```python
|
138 |
+
from backend.services.line_bot_service import LineBotService
|
139 |
+
|
140 |
+
# 初始化服務
|
141 |
+
line_service = LineBotService()
|
142 |
+
|
143 |
+
# 處理用戶訊息
|
144 |
+
response = line_service.handle_text_message(
|
145 |
+
user_id="U1234567890",
|
146 |
+
message_text="查詢商品 iPhone",
|
147 |
+
display_name="張小明"
|
148 |
+
)
|
149 |
+
|
150 |
+
print(response['text']) # 系統回應
|
151 |
+
```
|
152 |
+
|
153 |
+
## 自訂擴展
|
154 |
+
|
155 |
+
### 1. 新增查詢類型
|
156 |
+
|
157 |
+
在 `nlp_service.py` 中新增意圖模式:
|
158 |
+
|
159 |
+
```python
|
160 |
+
self.business_intent_patterns = {
|
161 |
+
# 現有模式...
|
162 |
+
"new_query_type": [
|
163 |
+
r"新查詢.*模式",
|
164 |
+
r"特殊.*查詢"
|
165 |
+
]
|
166 |
+
}
|
167 |
+
```
|
168 |
+
|
169 |
+
在 `database_service.py` 中新增對應方法:
|
170 |
+
|
171 |
+
```python
|
172 |
+
def new_query_method(self, param1: str = None) -> DatabaseResult:
|
173 |
+
"""新的查詢方法"""
|
174 |
+
# 實作查詢邏輯
|
175 |
+
pass
|
176 |
+
```
|
177 |
+
|
178 |
+
### 2. 自訂回應格式
|
179 |
+
|
180 |
+
在 `nlp_service.py` 中修改格式化方法:
|
181 |
+
|
182 |
+
```python
|
183 |
+
def _format_custom_response(self, data: List[Dict[str, Any]]) -> str:
|
184 |
+
"""自訂回應格式"""
|
185 |
+
# 實作自訂格式
|
186 |
+
pass
|
187 |
+
```
|
188 |
+
|
189 |
+
### 3. 整合其他資料源
|
190 |
+
|
191 |
+
您可以修改 `database_service.py` 來連接其他資料庫或 API:
|
192 |
+
|
193 |
+
```python
|
194 |
+
def search_external_data(self, query: str) -> DatabaseResult:
|
195 |
+
"""查詢外部資料源"""
|
196 |
+
# 連接外部 API 或資料庫
|
197 |
+
pass
|
198 |
+
```
|
199 |
+
|
200 |
+
## 注意事項
|
201 |
+
|
202 |
+
1. **資料庫結構**: 目前的實作基於現有的 `Product`, `Order`, `User` 模型。如果您的資料庫結構不同,請相應調整查詢方法。
|
203 |
+
|
204 |
+
2. **權限控制**: 系統目前允許所有查詢。在生產環境中,建議實作適當的權限控制。
|
205 |
+
|
206 |
+
3. **效能考量**: 對於大量資料,建議加入分頁和快取機制。
|
207 |
+
|
208 |
+
4. **錯誤處理**: 系統已包含基本的錯誤處理,但建議根據實際需求進一步完善。
|
209 |
+
|
210 |
+
## 參考資料
|
211 |
+
|
212 |
+
- `another_proj_schemas.py`: 包含完整的業務模型定義
|
213 |
+
- `backend/database/models.py`: 資料庫模型定義
|
214 |
+
- `backend/models/schemas.py`: API 回應模型定義
|
215 |
+
|
216 |
+
## 技術支援
|
217 |
+
|
218 |
+
如需��術支援或功能擴展,請參考:
|
219 |
+
1. 測試腳本中的使用範例
|
220 |
+
2. 各服務類別的文檔字串
|
221 |
+
3. 錯誤日誌輸出
|
222 |
+
|
223 |
+
---
|
224 |
+
|
225 |
+
**注意**: 這是一個基礎實作,您可以根據實際業務需求進行擴展和優化。
|
Dockerfile
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.12-slim
|
2 |
+
|
3 |
+
RUN useradd -m -u 1000 user
|
4 |
+
USER user
|
5 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
6 |
+
|
7 |
+
WORKDIR /app
|
8 |
+
|
9 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
10 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
11 |
+
|
12 |
+
COPY --chown=user . /app
|
13 |
+
|
14 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
OPENROUTER_INTEGRATION.md
ADDED
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 🚀 OpenRouter 整合文件
|
2 |
+
|
3 |
+
## 📋 概述
|
4 |
+
|
5 |
+
本專案已整合 OpenRouter API 來提供進階的自然語言處理功能,包括:
|
6 |
+
- 更精確的意圖識別
|
7 |
+
- 智能實體提取
|
8 |
+
- 自然的回應生成
|
9 |
+
|
10 |
+
## 🔧 設定方式
|
11 |
+
|
12 |
+
### 1. 取得 OpenRouter API Key
|
13 |
+
|
14 |
+
1. 前往 [OpenRouter 官網](https://openrouter.ai/)
|
15 |
+
2. 註冊帳號並登入
|
16 |
+
3. 前往 [API Keys 頁面](https://openrouter.ai/keys)
|
17 |
+
4. 建立新的 API Key
|
18 |
+
5. 複製 API Key 備用
|
19 |
+
|
20 |
+
### 2. 環境變數設定
|
21 |
+
|
22 |
+
在 Hugging Face Spaces 的 Settings 中新增以下環境變數:
|
23 |
+
|
24 |
+
```bash
|
25 |
+
# 必要設定
|
26 |
+
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
27 |
+
|
28 |
+
# 可選設定 (預設值已設定)
|
29 |
+
OPENROUTER_MODEL=anthropic/claude-3-haiku
|
30 |
+
```
|
31 |
+
|
32 |
+
### 3. 支援的模型
|
33 |
+
|
34 |
+
OpenRouter 支援多種 AI 模型,您可以根據需求選擇:
|
35 |
+
|
36 |
+
#### 推薦模型 (成本效益佳)
|
37 |
+
- `anthropic/claude-3-haiku` (預設) - 快速、便宜
|
38 |
+
- `openai/gpt-3.5-turbo` - 平衡性能與成本
|
39 |
+
- `meta-llama/llama-2-70b-chat` - 開源選項
|
40 |
+
|
41 |
+
#### 高性能模型
|
42 |
+
- `anthropic/claude-3-sonnet` - 更好的理解能力
|
43 |
+
- `openai/gpt-4` - 最佳性能
|
44 |
+
- `anthropic/claude-3-opus` - 最高品質
|
45 |
+
|
46 |
+
#### 設定範例
|
47 |
+
```bash
|
48 |
+
# 使用 GPT-3.5 Turbo
|
49 |
+
OPENROUTER_MODEL=openai/gpt-3.5-turbo
|
50 |
+
|
51 |
+
# 使用 Claude 3 Sonnet
|
52 |
+
OPENROUTER_MODEL=anthropic/claude-3-sonnet
|
53 |
+
```
|
54 |
+
|
55 |
+
## 🎯 功能特色
|
56 |
+
|
57 |
+
### 1. 進階意圖識別
|
58 |
+
|
59 |
+
OpenRouter 整合後,系統能更準確地識別用戶意圖:
|
60 |
+
|
61 |
+
```python
|
62 |
+
# 範例輸入
|
63 |
+
"幫我找一下價格在一千到五千之間的手機"
|
64 |
+
|
65 |
+
# 基礎 NLP (規則引擎)
|
66 |
+
{
|
67 |
+
"intent": "search_product",
|
68 |
+
"confidence": 0.6,
|
69 |
+
"entities": {"min_price": 1000, "max_price": 5000}
|
70 |
+
}
|
71 |
+
|
72 |
+
# 進階 NLP (OpenRouter)
|
73 |
+
{
|
74 |
+
"intent": "search_product",
|
75 |
+
"confidence": 0.9,
|
76 |
+
"entities": {
|
77 |
+
"min_price": 1000,
|
78 |
+
"max_price": 5000,
|
79 |
+
"category": "手機",
|
80 |
+
"product_type": "電子產品"
|
81 |
+
}
|
82 |
+
}
|
83 |
+
```
|
84 |
+
|
85 |
+
### 2. 智能回應生成
|
86 |
+
|
87 |
+
系統會根據查詢結果生成更自然的回應:
|
88 |
+
|
89 |
+
```python
|
90 |
+
# 基礎回應
|
91 |
+
"找到 3 筆商品資料。"
|
92 |
+
|
93 |
+
# 進階回應 (OpenRouter)
|
94 |
+
"我為您找到了 3 款符合條件的手機:
|
95 |
+
1. iPhone 15 Pro - NT$ 35,900
|
96 |
+
2. Samsung Galaxy S24 - NT$ 28,900
|
97 |
+
3. Google Pixel 8 - NT$ 24,900
|
98 |
+
|
99 |
+
這些都在您的預算範圍內,您想了解哪一款的詳細資訊呢?"
|
100 |
+
```
|
101 |
+
|
102 |
+
## 🔄 降級機制
|
103 |
+
|
104 |
+
系統具有完整的降級機制:
|
105 |
+
|
106 |
+
1. **優先使用 OpenRouter**: 如果 API Key 可用且正常運作
|
107 |
+
2. **自動降級**: 如果 OpenRouter 失敗,自動使用基礎規則引擎
|
108 |
+
3. **錯誤處理**: 完整的錯誤日誌和異常處理
|
109 |
+
|
110 |
+
```python
|
111 |
+
# 在程式碼中的實作
|
112 |
+
def analyze_message(self, message: str, use_advanced: bool = True):
|
113 |
+
if use_advanced and self.openrouter_service.api_key:
|
114 |
+
try:
|
115 |
+
# 嘗試使用 OpenRouter
|
116 |
+
return advanced_analysis
|
117 |
+
except Exception as e:
|
118 |
+
logger.warning(f"進階 NLP 分析失敗,使用基礎分析: {str(e)}")
|
119 |
+
|
120 |
+
# 降級到基礎規則引擎
|
121 |
+
return basic_analysis
|
122 |
+
```
|
123 |
+
|
124 |
+
## 💰 成本考量
|
125 |
+
|
126 |
+
### 模型成本比較 (每 1M tokens)
|
127 |
+
|
128 |
+
| 模型 | 輸入成本 | 輸出成本 | 適用場景 |
|
129 |
+
|------|----------|----------|----------|
|
130 |
+
| claude-3-haiku | $0.25 | $1.25 | 日常查詢 |
|
131 |
+
| gpt-3.5-turbo | $0.50 | $1.50 | 平衡使用 |
|
132 |
+
| claude-3-sonnet | $3.00 | $15.00 | 複雜查詢 |
|
133 |
+
| gpt-4 | $10.00 | $30.00 | 高精度需求 |
|
134 |
+
|
135 |
+
### 成本優化建議
|
136 |
+
|
137 |
+
1. **使用 claude-3-haiku** 作為預設模型 (成本最低)
|
138 |
+
2. **設定合理的 token 限制** (max_tokens: 300-500)
|
139 |
+
3. **監控使用量** 透過 OpenRouter Dashboard
|
140 |
+
4. **考慮快取機制** 對常見查詢進行快取
|
141 |
+
|
142 |
+
## 🛠️ 開發與測試
|
143 |
+
|
144 |
+
### 本地測試
|
145 |
+
|
146 |
+
```bash
|
147 |
+
# 設定環境變數
|
148 |
+
export OPENROUTER_API_KEY="your_key_here"
|
149 |
+
export OPENROUTER_MODEL="anthropic/claude-3-haiku"
|
150 |
+
|
151 |
+
# 執行測試
|
152 |
+
python test_api.py
|
153 |
+
```
|
154 |
+
|
155 |
+
### API 測試範例
|
156 |
+
|
157 |
+
```python
|
158 |
+
# 測試進階 NLP
|
159 |
+
from backend.services.openrouter_service import OpenRouterService
|
160 |
+
|
161 |
+
service = OpenRouterService()
|
162 |
+
result = await service.analyze_intent_advanced("查詢用戶張三的訂單")
|
163 |
+
print(result)
|
164 |
+
```
|
165 |
+
|
166 |
+
## 🔍 監控與除錯
|
167 |
+
|
168 |
+
### 日誌監控
|
169 |
+
|
170 |
+
系統會記錄以下資訊:
|
171 |
+
|
172 |
+
```python
|
173 |
+
# 成功使用 OpenRouter
|
174 |
+
logger.info("使用 OpenRouter 進行進階分析")
|
175 |
+
|
176 |
+
# 降級到基礎引擎
|
177 |
+
logger.warning("進階 NLP 分析失敗,使用基礎分析")
|
178 |
+
|
179 |
+
# API 錯誤
|
180 |
+
logger.error("OpenRouter API 錯誤: 401 Unauthorized")
|
181 |
+
```
|
182 |
+
|
183 |
+
### 常見問題
|
184 |
+
|
185 |
+
1. **API Key 無效**
|
186 |
+
- 檢查 API Key 是否正確
|
187 |
+
- 確認 OpenRouter 帳戶餘額
|
188 |
+
|
189 |
+
2. **模型不存在**
|
190 |
+
- 檢查模型名稱是否正確
|
191 |
+
- 參考 OpenRouter 模型列表
|
192 |
+
|
193 |
+
3. **請求超時**
|
194 |
+
- 檢查網路連線
|
195 |
+
- 考慮增加 timeout 設定
|
196 |
+
|
197 |
+
## 📈 效能優化
|
198 |
+
|
199 |
+
### 1. 異步處理
|
200 |
+
|
201 |
+
```python
|
202 |
+
# 使用 asyncio 避免阻塞
|
203 |
+
async def analyze_intent_advanced(self, message: str):
|
204 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
205 |
+
response = await client.post(...)
|
206 |
+
```
|
207 |
+
|
208 |
+
### 2. 錯誤重試
|
209 |
+
|
210 |
+
```python
|
211 |
+
# 實作重試機制
|
212 |
+
for attempt in range(3):
|
213 |
+
try:
|
214 |
+
result = await openrouter_request()
|
215 |
+
break
|
216 |
+
except Exception as e:
|
217 |
+
if attempt == 2:
|
218 |
+
raise e
|
219 |
+
await asyncio.sleep(1)
|
220 |
+
```
|
221 |
+
|
222 |
+
### 3. 快取策略
|
223 |
+
|
224 |
+
```python
|
225 |
+
# 快取常見查詢結果
|
226 |
+
@lru_cache(maxsize=100)
|
227 |
+
def cached_analysis(message_hash: str):
|
228 |
+
return analysis_result
|
229 |
+
```
|
230 |
+
|
231 |
+
## 🚀 進階功能
|
232 |
+
|
233 |
+
### 1. 自訂提示詞
|
234 |
+
|
235 |
+
您可以修改 `openrouter_service.py` 中的提示詞來優化特定領域的表現:
|
236 |
+
|
237 |
+
```python
|
238 |
+
def _build_analysis_prompt(self, message: str):
|
239 |
+
# 自訂您的提示詞
|
240 |
+
prompt = f"""
|
241 |
+
您是一個專業的電商客服助手...
|
242 |
+
"""
|
243 |
+
```
|
244 |
+
|
245 |
+
### 2. 多語言支援
|
246 |
+
|
247 |
+
```python
|
248 |
+
# 支援多語言分析
|
249 |
+
def analyze_message_multilingual(self, message: str, language: str = "zh"):
|
250 |
+
prompt = self._build_multilingual_prompt(message, language)
|
251 |
+
```
|
252 |
+
|
253 |
+
### 3. 上下文記憶
|
254 |
+
|
255 |
+
```python
|
256 |
+
# 維護對話上下文
|
257 |
+
def analyze_with_context(self, message: str, conversation_history: List[str]):
|
258 |
+
context = {"history": conversation_history}
|
259 |
+
return await self.analyze_intent_advanced(message, context)
|
260 |
+
```
|
261 |
+
|
262 |
+
## 📞 技術支援
|
263 |
+
|
264 |
+
如果您在整合 OpenRouter 時遇到問題:
|
265 |
+
|
266 |
+
1. 檢查 [OpenRouter 文件](https://openrouter.ai/docs)
|
267 |
+
2. 查看系統日誌檔案
|
268 |
+
3. 參考本專案的 `test_api.py` 進行測試
|
269 |
+
4. 確認環境變數設定正確
|
270 |
+
|
271 |
+
---
|
272 |
+
|
273 |
+
**注意**: OpenRouter 是付費服務,請根據您的使用量選擇適合的模型和方案。
|
POSTGRESQL_MIGRATION_GUIDE.md
ADDED
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 🔄 PostgreSQL 直連遷移指南
|
2 |
+
|
3 |
+
本文件說明如何從 Supabase SDK 遷移到 PostgreSQL 直連的詳細步驟和注意事項。
|
4 |
+
|
5 |
+
## 📋 遷移概述
|
6 |
+
|
7 |
+
### 變更內容
|
8 |
+
- ✅ 移除 Supabase Python SDK 依賴
|
9 |
+
- ✅ 新增 SQLAlchemy + psycopg 直連
|
10 |
+
- ✅ 重構資料庫服務層
|
11 |
+
- ✅ 新增 ORM 模型定義
|
12 |
+
- ✅ 支援 Alembic 資料庫遷移
|
13 |
+
|
14 |
+
### 優勢
|
15 |
+
- 🚀 **更好的效能**: 直連減少中間層開銷
|
16 |
+
- 🔧 **更多控制**: 完整的 SQL 查詢控制
|
17 |
+
- 📊 **ORM 支援**: SQLAlchemy 提供強大的 ORM 功能
|
18 |
+
- 🔄 **遷移管理**: Alembic 提供版本化的資料庫遷移
|
19 |
+
- 🛡️ **連線池**: 自動連線池管理
|
20 |
+
|
21 |
+
## 🔑 環境變數更新
|
22 |
+
|
23 |
+
### 舊的 Supabase 設定
|
24 |
+
```bash
|
25 |
+
SUPABASE_URL=https://your-project.supabase.co
|
26 |
+
SUPABASE_KEY=your-anon-key
|
27 |
+
```
|
28 |
+
|
29 |
+
### 新的 PostgreSQL 設定
|
30 |
+
```bash
|
31 |
+
DB_HOST=db.your-project.supabase.co
|
32 |
+
DB_PORT=6543
|
33 |
+
DB_NAME=postgres
|
34 |
+
DB_USER=postgres
|
35 |
+
DB_PASSWORD=your-database-password
|
36 |
+
```
|
37 |
+
|
38 |
+
## 🔍 如何取得 PostgreSQL 連線資訊
|
39 |
+
|
40 |
+
### 從 Supabase Dashboard 取得
|
41 |
+
|
42 |
+
1. **登入 Supabase Dashboard**
|
43 |
+
- 前往 [Supabase Dashboard](https://supabase.com/dashboard)
|
44 |
+
- 選擇您的專案
|
45 |
+
|
46 |
+
2. **取得連線資訊**
|
47 |
+
- 前往 "Settings" → "Database"
|
48 |
+
- 在 "Connection info" 區域找到:
|
49 |
+
- **Host**: `db.your-project-ref.supabase.co`
|
50 |
+
- **Port**: `6543` (PostgreSQL 直連埠)
|
51 |
+
- **Database**: `postgres`
|
52 |
+
- **User**: `postgres`
|
53 |
+
- **Password**: 您設定的資料庫密碼
|
54 |
+
|
55 |
+
3. **連線字串範例**
|
56 |
+
```
|
57 |
+
postgresql://postgres:your-password@db.your-project-ref.supabase.co:6543/postgres
|
58 |
+
```
|
59 |
+
|
60 |
+
### 從其他 PostgreSQL 提供商取得
|
61 |
+
|
62 |
+
如果您使用其他 PostgreSQL 服務:
|
63 |
+
- **AWS RDS**: 在 RDS Console 中查看端點資訊
|
64 |
+
- **Google Cloud SQL**: 在 Cloud Console 中查看連線詳情
|
65 |
+
- **Azure Database**: 在 Azure Portal 中查看連線字串
|
66 |
+
- **Railway/Render**: 在專案設定中查看資料庫資訊
|
67 |
+
|
68 |
+
## 🛠️ 部署步驟
|
69 |
+
|
70 |
+
### 1. 更新環境變數
|
71 |
+
|
72 |
+
在 Hugging Face Spaces 的 Settings 中更新:
|
73 |
+
|
74 |
+
```bash
|
75 |
+
# 移除舊的 Supabase 設定
|
76 |
+
# SUPABASE_URL=...
|
77 |
+
# SUPABASE_KEY=...
|
78 |
+
|
79 |
+
# 新增 PostgreSQL 設定
|
80 |
+
DB_HOST=db.your-project-ref.supabase.co
|
81 |
+
DB_PORT=6543
|
82 |
+
DB_NAME=postgres
|
83 |
+
DB_USER=postgres
|
84 |
+
DB_PASSWORD=your-database-password
|
85 |
+
|
86 |
+
# 其他設定保持不變
|
87 |
+
LINE_CHANNEL_ACCESS_TOKEN=your-line-token
|
88 |
+
LINE_CHANNEL_SECRET=your-line-secret
|
89 |
+
OPENROUTER_API_KEY=your-openrouter-key
|
90 |
+
OPENROUTER_MODEL=anthropic/claude-3-haiku
|
91 |
+
```
|
92 |
+
|
93 |
+
### 2. 初始化資料庫
|
94 |
+
|
95 |
+
部署後,執行資料庫初始化:
|
96 |
+
|
97 |
+
```bash
|
98 |
+
# 在容器中執行
|
99 |
+
python -m backend.database.init_db
|
100 |
+
```
|
101 |
+
|
102 |
+
或者透過 API 端點觸發初始化(如果實作了相關端點)。
|
103 |
+
|
104 |
+
### 3. 驗證連線
|
105 |
+
|
106 |
+
檢查健康檢查端點:
|
107 |
+
```bash
|
108 |
+
curl https://your-space-url.hf.space/health
|
109 |
+
```
|
110 |
+
|
111 |
+
應該會看到:
|
112 |
+
```json
|
113 |
+
{
|
114 |
+
"status": "healthy",
|
115 |
+
"message": "LINE Bot API is operational",
|
116 |
+
"database": "connected",
|
117 |
+
"database_url": "postgresql://your-host:6543/postgres"
|
118 |
+
}
|
119 |
+
```
|
120 |
+
|
121 |
+
## 📊 資料庫模型對應
|
122 |
+
|
123 |
+
### 新的 SQLAlchemy 模型
|
124 |
+
|
125 |
+
```python
|
126 |
+
# backend/database/models.py
|
127 |
+
|
128 |
+
class User(Base):
|
129 |
+
__tablename__ = "users"
|
130 |
+
user_id = Column(String(255), primary_key=True)
|
131 |
+
name = Column(String(255))
|
132 |
+
email = Column(String(255))
|
133 |
+
# ... 其他欄位
|
134 |
+
|
135 |
+
class Product(Base):
|
136 |
+
__tablename__ = "products"
|
137 |
+
product_id = Column(Integer, primary_key=True)
|
138 |
+
name = Column(String(255), nullable=False)
|
139 |
+
price = Column(DECIMAL(10, 2), nullable=False)
|
140 |
+
# ... 其他欄位
|
141 |
+
```
|
142 |
+
|
143 |
+
### 資料表關聯
|
144 |
+
|
145 |
+
```python
|
146 |
+
# 一對多關聯
|
147 |
+
class User(Base):
|
148 |
+
orders = relationship("Order", back_populates="user")
|
149 |
+
|
150 |
+
class Order(Base):
|
151 |
+
user = relationship("User", back_populates="orders")
|
152 |
+
order_items = relationship("OrderItem", back_populates="order")
|
153 |
+
```
|
154 |
+
|
155 |
+
## 🔄 API 變更
|
156 |
+
|
157 |
+
### 查詢方式變更
|
158 |
+
|
159 |
+
#### 舊的 Supabase 方式
|
160 |
+
```python
|
161 |
+
result = supabase.table("users").select("*").eq("name", "張三").execute()
|
162 |
+
```
|
163 |
+
|
164 |
+
#### 新的 SQLAlchemy 方式
|
165 |
+
```python
|
166 |
+
db = get_database_session()
|
167 |
+
users = db.query(User).filter(User.name == "張三").all()
|
168 |
+
```
|
169 |
+
|
170 |
+
### 錯誤處理改進
|
171 |
+
|
172 |
+
```python
|
173 |
+
def database_operation():
|
174 |
+
db = None
|
175 |
+
try:
|
176 |
+
db = get_database_session()
|
177 |
+
# 資料庫操作
|
178 |
+
result = db.query(User).all()
|
179 |
+
db.commit()
|
180 |
+
return result
|
181 |
+
except Exception as e:
|
182 |
+
if db:
|
183 |
+
db.rollback()
|
184 |
+
logger.error(f"資料庫操作失敗: {str(e)}")
|
185 |
+
raise
|
186 |
+
finally:
|
187 |
+
if db:
|
188 |
+
close_database_session(db)
|
189 |
+
```
|
190 |
+
|
191 |
+
## 🚀 效能優化
|
192 |
+
|
193 |
+
### 連線池設定
|
194 |
+
|
195 |
+
```python
|
196 |
+
# backend/database/connection.py
|
197 |
+
engine = create_engine(
|
198 |
+
settings.DATABASE_URL,
|
199 |
+
pool_size=10, # 連線池大小
|
200 |
+
max_overflow=20, # 最大溢出連線
|
201 |
+
pool_pre_ping=True, # 連線前檢查
|
202 |
+
pool_recycle=3600, # 1小時後回收連線
|
203 |
+
)
|
204 |
+
```
|
205 |
+
|
206 |
+
### 查詢優化
|
207 |
+
|
208 |
+
```python
|
209 |
+
# 使用 eager loading 避免 N+1 查詢
|
210 |
+
from sqlalchemy.orm import joinedload
|
211 |
+
|
212 |
+
users_with_orders = db.query(User).options(
|
213 |
+
joinedload(User.orders)
|
214 |
+
).all()
|
215 |
+
|
216 |
+
# 使用索引優化查詢
|
217 |
+
# 在模型中定義索引
|
218 |
+
class User(Base):
|
219 |
+
__tablename__ = "users"
|
220 |
+
name = Column(String(255), index=True) # 建立索引
|
221 |
+
```
|
222 |
+
|
223 |
+
## 🔧 開發工具
|
224 |
+
|
225 |
+
### Alembic 遷移
|
226 |
+
|
227 |
+
```bash
|
228 |
+
# 初始化 Alembic
|
229 |
+
alembic init alembic
|
230 |
+
|
231 |
+
# 建立遷移檔案
|
232 |
+
alembic revision --autogenerate -m "Initial migration"
|
233 |
+
|
234 |
+
# 執行遷移
|
235 |
+
alembic upgrade head
|
236 |
+
|
237 |
+
# 回滾遷移
|
238 |
+
alembic downgrade -1
|
239 |
+
```
|
240 |
+
|
241 |
+
### 本地開發
|
242 |
+
|
243 |
+
```bash
|
244 |
+
# 安裝依賴
|
245 |
+
pip install -r requirements.txt
|
246 |
+
|
247 |
+
# 設定環境變數
|
248 |
+
export DB_HOST=localhost
|
249 |
+
export DB_PORT=5432
|
250 |
+
export DB_NAME=linebot_dev
|
251 |
+
export DB_USER=postgres
|
252 |
+
export DB_PASSWORD=password
|
253 |
+
|
254 |
+
# 初始化資料庫
|
255 |
+
python -m backend.database.init_db
|
256 |
+
|
257 |
+
# 執行應用程式
|
258 |
+
uvicorn backend.main:app --reload --port 7860
|
259 |
+
```
|
260 |
+
|
261 |
+
## 🛡️ 安全性考量
|
262 |
+
|
263 |
+
### 連線安全
|
264 |
+
|
265 |
+
1. **使用 SSL 連線**
|
266 |
+
```python
|
267 |
+
DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}?sslmode=require"
|
268 |
+
```
|
269 |
+
|
270 |
+
2. **限制資料庫權限**
|
271 |
+
- 建立專用的應用程式使用者
|
272 |
+
- 只授予必要的資料表權限
|
273 |
+
- 定期輪換密碼
|
274 |
+
|
275 |
+
3. **連線字串保護**
|
276 |
+
- 使用環境變數儲存敏感資訊
|
277 |
+
- 避免在日誌中記錄連線字串
|
278 |
+
- 使用密碼管理工具
|
279 |
+
|
280 |
+
## 📈 監控與除錯
|
281 |
+
|
282 |
+
### 日誌設定
|
283 |
+
|
284 |
+
```python
|
285 |
+
# 啟用 SQLAlchemy 日誌
|
286 |
+
engine = create_engine(
|
287 |
+
settings.DATABASE_URL,
|
288 |
+
echo=settings.DEBUG, # 在 DEBUG 模式下顯示 SQL
|
289 |
+
)
|
290 |
+
```
|
291 |
+
|
292 |
+
### 效能監控
|
293 |
+
|
294 |
+
```python
|
295 |
+
import time
|
296 |
+
from functools import wraps
|
297 |
+
|
298 |
+
def monitor_db_performance(func):
|
299 |
+
@wraps(func)
|
300 |
+
def wrapper(*args, **kwargs):
|
301 |
+
start_time = time.time()
|
302 |
+
result = func(*args, **kwargs)
|
303 |
+
execution_time = time.time() - start_time
|
304 |
+
logger.info(f"{func.__name__} 執行時間: {execution_time:.2f}秒")
|
305 |
+
return result
|
306 |
+
return wrapper
|
307 |
+
```
|
308 |
+
|
309 |
+
## 🚨 常見問題
|
310 |
+
|
311 |
+
### Q: 連線失敗怎麼辦?
|
312 |
+
A: 檢查以下項目:
|
313 |
+
1. 主機名稱和埠號是否正確
|
314 |
+
2. 使用者名稱和密碼是否正確
|
315 |
+
3. 資料庫是否允許外部連線
|
316 |
+
4. 防火牆設定是否正確
|
317 |
+
|
318 |
+
### Q: 效能比 Supabase SDK 慢?
|
319 |
+
A: 可能原因:
|
320 |
+
1. 連線池設定不當
|
321 |
+
2. 查詢未優化
|
322 |
+
3. 缺少適當的索引
|
323 |
+
4. 網路延遲問題
|
324 |
+
|
325 |
+
### Q: 如何處理資料庫遷移?
|
326 |
+
A: 使用 Alembic:
|
327 |
+
1. 建立遷移腳本
|
328 |
+
2. 在部署前測試遷移
|
329 |
+
3. 備份資料庫
|
330 |
+
4. 執行遷移
|
331 |
+
|
332 |
+
## 📞 技術支援
|
333 |
+
|
334 |
+
如果遇到問題:
|
335 |
+
1. 檢查應用程式日誌
|
336 |
+
2. 驗證環境變數設定
|
337 |
+
3. 測試資料庫連線
|
338 |
+
4. 參考 SQLAlchemy 官方文件
|
339 |
+
|
340 |
+
---
|
341 |
+
|
342 |
+
**注意**: 遷移前請務必備份您的資料庫,並在測試環境中驗證所有功能正常運作。
|
README.md
CHANGED
@@ -1,12 +1,39 @@
|
|
1 |
---
|
2 |
title: Linebot Pydantic Fastapi
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
license: mit
|
9 |
-
short_description:
|
10 |
---
|
11 |
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
---
|
2 |
title: Linebot Pydantic Fastapi
|
3 |
+
emoji: 🤖
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: green
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
license: mit
|
9 |
+
short_description: LINE Bot with FastAPI, Pydantic, and Supabase for natural language database queries
|
10 |
---
|
11 |
|
12 |
+
# 🤖 LINE Bot + FastAPI + Pydantic + Supabase
|
13 |
+
|
14 |
+
智能 LINE Bot,支援自然語言查詢資料庫功能。
|
15 |
+
|
16 |
+
## ✨ 功能特色
|
17 |
+
|
18 |
+
- 🔍 **自然語言查詢**: 支援中文自然語言查詢資料庫
|
19 |
+
- 📊 **多種查詢類型**: 用戶、商品、訂單查詢及統計分析
|
20 |
+
- 🚀 **FastAPI 框架**: 高效能 Web API
|
21 |
+
- 📝 **Pydantic 驗證**: 強型別資料驗證
|
22 |
+
- 🗄️ **Supabase 資料庫**: PostgreSQL 雲端資料庫
|
23 |
+
- 💬 **LINE Bot 整合**: 完整的 LINE 訊息處理
|
24 |
+
|
25 |
+
## 🚀 快速開始
|
26 |
+
|
27 |
+
1. 設定環境變數(詳見 `setup_guide.md`)
|
28 |
+
2. 建立 Supabase 資料表
|
29 |
+
3. 設定 LINE Bot Webhook
|
30 |
+
4. 開始使用!
|
31 |
+
|
32 |
+
## 📖 使用範例
|
33 |
+
|
34 |
+
- "查詢用戶 張三"
|
35 |
+
- "找價格 1000 到 5000 的商品"
|
36 |
+
- "統計訂單數量"
|
37 |
+
- "我的訂單狀態"
|
38 |
+
|
39 |
+
詳細設定說明請參考 [setup_guide.md](./setup_guide.md)
|
alembic.ini
ADDED
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# A generic, single database configuration.
|
2 |
+
|
3 |
+
[alembic]
|
4 |
+
# path to migration scripts
|
5 |
+
script_location = alembic
|
6 |
+
|
7 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
8 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
9 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
10 |
+
|
11 |
+
# sys.path path, will be prepended to sys.path if present.
|
12 |
+
# defaults to the current working directory.
|
13 |
+
prepend_sys_path = .
|
14 |
+
|
15 |
+
# timezone to use when rendering the date within the migration file
|
16 |
+
# as well as the filename.
|
17 |
+
# If specified, requires the python-dateutil library that can be
|
18 |
+
# installed by adding `alembic[tz]` to the pip requirements
|
19 |
+
# string value is passed to dateutil.tz.gettz()
|
20 |
+
# leave blank for localtime
|
21 |
+
# timezone =
|
22 |
+
|
23 |
+
# max length of characters to apply to the
|
24 |
+
# "slug" field
|
25 |
+
# truncate_slug_length = 40
|
26 |
+
|
27 |
+
# set to 'true' to run the environment during
|
28 |
+
# the 'revision' command, regardless of autogenerate
|
29 |
+
# revision_environment = false
|
30 |
+
|
31 |
+
# set to 'true' to allow .pyc and .pyo files without
|
32 |
+
# a source .py file to be detected as revisions in the
|
33 |
+
# versions/ directory
|
34 |
+
# sourceless = false
|
35 |
+
|
36 |
+
# version number format. This value is passed to the
|
37 |
+
# "strftime" function from the Python standard library.
|
38 |
+
# see https://docs.python.org/library/datetime.html#strftime-strptime-behavior
|
39 |
+
# for a list of codes that can be used in this string.
|
40 |
+
# version_num_format = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d
|
41 |
+
|
42 |
+
# version path separator; As mentioned above, this is the character used to split
|
43 |
+
# version_locations. The default within new alembic.ini files is "os", which uses
|
44 |
+
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
|
45 |
+
# behavior of splitting on spaces and/or commas.
|
46 |
+
# Valid values for version_path_separator are:
|
47 |
+
#
|
48 |
+
# version_path_separator = :
|
49 |
+
# version_path_separator = ;
|
50 |
+
# version_path_separator = space
|
51 |
+
version_path_separator = os
|
52 |
+
|
53 |
+
# set to 'true' to search source files recursively
|
54 |
+
# in each "version_locations" directory
|
55 |
+
# new in Alembic version 1.10
|
56 |
+
# recursive_version_locations = false
|
57 |
+
|
58 |
+
# the output encoding used when revision files
|
59 |
+
# are written from script.py.mako
|
60 |
+
# output_encoding = utf-8
|
61 |
+
|
62 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
63 |
+
|
64 |
+
|
65 |
+
[post_write_hooks]
|
66 |
+
# post_write_hooks defines scripts or Python functions that are run
|
67 |
+
# on newly generated revision scripts. See the documentation for further
|
68 |
+
# detail and examples
|
69 |
+
|
70 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
71 |
+
# hooks = black
|
72 |
+
# black.type = console_scripts
|
73 |
+
# black.entrypoint = black
|
74 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
75 |
+
|
76 |
+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
77 |
+
# hooks = ruff
|
78 |
+
# ruff.type = exec
|
79 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
80 |
+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
81 |
+
|
82 |
+
# Logging configuration
|
83 |
+
[loggers]
|
84 |
+
keys = root,sqlalchemy,alembic
|
85 |
+
|
86 |
+
[handlers]
|
87 |
+
keys = console
|
88 |
+
|
89 |
+
[formatters]
|
90 |
+
keys = generic
|
91 |
+
|
92 |
+
[logger_root]
|
93 |
+
level = WARN
|
94 |
+
handlers = console
|
95 |
+
qualname =
|
96 |
+
|
97 |
+
[logger_sqlalchemy]
|
98 |
+
level = WARN
|
99 |
+
handlers =
|
100 |
+
qualname = sqlalchemy.engine
|
101 |
+
|
102 |
+
[logger_alembic]
|
103 |
+
level = INFO
|
104 |
+
handlers =
|
105 |
+
qualname = alembic
|
106 |
+
|
107 |
+
[handler_console]
|
108 |
+
class = StreamHandler
|
109 |
+
args = (sys.stderr,)
|
110 |
+
level = NOTSET
|
111 |
+
formatter = generic
|
112 |
+
|
113 |
+
[formatter_generic]
|
114 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
115 |
+
datefmt = %H:%M:%S
|
another_proj_schemas.py
ADDED
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, EmailStr, Field
|
2 |
+
from typing import List, Optional, Union
|
3 |
+
from datetime import datetime, date
|
4 |
+
import enum
|
5 |
+
|
6 |
+
# Import Enum types from models.py to be used in Pydantic schemas
|
7 |
+
# This avoids redefining them and ensures consistency.
|
8 |
+
# We might need to adjust models.py if enums are defined in a way that's hard to import directly
|
9 |
+
# For now, let's assume we can import them or we'll redefine for Pydantic if necessary.
|
10 |
+
# For simplicity in this step, I'll redefine them here. If issues arise, we can refactor.
|
11 |
+
|
12 |
+
class UserRole(str, enum.Enum):
|
13 |
+
ADMIN = "ADMIN"
|
14 |
+
USER = "USER"
|
15 |
+
|
16 |
+
class TransactionType(str, enum.Enum):
|
17 |
+
PURCHASE = "PURCHASE"
|
18 |
+
SELL = "SELL"
|
19 |
+
|
20 |
+
class TransactionStatus(str, enum.Enum):
|
21 |
+
PENDING = "PENDING"
|
22 |
+
PROCESSING = "PROCESSING" # Added PROCESSING status
|
23 |
+
COMPLETED = "COMPLETED"
|
24 |
+
CANCELLED = "CANCELLED"
|
25 |
+
|
26 |
+
# CustomerType 和 PaymentCategory 改為字串類型,支援動態值
|
27 |
+
# PaymentMethod 改為支援動態字串,如 "月結30天", "下收" 等
|
28 |
+
|
29 |
+
# Base and Read schemas for Category
|
30 |
+
class CategoryBase(BaseModel):
|
31 |
+
name: str
|
32 |
+
|
33 |
+
class CategoryCreate(CategoryBase):
|
34 |
+
pass
|
35 |
+
|
36 |
+
class Category(CategoryBase): # For Read operations
|
37 |
+
id: int
|
38 |
+
|
39 |
+
class Config:
|
40 |
+
from_attributes = True
|
41 |
+
|
42 |
+
# Base and Read schemas for Product - 根據客戶 Excel 欄位重新設計
|
43 |
+
class ProductBase(BaseModel):
|
44 |
+
productCode: str = Field(..., description="貨品編號,必須唯一")
|
45 |
+
productName: str = Field(..., description="貨品名稱")
|
46 |
+
unit: str = Field(..., description="單位(箱、盒等)")
|
47 |
+
warehouse: Optional[str] = None # 倉別
|
48 |
+
unitWeight: Optional[float] = None # 單位重量(KG)
|
49 |
+
barcode: Optional[str] = None # 條碼編號
|
50 |
+
category_id: int = Field(..., description="類別ID")
|
51 |
+
|
52 |
+
class ProductCreate(ProductBase):
|
53 |
+
pass
|
54 |
+
|
55 |
+
# For Read operations
|
56 |
+
class Product(ProductBase):
|
57 |
+
id: int
|
58 |
+
stock: int = Field(default=0, description="庫存數量") # 添加庫存欄位
|
59 |
+
is_deleted: bool = Field(default=False, description="是否已刪除")
|
60 |
+
deleted_at: Optional[datetime] = None
|
61 |
+
deleted_by: Optional[int] = None
|
62 |
+
createdAt: datetime
|
63 |
+
updatedAt: Optional[datetime] = None
|
64 |
+
category: Optional[Category] = None # 包含類別詳細資訊
|
65 |
+
|
66 |
+
class Config:
|
67 |
+
from_attributes = True
|
68 |
+
|
69 |
+
# Schemas for updating a product (all fields optional)
|
70 |
+
class ProductUpdate(BaseModel):
|
71 |
+
productCode: Optional[str] = None
|
72 |
+
productName: Optional[str] = None
|
73 |
+
unit: Optional[str] = None
|
74 |
+
warehouse: Optional[str] = None
|
75 |
+
unitWeight: Optional[float] = None
|
76 |
+
barcode: Optional[str] = None
|
77 |
+
category_id: Optional[int] = None
|
78 |
+
stock: Optional[int] = Field(None, ge=0, description="庫存數量,必須大於等於0") # 添加庫存更新
|
79 |
+
|
80 |
+
|
81 |
+
# Base and Read schemas for Supplier
|
82 |
+
class SupplierBase(BaseModel):
|
83 |
+
name: str
|
84 |
+
contactInfo: Optional[str] = None
|
85 |
+
address: Optional[str] = None
|
86 |
+
|
87 |
+
class SupplierCreate(SupplierBase):
|
88 |
+
pass
|
89 |
+
|
90 |
+
class Supplier(SupplierBase): # For Read operations
|
91 |
+
id: int
|
92 |
+
|
93 |
+
class Config:
|
94 |
+
from_attributes = True
|
95 |
+
|
96 |
+
# Base and Read schemas for Customer
|
97 |
+
class CustomerBase(BaseModel):
|
98 |
+
customerType: str
|
99 |
+
salesPersonId: Optional[str] = None
|
100 |
+
salesPersonName: Optional[str] = None
|
101 |
+
customerCode: str = Field(..., description="客戶編號,必須唯一")
|
102 |
+
customerName: str = Field(..., description="客戶名稱")
|
103 |
+
contactPerson: Optional[str] = None
|
104 |
+
invoiceTitle: Optional[str] = None
|
105 |
+
taxId: Optional[str] = None
|
106 |
+
phoneNumber: Optional[str] = None
|
107 |
+
faxNumber: Optional[str] = None
|
108 |
+
deliveryAddress: Optional[str] = None
|
109 |
+
businessHours: Optional[str] = None
|
110 |
+
paymentMethod: Optional[str] = None
|
111 |
+
paymentCategory: Optional[str] = None
|
112 |
+
creditLimit: Optional[float] = 0.0
|
113 |
+
|
114 |
+
class CustomerCreate(CustomerBase):
|
115 |
+
pass
|
116 |
+
|
117 |
+
class CustomerUpdate(BaseModel):
|
118 |
+
customerType: Optional[str] = None
|
119 |
+
salesPersonId: Optional[str] = None
|
120 |
+
salesPersonName: Optional[str] = None
|
121 |
+
customerCode: Optional[str] = None
|
122 |
+
customerName: Optional[str] = None
|
123 |
+
contactPerson: Optional[str] = None
|
124 |
+
invoiceTitle: Optional[str] = None
|
125 |
+
taxId: Optional[str] = None
|
126 |
+
phoneNumber: Optional[str] = None
|
127 |
+
faxNumber: Optional[str] = None
|
128 |
+
deliveryAddress: Optional[str] = None
|
129 |
+
businessHours: Optional[str] = None
|
130 |
+
paymentMethod: Optional[str] = None
|
131 |
+
paymentCategory: Optional[str] = None
|
132 |
+
creditLimit: Optional[float] = None
|
133 |
+
|
134 |
+
class Customer(CustomerBase): # For Read operations
|
135 |
+
id: int
|
136 |
+
createdDate: datetime
|
137 |
+
updatedAt: Optional[datetime] = None
|
138 |
+
|
139 |
+
class Config:
|
140 |
+
from_attributes = True
|
141 |
+
|
142 |
+
# Base and Read schemas for User
|
143 |
+
class UserBase(BaseModel):
|
144 |
+
name: Optional[str] = None
|
145 |
+
email: EmailStr
|
146 |
+
phoneNumber: Optional[str] = None
|
147 |
+
role: Optional[UserRole] = UserRole.USER
|
148 |
+
|
149 |
+
class UserCreate(UserBase):
|
150 |
+
password: str = Field(..., min_length=4) # Example: make password required on create
|
151 |
+
|
152 |
+
class UserUpdate(BaseModel): # For updating user profile
|
153 |
+
name: Optional[str] = None
|
154 |
+
email: Optional[EmailStr] = None
|
155 |
+
phoneNumber: Optional[str] = None
|
156 |
+
role: Optional[UserRole] = None
|
157 |
+
# Role and password updates might be handled by separate, more secure endpoints or admin functions
|
158 |
+
|
159 |
+
class User(UserBase): # For Read operations (e.g., /users/current)
|
160 |
+
id: int
|
161 |
+
createdAt: datetime
|
162 |
+
# Do not include password in responses
|
163 |
+
|
164 |
+
class Config:
|
165 |
+
from_attributes = True
|
166 |
+
|
167 |
+
|
168 |
+
# Schemas for TransactionProductAssociation (enhanced with pricing information)
|
169 |
+
class TransactionProductAssociationBase(BaseModel):
|
170 |
+
product_id: int
|
171 |
+
quantity: Optional[int] = 1
|
172 |
+
unit_price: Optional[float] = None
|
173 |
+
line_total: Optional[float] = None
|
174 |
+
notes: Optional[str] = None
|
175 |
+
|
176 |
+
class TransactionProductAssociationCreate(TransactionProductAssociationBase):
|
177 |
+
pass
|
178 |
+
|
179 |
+
class TransactionProductAssociation(TransactionProductAssociationBase): # For Read
|
180 |
+
# Include product details for complete information
|
181 |
+
product: Optional[Product] = None
|
182 |
+
|
183 |
+
class Config:
|
184 |
+
from_attributes = True
|
185 |
+
|
186 |
+
|
187 |
+
# Base and Read schemas for Transaction
|
188 |
+
class TransactionBase(BaseModel):
|
189 |
+
totalProducts: int
|
190 |
+
totalPrice: float
|
191 |
+
transactionType: TransactionType
|
192 |
+
transactionStatus: Optional[TransactionStatus] = TransactionStatus.PENDING
|
193 |
+
description: Optional[str] = None
|
194 |
+
note: Optional[str] = None
|
195 |
+
user_id: Optional[int] = None # Assuming user_id is set based on authenticated user
|
196 |
+
supplier_id: Optional[int] = None
|
197 |
+
customer_id: Optional[int] = None # For sell transactions
|
198 |
+
# For creating transactions, the frontend might send a list of products involved
|
199 |
+
# This needs to align with how api.service.ts sends data for purchase/sell
|
200 |
+
# For example: products_involved: List[TransactionProductAssociationCreate]
|
201 |
+
|
202 |
+
class TransactionCreate(TransactionBase):
|
203 |
+
# The frontend's purchaseProduct/sellProduct takes a 'body'. We need to match that structure.
|
204 |
+
# If 'body' contains a list of product IDs and quantities:
|
205 |
+
products_involved: List[TransactionProductAssociationCreate]
|
206 |
+
|
207 |
+
|
208 |
+
class Transaction(TransactionBase): # For Read operations
|
209 |
+
id: int
|
210 |
+
createdAt: datetime
|
211 |
+
updatedAt: Optional[datetime] = None
|
212 |
+
user: Optional[User] = None # Nested user details
|
213 |
+
supplier: Optional[Supplier] = None # Nested supplier details
|
214 |
+
customer: Optional[Customer] = None # Nested customer details for sell transactions
|
215 |
+
products: List[TransactionProductAssociation] # List of products involved in the transaction
|
216 |
+
|
217 |
+
class Config:
|
218 |
+
from_attributes = True
|
219 |
+
|
220 |
+
# Schema for updating transaction status (as per frontend api.service.ts)
|
221 |
+
class TransactionStatusUpdate(BaseModel):
|
222 |
+
status: TransactionStatus # Frontend sends JSON.stringify(status) - need to ensure this matches
|
223 |
+
|
224 |
+
# Schemas for Authentication
|
225 |
+
class Token(BaseModel):
|
226 |
+
access_token: str
|
227 |
+
token_type: str
|
228 |
+
|
229 |
+
class TokenData(BaseModel):
|
230 |
+
email: Optional[str] = None
|
231 |
+
|
232 |
+
class UserLogin(BaseModel):
|
233 |
+
email: EmailStr
|
234 |
+
password: str
|
235 |
+
|
236 |
+
|
237 |
+
# 採購單相關的枚舉類型
|
238 |
+
class PurchaseOrderStatus(str, enum.Enum):
|
239 |
+
DRAFT = "DRAFT" # 草稿
|
240 |
+
PENDING = "PENDING" # 待處理
|
241 |
+
CONFIRMED = "CONFIRMED" # 已確認
|
242 |
+
RECEIVED = "RECEIVED" # 已收貨
|
243 |
+
CANCELLED = "CANCELLED" # 已取消
|
244 |
+
|
245 |
+
class PaymentStatus(str, enum.Enum):
|
246 |
+
UNPAID = "UNPAID" # 未付款
|
247 |
+
PARTIAL = "PARTIAL" # 部分付款
|
248 |
+
PAID = "PAID" # 已付款
|
249 |
+
|
250 |
+
class TaxType(str, enum.Enum):
|
251 |
+
INCLUSIVE = "INCLUSIVE" # 含稅
|
252 |
+
EXCLUSIVE = "EXCLUSIVE" # 未稅
|
253 |
+
ADDITIONAL = "ADDITIONAL" # 外加稅
|
254 |
+
|
255 |
+
# 入庫單相關的枚舉類型
|
256 |
+
class GoodsReceiptStatus(str, enum.Enum):
|
257 |
+
DRAFT = "DRAFT" # 草稿
|
258 |
+
PENDING = "PENDING" # 待處理
|
259 |
+
COMPLETED = "COMPLETED" # 已完成
|
260 |
+
CANCELLED = "CANCELLED" # 已取消
|
261 |
+
|
262 |
+
class WarehouseType(str, enum.Enum):
|
263 |
+
MAIN = "MAIN" # 主倉
|
264 |
+
RAW_MATERIAL = "RAW_MATERIAL" # 原料倉
|
265 |
+
FINISHED_GOODS = "FINISHED_GOODS" # 成品倉
|
266 |
+
QUARANTINE = "QUARANTINE" # 檢疫倉
|
267 |
+
DAMAGED = "DAMAGED" # 損壞品倉
|
268 |
+
|
269 |
+
# 銷售單相關的枚舉類型
|
270 |
+
class SalesOrderStatus(str, enum.Enum):
|
271 |
+
DRAFT = "DRAFT" # 草稿
|
272 |
+
CONFIRMED = "CONFIRMED" # 已確認
|
273 |
+
SHIPPED = "SHIPPED" # 已出貨
|
274 |
+
DELIVERED = "DELIVERED" # 已送達
|
275 |
+
CANCELLED = "CANCELLED" # 已取消
|
276 |
+
|
277 |
+
class PaymentTerm(str, enum.Enum):
|
278 |
+
CASH = "CASH" # 現金
|
279 |
+
MONTHLY = "MONTHLY" # 月結
|
280 |
+
TRANSFER = "TRANSFER" # 轉帳
|
281 |
+
CREDIT_CARD = "CREDIT_CARD" # 信用卡
|
282 |
+
CHECK = "CHECK" # 支票
|
283 |
+
|
284 |
+
|
285 |
+
# 採購明細項目 schemas
|
286 |
+
class PurchaseOrderItemBase(BaseModel):
|
287 |
+
product_id: int = Field(..., description="產品ID")
|
288 |
+
quantity: int = Field(..., gt=0, description="數量,必須大於0")
|
289 |
+
unit_price: float = Field(..., ge=0, description="單價,必須大於等於0")
|
290 |
+
notes: Optional[str] = None
|
291 |
+
|
292 |
+
class PurchaseOrderItemCreate(PurchaseOrderItemBase):
|
293 |
+
pass
|
294 |
+
|
295 |
+
class PurchaseOrderItemUpdate(BaseModel):
|
296 |
+
product_id: Optional[int] = None
|
297 |
+
quantity: Optional[int] = Field(None, gt=0)
|
298 |
+
unit_price: Optional[float] = Field(None, ge=0)
|
299 |
+
notes: Optional[str] = None
|
300 |
+
|
301 |
+
class PurchaseOrderItem(PurchaseOrderItemBase):
|
302 |
+
id: int
|
303 |
+
line_total: float # 小計 (數量 × 單價)
|
304 |
+
product: Optional[Product] = None # 包含產品詳細資訊
|
305 |
+
created_at: datetime
|
306 |
+
updated_at: Optional[datetime] = None
|
307 |
+
|
308 |
+
class Config:
|
309 |
+
from_attributes = True
|
310 |
+
|
311 |
+
|
312 |
+
# 採購單主檔 schemas
|
313 |
+
class PurchaseOrderBase(BaseModel):
|
314 |
+
purchase_date: date = Field(..., description="採購日期")
|
315 |
+
expected_delivery_date: Optional[date] = None
|
316 |
+
supplier_id: int = Field(..., description="供應商ID")
|
317 |
+
notes: Optional[str] = None
|
318 |
+
tax_type: TaxType = TaxType.INCLUSIVE
|
319 |
+
tax_rate: float = Field(0.05, ge=0, le=1, description="稅率,0-1之間")
|
320 |
+
payment_method: Optional[str] = None
|
321 |
+
|
322 |
+
class PurchaseOrderCreate(PurchaseOrderBase):
|
323 |
+
items: List[PurchaseOrderItemCreate] = Field(..., min_items=1, description="採購明細,至少要有一項")
|
324 |
+
|
325 |
+
class PurchaseOrderUpdate(BaseModel):
|
326 |
+
purchase_date: Optional[date] = None
|
327 |
+
expected_delivery_date: Optional[date] = None
|
328 |
+
supplier_id: Optional[int] = None
|
329 |
+
status: Optional[PurchaseOrderStatus] = None
|
330 |
+
notes: Optional[str] = None
|
331 |
+
tax_type: Optional[TaxType] = None
|
332 |
+
tax_rate: Optional[float] = Field(None, ge=0, le=1)
|
333 |
+
payment_method: Optional[str] = None
|
334 |
+
payment_status: Optional[PaymentStatus] = None
|
335 |
+
|
336 |
+
class PurchaseOrder(PurchaseOrderBase):
|
337 |
+
id: int
|
338 |
+
po_number: str # 採購單號
|
339 |
+
purchaser_id: int
|
340 |
+
status: PurchaseOrderStatus
|
341 |
+
subtotal: float # 小計
|
342 |
+
tax_amount: float # 稅額
|
343 |
+
total_amount: float # 含稅總額
|
344 |
+
payment_status: PaymentStatus
|
345 |
+
created_at: datetime
|
346 |
+
updated_at: Optional[datetime] = None
|
347 |
+
|
348 |
+
# 關聯資料
|
349 |
+
purchaser: Optional[User] = None
|
350 |
+
supplier: Optional[Supplier] = None
|
351 |
+
items: List[PurchaseOrderItem] = []
|
352 |
+
|
353 |
+
class Config:
|
354 |
+
from_attributes = True
|
355 |
+
|
356 |
+
|
357 |
+
# 入庫明細項目 schemas
|
358 |
+
class GoodsReceiptItemBase(BaseModel):
|
359 |
+
purchase_order_item_id: int = Field(..., description="採購明細ID")
|
360 |
+
product_id: int = Field(..., description="產品ID")
|
361 |
+
ordered_quantity: int = Field(..., gt=0, description="採購數量")
|
362 |
+
received_quantity: int = Field(..., ge=0, description="實到數量,必須大於等於0")
|
363 |
+
storage_location: Optional[str] = None
|
364 |
+
notes: Optional[str] = None
|
365 |
+
|
366 |
+
class GoodsReceiptItemCreate(GoodsReceiptItemBase):
|
367 |
+
pass
|
368 |
+
|
369 |
+
class GoodsReceiptItemUpdate(BaseModel):
|
370 |
+
purchase_order_item_id: Optional[int] = None # 需要用來識別明細項目
|
371 |
+
product_id: Optional[int] = None # 需要用來識別產品
|
372 |
+
received_quantity: Optional[int] = Field(None, ge=0)
|
373 |
+
storage_location: Optional[str] = None
|
374 |
+
notes: Optional[str] = None
|
375 |
+
|
376 |
+
class GoodsReceiptItem(GoodsReceiptItemBase):
|
377 |
+
id: int
|
378 |
+
product: Optional[Product] = None # 包含產品詳細資訊
|
379 |
+
purchase_order_item: Optional[PurchaseOrderItem] = None # 包含採購明細資訊
|
380 |
+
created_at: datetime
|
381 |
+
updated_at: Optional[datetime] = None
|
382 |
+
|
383 |
+
class Config:
|
384 |
+
from_attributes = True
|
385 |
+
|
386 |
+
|
387 |
+
# 入庫單主檔 schemas
|
388 |
+
class GoodsReceiptBase(BaseModel):
|
389 |
+
receipt_date: date = Field(..., description="入庫日期")
|
390 |
+
purchase_order_id: int = Field(..., description="採購單ID")
|
391 |
+
warehouse_type: WarehouseType = WarehouseType.MAIN
|
392 |
+
warehouse_location: Optional[str] = None
|
393 |
+
notes: Optional[str] = None
|
394 |
+
|
395 |
+
class GoodsReceiptCreate(GoodsReceiptBase):
|
396 |
+
items: List[GoodsReceiptItemCreate] = Field(..., min_items=1, description="入庫明細,至少要有一項")
|
397 |
+
|
398 |
+
class GoodsReceiptUpdate(BaseModel):
|
399 |
+
receipt_date: Optional[date] = None
|
400 |
+
warehouse_type: Optional[WarehouseType] = None
|
401 |
+
warehouse_location: Optional[str] = None
|
402 |
+
status: Optional[GoodsReceiptStatus] = None
|
403 |
+
notes: Optional[str] = None
|
404 |
+
items: Optional[List[GoodsReceiptItemUpdate]] = None # 新增入庫明細更新
|
405 |
+
|
406 |
+
class GoodsReceipt(GoodsReceiptBase):
|
407 |
+
id: int
|
408 |
+
gr_number: str # 入庫單號
|
409 |
+
warehouse_staff_id: int
|
410 |
+
status: GoodsReceiptStatus
|
411 |
+
created_at: datetime
|
412 |
+
updated_at: Optional[datetime] = None
|
413 |
+
|
414 |
+
# 關聯資料
|
415 |
+
warehouse_staff: Optional[User] = None
|
416 |
+
purchase_order: Optional[PurchaseOrder] = None
|
417 |
+
items: List[GoodsReceiptItem] = []
|
418 |
+
|
419 |
+
class Config:
|
420 |
+
from_attributes = True
|
421 |
+
|
422 |
+
|
423 |
+
# 銷售明細項目 schemas
|
424 |
+
class SalesOrderItemBase(BaseModel):
|
425 |
+
product_id: int = Field(..., description="產品ID")
|
426 |
+
quantity: int = Field(..., gt=0, description="數量,必須大於0")
|
427 |
+
unit_price: float = Field(..., ge=0, description="單價,必須大於等於0")
|
428 |
+
notes: Optional[str] = None
|
429 |
+
|
430 |
+
class SalesOrderItemCreate(SalesOrderItemBase):
|
431 |
+
pass
|
432 |
+
|
433 |
+
class SalesOrderItemUpdate(BaseModel):
|
434 |
+
product_id: Optional[int] = None
|
435 |
+
quantity: Optional[int] = Field(None, gt=0)
|
436 |
+
unit_price: Optional[float] = Field(None, ge=0)
|
437 |
+
notes: Optional[str] = None
|
438 |
+
|
439 |
+
class SalesOrderItem(SalesOrderItemBase):
|
440 |
+
id: int
|
441 |
+
line_total: float # 小計 (數量 × 單價)
|
442 |
+
product: Optional[Product] = None # 包含產品詳細資訊
|
443 |
+
created_at: datetime
|
444 |
+
updated_at: Optional[datetime] = None
|
445 |
+
|
446 |
+
class Config:
|
447 |
+
from_attributes = True
|
448 |
+
|
449 |
+
|
450 |
+
# 銷售單主檔 schemas
|
451 |
+
class SalesOrderBase(BaseModel):
|
452 |
+
sales_date: date = Field(..., description="銷售日期")
|
453 |
+
customer_id: int = Field(..., description="客戶ID")
|
454 |
+
payment_term: PaymentTerm = PaymentTerm.CASH
|
455 |
+
notes: Optional[str] = None
|
456 |
+
tax_type: TaxType = TaxType.INCLUSIVE
|
457 |
+
tax_rate: float = Field(0.05, ge=0, le=1, description="稅率,0-1之間")
|
458 |
+
discount_rate: float = Field(0.0, ge=0, le=1, description="折扣率,0-1之間")
|
459 |
+
|
460 |
+
class SalesOrderCreate(SalesOrderBase):
|
461 |
+
items: List[SalesOrderItemCreate] = Field(..., min_items=1, description="銷售明細,至少要有一項")
|
462 |
+
|
463 |
+
class SalesOrderUpdate(BaseModel):
|
464 |
+
sales_date: Optional[date] = None
|
465 |
+
customer_id: Optional[int] = None
|
466 |
+
payment_term: Optional[PaymentTerm] = None
|
467 |
+
status: Optional[SalesOrderStatus] = None
|
468 |
+
notes: Optional[str] = None
|
469 |
+
tax_type: Optional[TaxType] = None
|
470 |
+
tax_rate: Optional[float] = Field(None, ge=0, le=1)
|
471 |
+
discount_rate: Optional[float] = Field(None, ge=0, le=1)
|
472 |
+
items: Optional[List[SalesOrderItemUpdate]] = None # 銷售明細更新
|
473 |
+
|
474 |
+
class SalesOrder(SalesOrderBase):
|
475 |
+
id: int
|
476 |
+
so_number: str # 銷售單號
|
477 |
+
salesperson_id: int
|
478 |
+
status: SalesOrderStatus
|
479 |
+
subtotal: float # 小計
|
480 |
+
tax_amount: float # 稅額
|
481 |
+
discount_amount: float # 折扣金額
|
482 |
+
total_amount: float # 實收總額
|
483 |
+
created_at: datetime
|
484 |
+
updated_at: Optional[datetime] = None
|
485 |
+
|
486 |
+
# 關聯資料
|
487 |
+
salesperson: Optional[User] = None
|
488 |
+
customer: Optional[Customer] = None
|
489 |
+
items: List[SalesOrderItem] = []
|
490 |
+
|
491 |
+
class Config:
|
492 |
+
from_attributes = True
|
app.py
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI
|
2 |
+
|
3 |
+
app = FastAPI()
|
4 |
+
|
5 |
+
@app.get("/")
|
6 |
+
def greet_json():
|
7 |
+
return {"Hello": "World!"}
|
backend/__init__.py
ADDED
File without changes
|
backend/config.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pydantic_settings import BaseSettings
|
3 |
+
from typing import Optional
|
4 |
+
|
5 |
+
class Settings(BaseSettings):
|
6 |
+
"""應用程式設定"""
|
7 |
+
|
8 |
+
# LINE Bot 設定
|
9 |
+
LINE_CHANNEL_ACCESS_TOKEN: str = os.getenv("LINE_CHANNEL_ACCESS_TOKEN", "")
|
10 |
+
LINE_CHANNEL_SECRET: str = os.getenv("LINE_CHANNEL_SECRET", "")
|
11 |
+
|
12 |
+
# 資料庫設定 (PostgreSQL 直連)
|
13 |
+
DB_HOST: str = os.getenv("DB_HOST", "")
|
14 |
+
DB_PORT: str = os.getenv("DB_PORT", "6543")
|
15 |
+
DB_NAME: str = os.getenv("DB_NAME", "")
|
16 |
+
DB_USER: str = os.getenv("DB_USER", "")
|
17 |
+
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "")
|
18 |
+
|
19 |
+
# SQLAlchemy 資料庫 URL
|
20 |
+
@property
|
21 |
+
def DATABASE_URL(self) -> str:
|
22 |
+
return f"postgresql+psycopg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
23 |
+
|
24 |
+
# OpenRouter 設定 (用於自然語言處理)
|
25 |
+
OPENROUTER_API_KEY: Optional[str] = os.getenv("OPENROUTER_API_KEY", "")
|
26 |
+
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "anthropic/claude-3-haiku")
|
27 |
+
|
28 |
+
# 其他設定
|
29 |
+
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
|
30 |
+
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
31 |
+
|
32 |
+
class Config:
|
33 |
+
env_file = ".env"
|
34 |
+
case_sensitive = True
|
35 |
+
|
36 |
+
settings = Settings()
|
backend/database/__init__.py
ADDED
File without changes
|
backend/database/connection.py
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
資料庫連線管理
|
3 |
+
"""
|
4 |
+
|
5 |
+
from sqlalchemy import create_engine, text
|
6 |
+
from sqlalchemy.ext.declarative import declarative_base
|
7 |
+
from sqlalchemy.orm import sessionmaker, Session
|
8 |
+
from backend.config import settings
|
9 |
+
import logging
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
# SQLAlchemy 設定
|
14 |
+
engine = create_engine(
|
15 |
+
settings.DATABASE_URL,
|
16 |
+
echo=settings.DEBUG, # 在 DEBUG 模式下顯示 SQL 語句
|
17 |
+
pool_size=10,
|
18 |
+
max_overflow=20,
|
19 |
+
pool_pre_ping=True, # 連線前檢查
|
20 |
+
pool_recycle=3600, # 1小時後回收連線
|
21 |
+
)
|
22 |
+
|
23 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
24 |
+
Base = declarative_base()
|
25 |
+
|
26 |
+
def get_database_session() -> Session:
|
27 |
+
"""取得資料庫 session"""
|
28 |
+
db = SessionLocal()
|
29 |
+
try:
|
30 |
+
return db
|
31 |
+
except Exception as e:
|
32 |
+
db.close()
|
33 |
+
raise e
|
34 |
+
|
35 |
+
def close_database_session(db: Session):
|
36 |
+
"""關閉資料庫 session"""
|
37 |
+
try:
|
38 |
+
db.close()
|
39 |
+
except Exception as e:
|
40 |
+
logger.error(f"關閉資料庫 session 時發生錯誤: {str(e)}")
|
41 |
+
|
42 |
+
def test_database_connection() -> bool:
|
43 |
+
"""測試資料庫連線"""
|
44 |
+
try:
|
45 |
+
with engine.connect() as connection:
|
46 |
+
result = connection.execute(text("SELECT 1"))
|
47 |
+
return result.fetchone()[0] == 1
|
48 |
+
except Exception as e:
|
49 |
+
logger.error(f"資料庫連線測試失敗: {str(e)}")
|
50 |
+
return False
|
51 |
+
|
52 |
+
# 依賴注入函數,用於 FastAPI
|
53 |
+
def get_db():
|
54 |
+
"""FastAPI 依賴注入函數"""
|
55 |
+
db = SessionLocal()
|
56 |
+
try:
|
57 |
+
yield db
|
58 |
+
finally:
|
59 |
+
db.close()
|
backend/database/init_db.py
ADDED
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
資料庫初始化腳本
|
3 |
+
使用 SQLAlchemy 建立資料表和插入範例資料
|
4 |
+
"""
|
5 |
+
|
6 |
+
from sqlalchemy import create_engine
|
7 |
+
from backend.database.connection import Base, engine
|
8 |
+
from backend.database.models import User, Product, Order, OrderItem, LineMessage, UserSession
|
9 |
+
from backend.config import settings
|
10 |
+
import logging
|
11 |
+
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
def create_tables():
|
15 |
+
"""建立所有資料表"""
|
16 |
+
try:
|
17 |
+
logger.info("開始建立資料表...")
|
18 |
+
Base.metadata.create_all(bind=engine)
|
19 |
+
logger.info("資料表建立完成!")
|
20 |
+
return True
|
21 |
+
except Exception as e:
|
22 |
+
logger.error(f"建立資料表失敗: {str(e)}")
|
23 |
+
return False
|
24 |
+
|
25 |
+
def insert_sample_data():
|
26 |
+
"""插入範例資料"""
|
27 |
+
from backend.database.connection import SessionLocal
|
28 |
+
|
29 |
+
db = SessionLocal()
|
30 |
+
try:
|
31 |
+
logger.info("開始插入範例資料...")
|
32 |
+
|
33 |
+
# 檢查是否已有資料
|
34 |
+
existing_products = db.query(Product).count()
|
35 |
+
if existing_products > 0:
|
36 |
+
logger.info("資料表已有資料,跳過範例資料插入")
|
37 |
+
return True
|
38 |
+
|
39 |
+
# 插入範例商品
|
40 |
+
sample_products = [
|
41 |
+
Product(
|
42 |
+
name="iPhone 15 Pro",
|
43 |
+
description="最新款 iPhone,配備 A17 Pro 晶片",
|
44 |
+
price=35900,
|
45 |
+
stock=50,
|
46 |
+
category="手機"
|
47 |
+
),
|
48 |
+
Product(
|
49 |
+
name="MacBook Air M2",
|
50 |
+
description="輕薄筆記型電腦,搭載 M2 晶片",
|
51 |
+
price=37900,
|
52 |
+
stock=30,
|
53 |
+
category="筆電"
|
54 |
+
),
|
55 |
+
Product(
|
56 |
+
name="AirPods Pro",
|
57 |
+
description="主動降噪無線耳機",
|
58 |
+
price=7490,
|
59 |
+
stock=100,
|
60 |
+
category="耳機"
|
61 |
+
),
|
62 |
+
Product(
|
63 |
+
name="iPad Air",
|
64 |
+
description="輕薄平板電腦,適合工作和娛樂",
|
65 |
+
price=18900,
|
66 |
+
stock=25,
|
67 |
+
category="平板"
|
68 |
+
),
|
69 |
+
Product(
|
70 |
+
name="Apple Watch Series 9",
|
71 |
+
description="智慧手錶,健康監測功能",
|
72 |
+
price=12900,
|
73 |
+
stock=40,
|
74 |
+
category="穿戴裝置"
|
75 |
+
)
|
76 |
+
]
|
77 |
+
|
78 |
+
for product in sample_products:
|
79 |
+
db.add(product)
|
80 |
+
|
81 |
+
# 插入範例用戶
|
82 |
+
sample_users = [
|
83 |
+
User(
|
84 |
+
user_id="U001",
|
85 |
+
name="張小明",
|
86 |
+
email="ming@example.com",
|
87 |
+
display_name="小明"
|
88 |
+
),
|
89 |
+
User(
|
90 |
+
user_id="U002",
|
91 |
+
name="李小華",
|
92 |
+
email="hua@example.com",
|
93 |
+
display_name="小華"
|
94 |
+
),
|
95 |
+
User(
|
96 |
+
user_id="U003",
|
97 |
+
name="王小美",
|
98 |
+
email="mei@example.com",
|
99 |
+
display_name="小美"
|
100 |
+
)
|
101 |
+
]
|
102 |
+
|
103 |
+
for user in sample_users:
|
104 |
+
db.add(user)
|
105 |
+
|
106 |
+
# 提交變更
|
107 |
+
db.commit()
|
108 |
+
logger.info("範例資料插入完成!")
|
109 |
+
return True
|
110 |
+
|
111 |
+
except Exception as e:
|
112 |
+
db.rollback()
|
113 |
+
logger.error(f"插入範例資料失敗: {str(e)}")
|
114 |
+
return False
|
115 |
+
finally:
|
116 |
+
db.close()
|
117 |
+
|
118 |
+
def init_database():
|
119 |
+
"""初始化資料庫"""
|
120 |
+
logger.info("=== 開始初始化資料庫 ===")
|
121 |
+
|
122 |
+
# 建立資料表
|
123 |
+
if not create_tables():
|
124 |
+
logger.error("資料表建立失敗,停止初始化")
|
125 |
+
return False
|
126 |
+
|
127 |
+
# 插入範例資料
|
128 |
+
if not insert_sample_data():
|
129 |
+
logger.error("範例資料插入失敗")
|
130 |
+
return False
|
131 |
+
|
132 |
+
logger.info("=== 資料庫初始化完成 ===")
|
133 |
+
return True
|
134 |
+
|
135 |
+
def drop_all_tables():
|
136 |
+
"""刪除所有資料表(謹慎使用)"""
|
137 |
+
try:
|
138 |
+
logger.warning("正在刪除所有資料表...")
|
139 |
+
Base.metadata.drop_all(bind=engine)
|
140 |
+
logger.warning("所有資料表已刪除!")
|
141 |
+
return True
|
142 |
+
except Exception as e:
|
143 |
+
logger.error(f"刪除資料表失敗: {str(e)}")
|
144 |
+
return False
|
145 |
+
|
146 |
+
if __name__ == "__main__":
|
147 |
+
import sys
|
148 |
+
|
149 |
+
if len(sys.argv) > 1 and sys.argv[1] == "--drop":
|
150 |
+
# 刪除並重建資料表
|
151 |
+
drop_all_tables()
|
152 |
+
init_database()
|
153 |
+
else:
|
154 |
+
# 正常初始化
|
155 |
+
init_database()
|
backend/database/models.py
ADDED
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
SQLAlchemy 資料庫模型 - 基於進銷存系統
|
3 |
+
"""
|
4 |
+
|
5 |
+
from sqlalchemy import Column, Integer, String, Text, DECIMAL, Boolean, DateTime, ForeignKey, JSON, Date, Float, Enum
|
6 |
+
from sqlalchemy.sql import func
|
7 |
+
from sqlalchemy.orm import relationship
|
8 |
+
from backend.database.connection import Base
|
9 |
+
import enum
|
10 |
+
|
11 |
+
# 枚舉類型定義
|
12 |
+
class UserRole(str, enum.Enum):
|
13 |
+
ADMIN = "ADMIN"
|
14 |
+
USER = "USER"
|
15 |
+
|
16 |
+
class TransactionType(str, enum.Enum):
|
17 |
+
PURCHASE = "PURCHASE"
|
18 |
+
SELL = "SELL"
|
19 |
+
|
20 |
+
class TransactionStatus(str, enum.Enum):
|
21 |
+
PENDING = "PENDING"
|
22 |
+
PROCESSING = "PROCESSING"
|
23 |
+
COMPLETED = "COMPLETED"
|
24 |
+
CANCELLED = "CANCELLED"
|
25 |
+
|
26 |
+
class PurchaseOrderStatus(str, enum.Enum):
|
27 |
+
DRAFT = "DRAFT"
|
28 |
+
PENDING = "PENDING"
|
29 |
+
CONFIRMED = "CONFIRMED"
|
30 |
+
RECEIVED = "RECEIVED"
|
31 |
+
CANCELLED = "CANCELLED"
|
32 |
+
|
33 |
+
class SalesOrderStatus(str, enum.Enum):
|
34 |
+
DRAFT = "DRAFT"
|
35 |
+
CONFIRMED = "CONFIRMED"
|
36 |
+
SHIPPED = "SHIPPED"
|
37 |
+
DELIVERED = "DELIVERED"
|
38 |
+
CANCELLED = "CANCELLED"
|
39 |
+
|
40 |
+
class PaymentStatus(str, enum.Enum):
|
41 |
+
UNPAID = "UNPAID"
|
42 |
+
PARTIAL = "PARTIAL"
|
43 |
+
PAID = "PAID"
|
44 |
+
|
45 |
+
# 基礎模型
|
46 |
+
class User(Base):
|
47 |
+
"""用戶模型"""
|
48 |
+
__tablename__ = "users"
|
49 |
+
|
50 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
51 |
+
name = Column(String(255))
|
52 |
+
email = Column(String(255), unique=True, nullable=False)
|
53 |
+
phoneNumber = Column(String(50))
|
54 |
+
role = Column(Enum(UserRole), default=UserRole.USER)
|
55 |
+
createdAt = Column(DateTime(timezone=True), server_default=func.now())
|
56 |
+
|
57 |
+
# 關聯
|
58 |
+
purchase_orders = relationship("PurchaseOrder", back_populates="purchaser")
|
59 |
+
sales_orders = relationship("SalesOrder", back_populates="salesperson")
|
60 |
+
|
61 |
+
class Category(Base):
|
62 |
+
"""商品類別模型"""
|
63 |
+
__tablename__ = "categories"
|
64 |
+
|
65 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
66 |
+
name = Column(String(255), nullable=False)
|
67 |
+
|
68 |
+
# 關聯
|
69 |
+
products = relationship("Product", back_populates="category")
|
70 |
+
|
71 |
+
class Product(Base):
|
72 |
+
"""商品模型"""
|
73 |
+
__tablename__ = "products"
|
74 |
+
|
75 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
76 |
+
productCode = Column(String(100), unique=True, nullable=False)
|
77 |
+
productName = Column(String(255), nullable=False)
|
78 |
+
unit = Column(String(50), nullable=False)
|
79 |
+
warehouse = Column(String(100))
|
80 |
+
unitWeight = Column(Float)
|
81 |
+
barcode = Column(String(100))
|
82 |
+
category_id = Column(Integer, ForeignKey("categories.id"))
|
83 |
+
stock = Column(Integer, default=0)
|
84 |
+
is_deleted = Column(Boolean, default=False)
|
85 |
+
deleted_at = Column(DateTime(timezone=True))
|
86 |
+
deleted_by = Column(Integer)
|
87 |
+
createdAt = Column(DateTime(timezone=True), server_default=func.now())
|
88 |
+
updatedAt = Column(DateTime(timezone=True), onupdate=func.now())
|
89 |
+
|
90 |
+
# 關聯
|
91 |
+
category = relationship("Category", back_populates="products")
|
92 |
+
purchase_order_items = relationship("PurchaseOrderItem", back_populates="product")
|
93 |
+
sales_order_items = relationship("SalesOrderItem", back_populates="product")
|
94 |
+
|
95 |
+
class Supplier(Base):
|
96 |
+
"""供應商模型"""
|
97 |
+
__tablename__ = "suppliers"
|
98 |
+
|
99 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
100 |
+
name = Column(String(255), nullable=False)
|
101 |
+
contactInfo = Column(Text)
|
102 |
+
address = Column(Text)
|
103 |
+
|
104 |
+
# 關聯
|
105 |
+
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
106 |
+
|
107 |
+
class Customer(Base):
|
108 |
+
"""客戶模型"""
|
109 |
+
__tablename__ = "customers"
|
110 |
+
|
111 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
112 |
+
customerType = Column(String(100))
|
113 |
+
salesPersonId = Column(String(100))
|
114 |
+
salesPersonName = Column(String(255))
|
115 |
+
customerCode = Column(String(100), unique=True, nullable=False)
|
116 |
+
customerName = Column(String(255), nullable=False)
|
117 |
+
contactPerson = Column(String(255))
|
118 |
+
invoiceTitle = Column(String(255))
|
119 |
+
taxId = Column(String(50))
|
120 |
+
phoneNumber = Column(String(50))
|
121 |
+
faxNumber = Column(String(50))
|
122 |
+
deliveryAddress = Column(Text)
|
123 |
+
businessHours = Column(String(255))
|
124 |
+
paymentMethod = Column(String(100))
|
125 |
+
paymentCategory = Column(String(100))
|
126 |
+
creditLimit = Column(Float, default=0.0)
|
127 |
+
createdDate = Column(DateTime(timezone=True), server_default=func.now())
|
128 |
+
updatedAt = Column(DateTime(timezone=True), onupdate=func.now())
|
129 |
+
|
130 |
+
# 關聯
|
131 |
+
sales_orders = relationship("SalesOrder", back_populates="customer")
|
132 |
+
|
133 |
+
class PurchaseOrder(Base):
|
134 |
+
"""採購單模型"""
|
135 |
+
__tablename__ = "purchase_orders"
|
136 |
+
|
137 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
138 |
+
po_number = Column(String(100), unique=True, nullable=False)
|
139 |
+
purchase_date = Column(Date, nullable=False)
|
140 |
+
expected_delivery_date = Column(Date)
|
141 |
+
supplier_id = Column(Integer, ForeignKey("suppliers.id"))
|
142 |
+
purchaser_id = Column(Integer, ForeignKey("users.id"))
|
143 |
+
status = Column(Enum(PurchaseOrderStatus), default=PurchaseOrderStatus.DRAFT)
|
144 |
+
subtotal = Column(Float, default=0.0)
|
145 |
+
tax_amount = Column(Float, default=0.0)
|
146 |
+
total_amount = Column(Float, default=0.0)
|
147 |
+
payment_status = Column(Enum(PaymentStatus), default=PaymentStatus.UNPAID)
|
148 |
+
notes = Column(Text)
|
149 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
150 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
151 |
+
|
152 |
+
# 關聯
|
153 |
+
supplier = relationship("Supplier", back_populates="purchase_orders")
|
154 |
+
purchaser = relationship("User", back_populates="purchase_orders")
|
155 |
+
items = relationship("PurchaseOrderItem", back_populates="purchase_order")
|
156 |
+
|
157 |
+
class PurchaseOrderItem(Base):
|
158 |
+
"""採購單明細模型"""
|
159 |
+
__tablename__ = "purchase_order_items"
|
160 |
+
|
161 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
162 |
+
purchase_order_id = Column(Integer, ForeignKey("purchase_orders.id"))
|
163 |
+
product_id = Column(Integer, ForeignKey("products.id"))
|
164 |
+
quantity = Column(Integer, nullable=False)
|
165 |
+
unit_price = Column(Float, nullable=False)
|
166 |
+
line_total = Column(Float, nullable=False)
|
167 |
+
notes = Column(Text)
|
168 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
169 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
170 |
+
|
171 |
+
# 關聯
|
172 |
+
purchase_order = relationship("PurchaseOrder", back_populates="items")
|
173 |
+
product = relationship("Product", back_populates="purchase_order_items")
|
174 |
+
|
175 |
+
class SalesOrder(Base):
|
176 |
+
"""銷售單模型"""
|
177 |
+
__tablename__ = "sales_orders"
|
178 |
+
|
179 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
180 |
+
so_number = Column(String(100), unique=True, nullable=False)
|
181 |
+
sales_date = Column(Date, nullable=False)
|
182 |
+
customer_id = Column(Integer, ForeignKey("customers.id"))
|
183 |
+
salesperson_id = Column(Integer, ForeignKey("users.id"))
|
184 |
+
status = Column(Enum(SalesOrderStatus), default=SalesOrderStatus.DRAFT)
|
185 |
+
subtotal = Column(Float, default=0.0)
|
186 |
+
tax_amount = Column(Float, default=0.0)
|
187 |
+
discount_amount = Column(Float, default=0.0)
|
188 |
+
total_amount = Column(Float, default=0.0)
|
189 |
+
notes = Column(Text)
|
190 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
191 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
192 |
+
|
193 |
+
# 關聯
|
194 |
+
customer = relationship("Customer", back_populates="sales_orders")
|
195 |
+
salesperson = relationship("User", back_populates="sales_orders")
|
196 |
+
items = relationship("SalesOrderItem", back_populates="sales_order")
|
197 |
+
|
198 |
+
class SalesOrderItem(Base):
|
199 |
+
"""銷售單明細模型"""
|
200 |
+
__tablename__ = "sales_order_items"
|
201 |
+
|
202 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
203 |
+
sales_order_id = Column(Integer, ForeignKey("sales_orders.id"))
|
204 |
+
product_id = Column(Integer, ForeignKey("products.id"))
|
205 |
+
quantity = Column(Integer, nullable=False)
|
206 |
+
unit_price = Column(Float, nullable=False)
|
207 |
+
line_total = Column(Float, nullable=False)
|
208 |
+
notes = Column(Text)
|
209 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
210 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
211 |
+
|
212 |
+
# 關聯
|
213 |
+
sales_order = relationship("SalesOrder", back_populates="items")
|
214 |
+
product = relationship("Product", back_populates="sales_order_items")
|
215 |
+
|
216 |
+
# LINE Bot 相關模型
|
217 |
+
class LineMessage(Base):
|
218 |
+
"""LINE 訊息記錄模型"""
|
219 |
+
__tablename__ = "line_messages"
|
220 |
+
|
221 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
222 |
+
user_id = Column(String(255))
|
223 |
+
message = Column(Text, nullable=False)
|
224 |
+
message_type = Column(String(50), default="text")
|
225 |
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
226 |
+
processed = Column(Boolean, default=False)
|
backend/main.py
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from fastapi import FastAPI, Request, HTTPException
|
3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
4 |
+
from linebot import LineBotApi, WebhookHandler
|
5 |
+
from linebot.exceptions import InvalidSignatureError
|
6 |
+
from linebot.models import MessageEvent, TextMessage, TextSendMessage
|
7 |
+
import logging
|
8 |
+
from backend.services.nlp_service import NLPService
|
9 |
+
from backend.services.database_service import DatabaseService
|
10 |
+
from backend.config import settings
|
11 |
+
from backend.database.connection import test_database_connection
|
12 |
+
|
13 |
+
# 設定日誌
|
14 |
+
logging.basicConfig(level=logging.INFO)
|
15 |
+
logger = logging.getLogger(__name__)
|
16 |
+
|
17 |
+
app = FastAPI(title="LINE Bot API", version="1.0.0")
|
18 |
+
|
19 |
+
# CORS 設定
|
20 |
+
app.add_middleware(
|
21 |
+
CORSMiddleware,
|
22 |
+
allow_origins=["*"],
|
23 |
+
allow_credentials=True,
|
24 |
+
allow_methods=["*"],
|
25 |
+
allow_headers=["*"],
|
26 |
+
)
|
27 |
+
|
28 |
+
# LINE Bot 初始化
|
29 |
+
line_bot_api = LineBotApi(settings.LINE_CHANNEL_ACCESS_TOKEN)
|
30 |
+
handler = WebhookHandler(settings.LINE_CHANNEL_SECRET)
|
31 |
+
|
32 |
+
# 服務初始化
|
33 |
+
nlp_service = NLPService()
|
34 |
+
db_service = DatabaseService()
|
35 |
+
|
36 |
+
@app.get("/")
|
37 |
+
def greet_json():
|
38 |
+
return {"Hello": "LINE Bot API is running!"}
|
39 |
+
|
40 |
+
@app.get("/health")
|
41 |
+
def health_check():
|
42 |
+
# 檢查資料庫連線
|
43 |
+
db_status = "connected" if test_database_connection() else "disconnected"
|
44 |
+
|
45 |
+
return {
|
46 |
+
"status": "healthy",
|
47 |
+
"message": "LINE Bot API is operational",
|
48 |
+
"database": db_status,
|
49 |
+
"database_url": f"postgresql://{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}"
|
50 |
+
}
|
51 |
+
|
52 |
+
@app.post("/webhook")
|
53 |
+
async def callback(request: Request):
|
54 |
+
"""LINE Bot Webhook 端點"""
|
55 |
+
signature = request.headers.get('X-Line-Signature', '')
|
56 |
+
body = await request.body()
|
57 |
+
|
58 |
+
try:
|
59 |
+
handler.handle(body.decode('utf-8'), signature)
|
60 |
+
except InvalidSignatureError:
|
61 |
+
logger.error("Invalid signature")
|
62 |
+
raise HTTPException(status_code=400, detail="Invalid signature")
|
63 |
+
|
64 |
+
return {"status": "ok"}
|
65 |
+
|
66 |
+
@handler.add(MessageEvent, message=TextMessage)
|
67 |
+
def handle_message(event):
|
68 |
+
"""處理 LINE 訊息"""
|
69 |
+
try:
|
70 |
+
user_message = event.message.text
|
71 |
+
user_id = event.source.user_id
|
72 |
+
|
73 |
+
logger.info(f"收到訊息: {user_message} from {user_id}")
|
74 |
+
|
75 |
+
# 使用 NLP 服務分析訊息
|
76 |
+
analysis_result = nlp_service.analyze_message(user_message)
|
77 |
+
|
78 |
+
# 根據分析結果調用對應的資料庫方法
|
79 |
+
response_data = db_service.execute_query(analysis_result)
|
80 |
+
|
81 |
+
# 格式化回應訊息
|
82 |
+
reply_message = nlp_service.format_response(response_data, analysis_result, user_message)
|
83 |
+
|
84 |
+
# 回覆訊息
|
85 |
+
line_bot_api.reply_message(
|
86 |
+
event.reply_token,
|
87 |
+
TextSendMessage(text=reply_message)
|
88 |
+
)
|
89 |
+
|
90 |
+
except Exception as e:
|
91 |
+
logger.error(f"處理訊息時發生錯誤: {str(e)}")
|
92 |
+
line_bot_api.reply_message(
|
93 |
+
event.reply_token,
|
94 |
+
TextSendMessage(text="抱歉,處理您的訊息時發生錯誤,請稍後再試。")
|
95 |
+
)
|
96 |
+
|
97 |
+
if __name__ == "__main__":
|
98 |
+
import uvicorn
|
99 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
backend/models/__init__.py
ADDED
File without changes
|
backend/models/schemas.py
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field
|
2 |
+
from typing import Optional, Dict, Any, List
|
3 |
+
from datetime import datetime
|
4 |
+
from enum import Enum
|
5 |
+
|
6 |
+
class QueryType(str, Enum):
|
7 |
+
"""查詢類型枚舉"""
|
8 |
+
SEARCH = "search"
|
9 |
+
CREATE = "create"
|
10 |
+
UPDATE = "update"
|
11 |
+
DELETE = "delete"
|
12 |
+
ANALYTICS = "analytics"
|
13 |
+
UNKNOWN = "unknown"
|
14 |
+
|
15 |
+
class NLPAnalysisRequest(BaseModel):
|
16 |
+
"""NLP 分析請求模型"""
|
17 |
+
message: str = Field(..., description="用戶輸入的訊息")
|
18 |
+
user_id: Optional[str] = Field(None, description="用戶ID")
|
19 |
+
context: Optional[Dict[str, Any]] = Field(default_factory=dict, description="上下文資訊")
|
20 |
+
|
21 |
+
class NLPAnalysisResult(BaseModel):
|
22 |
+
"""NLP 分析結果模型"""
|
23 |
+
query_type: QueryType = Field(..., description="查詢類型")
|
24 |
+
intent: str = Field(..., description="用戶意圖")
|
25 |
+
entities: Dict[str, Any] = Field(default_factory=dict, description="提取的實體")
|
26 |
+
confidence: float = Field(..., ge=0.0, le=1.0, description="信心度")
|
27 |
+
parameters: Dict[str, Any] = Field(default_factory=dict, description="查詢參數")
|
28 |
+
|
29 |
+
class DatabaseQuery(BaseModel):
|
30 |
+
"""資料庫查詢模型"""
|
31 |
+
table_name: str = Field(..., description="資料表名稱")
|
32 |
+
operation: str = Field(..., description="操作類型")
|
33 |
+
conditions: Optional[Dict[str, Any]] = Field(default_factory=dict, description="查詢條件")
|
34 |
+
data: Optional[Dict[str, Any]] = Field(default_factory=dict, description="要操作的資料")
|
35 |
+
limit: Optional[int] = Field(None, description="查詢限制數量")
|
36 |
+
offset: Optional[int] = Field(None, description="查詢偏移量")
|
37 |
+
|
38 |
+
class DatabaseResult(BaseModel):
|
39 |
+
"""資料庫查詢結果模型"""
|
40 |
+
success: bool = Field(..., description="是否成功")
|
41 |
+
data: Optional[List[Dict[str, Any]]] = Field(default_factory=list, description="查詢結果資料")
|
42 |
+
count: Optional[int] = Field(None, description="結果數量")
|
43 |
+
error: Optional[str] = Field(None, description="錯誤訊息")
|
44 |
+
|
45 |
+
class LineMessage(BaseModel):
|
46 |
+
"""LINE 訊息模型"""
|
47 |
+
user_id: str = Field(..., description="用戶ID")
|
48 |
+
message: str = Field(..., description="訊息內容")
|
49 |
+
timestamp: datetime = Field(default_factory=datetime.now, description="時間戳記")
|
50 |
+
message_type: str = Field(default="text", description="訊息類型")
|
51 |
+
|
52 |
+
class UserProfile(BaseModel):
|
53 |
+
"""用戶資料模型"""
|
54 |
+
user_id: str = Field(..., description="用戶ID")
|
55 |
+
display_name: Optional[str] = Field(None, description="顯示名稱")
|
56 |
+
status_message: Optional[str] = Field(None, description="狀態訊息")
|
57 |
+
picture_url: Optional[str] = Field(None, description="頭像URL")
|
58 |
+
created_at: datetime = Field(default_factory=datetime.now, description="建立時間")
|
59 |
+
updated_at: datetime = Field(default_factory=datetime.now, description="更新時間")
|
60 |
+
|
61 |
+
class APIResponse(BaseModel):
|
62 |
+
"""API 回應模型"""
|
63 |
+
success: bool = Field(..., description="是否成功")
|
64 |
+
message: str = Field(..., description="回應訊息")
|
65 |
+
data: Optional[Any] = Field(None, description="回應資料")
|
66 |
+
error: Optional[str] = Field(None, description="錯誤訊息")
|
backend/services/__init__.py
ADDED
File without changes
|
backend/services/business_query_service.py
ADDED
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
業務查詢服務 - 整合 NLP 分析和資料庫查詢
|
3 |
+
用於處理 LINE 用戶的自然語言業務查詢
|
4 |
+
"""
|
5 |
+
|
6 |
+
import logging
|
7 |
+
from typing import Dict, Any, Optional
|
8 |
+
from backend.services.nlp_service import NLPService
|
9 |
+
from backend.services.database_service import DatabaseService
|
10 |
+
from backend.models.schemas import DatabaseResult
|
11 |
+
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
class BusinessQueryService:
|
15 |
+
"""業務查詢服務 - 整合自然語言處理和資料庫查詢"""
|
16 |
+
|
17 |
+
def __init__(self):
|
18 |
+
self.nlp_service = NLPService()
|
19 |
+
self.db_service = DatabaseService()
|
20 |
+
|
21 |
+
def process_user_query(self, user_message: str, user_id: str = None) -> Dict[str, Any]:
|
22 |
+
"""
|
23 |
+
處理用戶查詢的主要入口點
|
24 |
+
|
25 |
+
Args:
|
26 |
+
user_message: 用戶的自然語言查詢
|
27 |
+
user_id: 用戶ID(可選)
|
28 |
+
|
29 |
+
Returns:
|
30 |
+
包含查詢結果和格式化回應的字典
|
31 |
+
"""
|
32 |
+
try:
|
33 |
+
# 1. 使用 NLP 服務分析用戶查詢
|
34 |
+
analysis_result = self.nlp_service.analyze_business_query(user_message, user_id)
|
35 |
+
|
36 |
+
logger.info(f"NLP 分析結果 - 意圖: {analysis_result.intent}, 信心度: {analysis_result.confidence}")
|
37 |
+
|
38 |
+
# 2. 根據分析結果執行對應的資料庫查詢
|
39 |
+
db_result = self._execute_database_query(analysis_result)
|
40 |
+
|
41 |
+
# 3. 格式化回應訊息
|
42 |
+
response_message = self.nlp_service.format_response_message(db_result, analysis_result.intent)
|
43 |
+
|
44 |
+
# 4. 儲存查詢記錄(如果有用戶ID)
|
45 |
+
if user_id:
|
46 |
+
self.db_service.save_message(user_id, user_message, "query")
|
47 |
+
|
48 |
+
return {
|
49 |
+
"success": db_result.success,
|
50 |
+
"intent": analysis_result.intent,
|
51 |
+
"confidence": analysis_result.confidence,
|
52 |
+
"entities": analysis_result.entities,
|
53 |
+
"response_message": response_message,
|
54 |
+
"data": db_result.data,
|
55 |
+
"count": db_result.count,
|
56 |
+
"error": db_result.error
|
57 |
+
}
|
58 |
+
|
59 |
+
except Exception as e:
|
60 |
+
logger.error(f"處理用戶查詢時發生錯誤: {str(e)}")
|
61 |
+
return {
|
62 |
+
"success": False,
|
63 |
+
"intent": "unknown",
|
64 |
+
"confidence": 0.0,
|
65 |
+
"entities": {},
|
66 |
+
"response_message": f"抱歉,處理您的查詢時發生錯誤:{str(e)}",
|
67 |
+
"data": [],
|
68 |
+
"count": 0,
|
69 |
+
"error": str(e)
|
70 |
+
}
|
71 |
+
|
72 |
+
def _execute_database_query(self, analysis_result) -> DatabaseResult:
|
73 |
+
"""根據 NLP 分析結果執行對應的資料庫查詢"""
|
74 |
+
try:
|
75 |
+
method_name = analysis_result.parameters.get("method")
|
76 |
+
|
77 |
+
if method_name == "search_products":
|
78 |
+
return self.db_service.search_products(
|
79 |
+
query_text=analysis_result.parameters.get("query_text"),
|
80 |
+
category=analysis_result.parameters.get("category"),
|
81 |
+
limit=analysis_result.parameters.get("limit", 10)
|
82 |
+
)
|
83 |
+
|
84 |
+
elif method_name == "check_inventory":
|
85 |
+
return self.db_service.check_inventory(
|
86 |
+
product_name=analysis_result.parameters.get("product_name"),
|
87 |
+
category=analysis_result.parameters.get("category")
|
88 |
+
)
|
89 |
+
|
90 |
+
elif method_name == "search_orders":
|
91 |
+
return self.db_service.search_orders(
|
92 |
+
user_id=analysis_result.parameters.get("user_id"),
|
93 |
+
status=analysis_result.parameters.get("status"),
|
94 |
+
limit=analysis_result.parameters.get("limit", 10)
|
95 |
+
)
|
96 |
+
|
97 |
+
elif method_name == "get_low_stock_products":
|
98 |
+
return self.db_service.get_low_stock_products(
|
99 |
+
threshold=analysis_result.parameters.get("threshold", 10)
|
100 |
+
)
|
101 |
+
|
102 |
+
elif method_name == "get_business_summary":
|
103 |
+
return self.db_service.get_business_summary()
|
104 |
+
|
105 |
+
else:
|
106 |
+
# 使用通用的自然語言查詢處理
|
107 |
+
return self.db_service.process_natural_language_query(
|
108 |
+
analysis_result.parameters.get("query_text", ""),
|
109 |
+
analysis_result.parameters.get("user_id")
|
110 |
+
)
|
111 |
+
|
112 |
+
except Exception as e:
|
113 |
+
logger.error(f"執行資料庫查詢時發生錯誤: {str(e)}")
|
114 |
+
return DatabaseResult(
|
115 |
+
success=False,
|
116 |
+
error=f"資料庫查詢失敗: {str(e)}"
|
117 |
+
)
|
118 |
+
|
119 |
+
def get_query_suggestions(self, user_id: str = None) -> List[str]:
|
120 |
+
"""提供查詢建議"""
|
121 |
+
suggestions = [
|
122 |
+
"查詢商品 iPhone",
|
123 |
+
"庫存查詢 筆記型電腦",
|
124 |
+
"我的訂單",
|
125 |
+
"低庫存商品",
|
126 |
+
"業務統計摘要",
|
127 |
+
"查詢客戶資料",
|
128 |
+
"今天的銷售報表"
|
129 |
+
]
|
130 |
+
return suggestions
|
131 |
+
|
132 |
+
def get_help_message(self) -> str:
|
133 |
+
"""取得幫助訊息"""
|
134 |
+
help_text = """
|
135 |
+
🤖 智能查詢助手使用說明:
|
136 |
+
|
137 |
+
📦 商品查詢:
|
138 |
+
• "查詢商品 iPhone"
|
139 |
+
• "有什麼筆記型電腦"
|
140 |
+
• "商品價格查詢"
|
141 |
+
|
142 |
+
📊 庫存查詢:
|
143 |
+
• "庫存查詢 iPhone"
|
144 |
+
• "筆記型電腦還有多少"
|
145 |
+
• "查詢存貨狀況"
|
146 |
+
|
147 |
+
📋 訂單查詢:
|
148 |
+
• "我的訂單"
|
149 |
+
• "查詢訂單狀態"
|
150 |
+
• "訂單編號 ORD001"
|
151 |
+
|
152 |
+
⚠️ 庫存警告:
|
153 |
+
• "低庫存商品"
|
154 |
+
• "缺貨商品查詢"
|
155 |
+
|
156 |
+
📈 業務統計:
|
157 |
+
• "業務摘要"
|
158 |
+
• "統計報表"
|
159 |
+
• "總計資料"
|
160 |
+
|
161 |
+
💡 您可以用自然語言提問,系統會智能理解您的需求!
|
162 |
+
"""
|
163 |
+
return help_text.strip()
|
164 |
+
|
165 |
+
def validate_user_permissions(self, user_id: str, query_type: str) -> bool:
|
166 |
+
"""驗證用戶權限(可根據需要擴展)"""
|
167 |
+
# 這裡可以根據用戶角色和查詢類型來驗證權限
|
168 |
+
# 目前先返回 True,允許所有查詢
|
169 |
+
return True
|
backend/services/database_service.py
ADDED
@@ -0,0 +1,765 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from typing import Dict, Any, List, Optional
|
3 |
+
from sqlalchemy.orm import Session
|
4 |
+
from sqlalchemy import text, and_, or_
|
5 |
+
from backend.models.schemas import NLPAnalysisResult, DatabaseResult, QueryType
|
6 |
+
from backend.config import settings
|
7 |
+
from backend.database.connection import get_database_session, close_database_session
|
8 |
+
from backend.database.models import User, Product, Order, OrderItem, LineMessage, UserSession
|
9 |
+
import logging
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class DatabaseService:
|
14 |
+
"""資料庫服務"""
|
15 |
+
|
16 |
+
def __init__(self):
|
17 |
+
self.model_mapping = {
|
18 |
+
"users": User,
|
19 |
+
"products": Product,
|
20 |
+
"orders": Order,
|
21 |
+
"order_items": OrderItem,
|
22 |
+
"line_messages": LineMessage,
|
23 |
+
"user_sessions": UserSession
|
24 |
+
}
|
25 |
+
|
26 |
+
# 定義資料表結構
|
27 |
+
self.table_schemas = {
|
28 |
+
"users": {
|
29 |
+
"model": User,
|
30 |
+
"searchable": ["name", "email", "display_name"]
|
31 |
+
},
|
32 |
+
"orders": {
|
33 |
+
"model": Order,
|
34 |
+
"searchable": ["order_id", "status"]
|
35 |
+
},
|
36 |
+
"products": {
|
37 |
+
"model": Product,
|
38 |
+
"searchable": ["name", "category", "description"]
|
39 |
+
},
|
40 |
+
"line_messages": {
|
41 |
+
"model": LineMessage,
|
42 |
+
"searchable": ["message", "user_id"]
|
43 |
+
},
|
44 |
+
"order_items": {
|
45 |
+
"model": OrderItem,
|
46 |
+
"searchable": []
|
47 |
+
},
|
48 |
+
"user_sessions": {
|
49 |
+
"model": UserSession,
|
50 |
+
"searchable": ["user_id"]
|
51 |
+
}
|
52 |
+
}
|
53 |
+
|
54 |
+
def execute_query(self, analysis_result: NLPAnalysisResult) -> DatabaseResult:
|
55 |
+
"""執行資料庫查詢"""
|
56 |
+
try:
|
57 |
+
query_type = analysis_result.query_type
|
58 |
+
parameters = analysis_result.parameters
|
59 |
+
|
60 |
+
if query_type == QueryType.SEARCH:
|
61 |
+
return self._execute_search(parameters)
|
62 |
+
elif query_type == QueryType.CREATE:
|
63 |
+
return self._execute_create(parameters)
|
64 |
+
elif query_type == QueryType.UPDATE:
|
65 |
+
return self._execute_update(parameters)
|
66 |
+
elif query_type == QueryType.DELETE:
|
67 |
+
return self._execute_delete(parameters)
|
68 |
+
elif query_type == QueryType.ANALYTICS:
|
69 |
+
return self._execute_analytics(parameters)
|
70 |
+
else:
|
71 |
+
return DatabaseResult(
|
72 |
+
success=False,
|
73 |
+
error="不支援的查詢類型"
|
74 |
+
)
|
75 |
+
|
76 |
+
except Exception as e:
|
77 |
+
logger.error(f"資料庫查詢錯誤: {str(e)}")
|
78 |
+
return DatabaseResult(
|
79 |
+
success=False,
|
80 |
+
error=f"資料庫查詢失敗: {str(e)}"
|
81 |
+
)
|
82 |
+
|
83 |
+
def _execute_search(self, parameters: Dict[str, Any]) -> DatabaseResult:
|
84 |
+
"""執行搜尋查詢"""
|
85 |
+
table_name = parameters.get("table", "users")
|
86 |
+
conditions = parameters.get("conditions", {})
|
87 |
+
limit = parameters.get("limit", 10)
|
88 |
+
offset = parameters.get("offset", 0)
|
89 |
+
|
90 |
+
db = None
|
91 |
+
try:
|
92 |
+
db = get_database_session()
|
93 |
+
|
94 |
+
# 取得對應的模型
|
95 |
+
model = self.table_schemas.get(table_name, {}).get("model")
|
96 |
+
if not model:
|
97 |
+
return DatabaseResult(
|
98 |
+
success=False,
|
99 |
+
error=f"不支援的資料表: {table_name}"
|
100 |
+
)
|
101 |
+
|
102 |
+
# 建立查詢
|
103 |
+
query = db.query(model)
|
104 |
+
|
105 |
+
# 添加條件
|
106 |
+
for field, value in conditions.items():
|
107 |
+
if hasattr(model, field):
|
108 |
+
column = getattr(model, field)
|
109 |
+
|
110 |
+
if isinstance(value, dict):
|
111 |
+
# 處理範圍查詢
|
112 |
+
if "gte" in value:
|
113 |
+
query = query.filter(column >= value["gte"])
|
114 |
+
if "lte" in value:
|
115 |
+
query = query.filter(column <= value["lte"])
|
116 |
+
if "gt" in value:
|
117 |
+
query = query.filter(column > value["gt"])
|
118 |
+
if "lt" in value:
|
119 |
+
query = query.filter(column < value["lt"])
|
120 |
+
else:
|
121 |
+
# 處理精確匹配或模糊搜尋
|
122 |
+
searchable_fields = self.table_schemas.get(table_name, {}).get("searchable", [])
|
123 |
+
if field in searchable_fields:
|
124 |
+
query = query.filter(column.ilike(f"%{value}%"))
|
125 |
+
else:
|
126 |
+
query = query.filter(column == value)
|
127 |
+
|
128 |
+
# 添加限制和偏移
|
129 |
+
query = query.offset(offset).limit(limit)
|
130 |
+
|
131 |
+
# 執行查詢
|
132 |
+
results = query.all()
|
133 |
+
|
134 |
+
# 轉換為字典格式
|
135 |
+
data = []
|
136 |
+
for result in results:
|
137 |
+
item = {}
|
138 |
+
for column in result.__table__.columns:
|
139 |
+
value = getattr(result, column.name)
|
140 |
+
# 處理特殊類型
|
141 |
+
if hasattr(value, 'isoformat'): # datetime
|
142 |
+
item[column.name] = value.isoformat()
|
143 |
+
elif isinstance(value, (int, float, str, bool)) or value is None:
|
144 |
+
item[column.name] = value
|
145 |
+
else:
|
146 |
+
item[column.name] = str(value)
|
147 |
+
data.append(item)
|
148 |
+
|
149 |
+
return DatabaseResult(
|
150 |
+
success=True,
|
151 |
+
data=data,
|
152 |
+
count=len(data)
|
153 |
+
)
|
154 |
+
|
155 |
+
except Exception as e:
|
156 |
+
logger.error(f"搜尋查詢錯誤: {str(e)}")
|
157 |
+
return DatabaseResult(
|
158 |
+
success=False,
|
159 |
+
error=f"搜尋失敗: {str(e)}"
|
160 |
+
)
|
161 |
+
finally:
|
162 |
+
if db:
|
163 |
+
close_database_session(db)
|
164 |
+
|
165 |
+
def _execute_create(self, parameters: Dict[str, Any]) -> DatabaseResult:
|
166 |
+
"""執行建立查詢"""
|
167 |
+
table_name = parameters.get("table", "users")
|
168 |
+
data = parameters.get("data", {})
|
169 |
+
|
170 |
+
db = None
|
171 |
+
try:
|
172 |
+
db = get_database_session()
|
173 |
+
|
174 |
+
# 取得對應的模型
|
175 |
+
model = self.table_schemas.get(table_name, {}).get("model")
|
176 |
+
if not model:
|
177 |
+
return DatabaseResult(
|
178 |
+
success=False,
|
179 |
+
error=f"不支援的資料表: {table_name}"
|
180 |
+
)
|
181 |
+
|
182 |
+
# 建立新記錄
|
183 |
+
new_record = model(**data)
|
184 |
+
db.add(new_record)
|
185 |
+
db.commit()
|
186 |
+
db.refresh(new_record)
|
187 |
+
|
188 |
+
# 轉換為字典格式
|
189 |
+
result_data = {}
|
190 |
+
for column in new_record.__table__.columns:
|
191 |
+
value = getattr(new_record, column.name)
|
192 |
+
if hasattr(value, 'isoformat'): # datetime
|
193 |
+
result_data[column.name] = value.isoformat()
|
194 |
+
elif isinstance(value, (int, float, str, bool)) or value is None:
|
195 |
+
result_data[column.name] = value
|
196 |
+
else:
|
197 |
+
result_data[column.name] = str(value)
|
198 |
+
|
199 |
+
return DatabaseResult(
|
200 |
+
success=True,
|
201 |
+
data=[result_data],
|
202 |
+
count=1
|
203 |
+
)
|
204 |
+
|
205 |
+
except Exception as e:
|
206 |
+
if db:
|
207 |
+
db.rollback()
|
208 |
+
logger.error(f"建立查詢錯誤: {str(e)}")
|
209 |
+
return DatabaseResult(
|
210 |
+
success=False,
|
211 |
+
error=f"建立失敗: {str(e)}"
|
212 |
+
)
|
213 |
+
finally:
|
214 |
+
if db:
|
215 |
+
close_database_session(db)
|
216 |
+
|
217 |
+
def _execute_update(self, parameters: Dict[str, Any]) -> DatabaseResult:
|
218 |
+
"""執行更新查詢"""
|
219 |
+
table_name = parameters.get("table", "users")
|
220 |
+
conditions = parameters.get("conditions", {})
|
221 |
+
data = parameters.get("data", {})
|
222 |
+
|
223 |
+
db = None
|
224 |
+
try:
|
225 |
+
db = get_database_session()
|
226 |
+
|
227 |
+
# 取得對應的模型
|
228 |
+
model = self.table_schemas.get(table_name, {}).get("model")
|
229 |
+
if not model:
|
230 |
+
return DatabaseResult(
|
231 |
+
success=False,
|
232 |
+
error=f"不支援的資料表: {table_name}"
|
233 |
+
)
|
234 |
+
|
235 |
+
# 建立查詢
|
236 |
+
query = db.query(model)
|
237 |
+
|
238 |
+
# 添加條件
|
239 |
+
for field, value in conditions.items():
|
240 |
+
if hasattr(model, field):
|
241 |
+
column = getattr(model, field)
|
242 |
+
query = query.filter(column == value)
|
243 |
+
|
244 |
+
# 執行更新
|
245 |
+
updated_count = query.update(data)
|
246 |
+
db.commit()
|
247 |
+
|
248 |
+
# 取得更新後的資料
|
249 |
+
updated_records = query.all()
|
250 |
+
|
251 |
+
# 轉換為字典格式
|
252 |
+
result_data = []
|
253 |
+
for record in updated_records:
|
254 |
+
item = {}
|
255 |
+
for column in record.__table__.columns:
|
256 |
+
value = getattr(record, column.name)
|
257 |
+
if hasattr(value, 'isoformat'): # datetime
|
258 |
+
item[column.name] = value.isoformat()
|
259 |
+
elif isinstance(value, (int, float, str, bool)) or value is None:
|
260 |
+
item[column.name] = value
|
261 |
+
else:
|
262 |
+
item[column.name] = str(value)
|
263 |
+
result_data.append(item)
|
264 |
+
|
265 |
+
return DatabaseResult(
|
266 |
+
success=True,
|
267 |
+
data=result_data,
|
268 |
+
count=updated_count
|
269 |
+
)
|
270 |
+
|
271 |
+
except Exception as e:
|
272 |
+
if db:
|
273 |
+
db.rollback()
|
274 |
+
logger.error(f"更新查詢錯誤: {str(e)}")
|
275 |
+
return DatabaseResult(
|
276 |
+
success=False,
|
277 |
+
error=f"更新失敗: {str(e)}"
|
278 |
+
)
|
279 |
+
finally:
|
280 |
+
if db:
|
281 |
+
close_database_session(db)
|
282 |
+
|
283 |
+
def _execute_delete(self, parameters: Dict[str, Any]) -> DatabaseResult:
|
284 |
+
"""執行刪除查詢"""
|
285 |
+
table_name = parameters.get("table", "users")
|
286 |
+
conditions = parameters.get("conditions", {})
|
287 |
+
|
288 |
+
db = None
|
289 |
+
try:
|
290 |
+
db = get_database_session()
|
291 |
+
|
292 |
+
# 取得對應的模型
|
293 |
+
model = self.table_schemas.get(table_name, {}).get("model")
|
294 |
+
if not model:
|
295 |
+
return DatabaseResult(
|
296 |
+
success=False,
|
297 |
+
error=f"不支援的資料表: {table_name}"
|
298 |
+
)
|
299 |
+
|
300 |
+
# 建立查詢
|
301 |
+
query = db.query(model)
|
302 |
+
|
303 |
+
# 添加條件
|
304 |
+
for field, value in conditions.items():
|
305 |
+
if hasattr(model, field):
|
306 |
+
column = getattr(model, field)
|
307 |
+
query = query.filter(column == value)
|
308 |
+
|
309 |
+
# 先取得要刪除的記錄
|
310 |
+
records_to_delete = query.all()
|
311 |
+
|
312 |
+
# 轉換為字典格式
|
313 |
+
result_data = []
|
314 |
+
for record in records_to_delete:
|
315 |
+
item = {}
|
316 |
+
for column in record.__table__.columns:
|
317 |
+
value = getattr(record, column.name)
|
318 |
+
if hasattr(value, 'isoformat'): # datetime
|
319 |
+
item[column.name] = value.isoformat()
|
320 |
+
elif isinstance(value, (int, float, str, bool)) or value is None:
|
321 |
+
item[column.name] = value
|
322 |
+
else:
|
323 |
+
item[column.name] = str(value)
|
324 |
+
result_data.append(item)
|
325 |
+
|
326 |
+
# 執行刪除
|
327 |
+
deleted_count = query.delete()
|
328 |
+
db.commit()
|
329 |
+
|
330 |
+
return DatabaseResult(
|
331 |
+
success=True,
|
332 |
+
data=result_data,
|
333 |
+
count=deleted_count
|
334 |
+
)
|
335 |
+
|
336 |
+
except Exception as e:
|
337 |
+
if db:
|
338 |
+
db.rollback()
|
339 |
+
logger.error(f"刪除查詢錯誤: {str(e)}")
|
340 |
+
return DatabaseResult(
|
341 |
+
success=False,
|
342 |
+
error=f"刪除失敗: {str(e)}"
|
343 |
+
)
|
344 |
+
finally:
|
345 |
+
if db:
|
346 |
+
close_database_session(db)
|
347 |
+
|
348 |
+
def _execute_analytics(self, parameters: Dict[str, Any]) -> DatabaseResult:
|
349 |
+
"""執行分析查詢"""
|
350 |
+
table_name = parameters.get("table", "users")
|
351 |
+
|
352 |
+
db = None
|
353 |
+
try:
|
354 |
+
db = get_database_session()
|
355 |
+
|
356 |
+
# 取得對應的模型
|
357 |
+
model = self.table_schemas.get(table_name, {}).get("model")
|
358 |
+
if not model:
|
359 |
+
return DatabaseResult(
|
360 |
+
success=False,
|
361 |
+
error=f"不支援的資料表: {table_name}"
|
362 |
+
)
|
363 |
+
|
364 |
+
# 執行計數查詢
|
365 |
+
total_count = db.query(model).count()
|
366 |
+
|
367 |
+
# 取得範例資料
|
368 |
+
sample_records = db.query(model).limit(5).all()
|
369 |
+
|
370 |
+
# 轉換範例資料為字典格式
|
371 |
+
sample_data = []
|
372 |
+
for record in sample_records:
|
373 |
+
item = {}
|
374 |
+
for column in record.__table__.columns:
|
375 |
+
value = getattr(record, column.name)
|
376 |
+
if hasattr(value, 'isoformat'): # datetime
|
377 |
+
item[column.name] = value.isoformat()
|
378 |
+
elif isinstance(value, (int, float, str, bool)) or value is None:
|
379 |
+
item[column.name] = value
|
380 |
+
else:
|
381 |
+
item[column.name] = str(value)
|
382 |
+
sample_data.append(item)
|
383 |
+
|
384 |
+
analytics_data = [{
|
385 |
+
"table": table_name,
|
386 |
+
"count": total_count,
|
387 |
+
"data_sample": sample_data
|
388 |
+
}]
|
389 |
+
|
390 |
+
return DatabaseResult(
|
391 |
+
success=True,
|
392 |
+
data=analytics_data,
|
393 |
+
count=1
|
394 |
+
)
|
395 |
+
|
396 |
+
except Exception as e:
|
397 |
+
logger.error(f"分析查詢錯誤: {str(e)}")
|
398 |
+
return DatabaseResult(
|
399 |
+
success=False,
|
400 |
+
error=f"分析失敗: {str(e)}"
|
401 |
+
)
|
402 |
+
finally:
|
403 |
+
if db:
|
404 |
+
close_database_session(db)
|
405 |
+
|
406 |
+
def save_message(self, user_id: str, message: str, message_type: str = "text") -> bool:
|
407 |
+
"""儲存訊息記錄"""
|
408 |
+
db = None
|
409 |
+
try:
|
410 |
+
db = get_database_session()
|
411 |
+
|
412 |
+
new_message = LineMessage(
|
413 |
+
user_id=user_id,
|
414 |
+
message=message,
|
415 |
+
message_type=message_type
|
416 |
+
)
|
417 |
+
|
418 |
+
db.add(new_message)
|
419 |
+
db.commit()
|
420 |
+
return True
|
421 |
+
|
422 |
+
except Exception as e:
|
423 |
+
if db:
|
424 |
+
db.rollback()
|
425 |
+
logger.error(f"儲存訊息錯誤: {str(e)}")
|
426 |
+
return False
|
427 |
+
finally:
|
428 |
+
if db:
|
429 |
+
close_database_session(db)
|
430 |
+
|
431 |
+
def get_user_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
|
432 |
+
"""取得用戶資料"""
|
433 |
+
db = None
|
434 |
+
try:
|
435 |
+
db = get_database_session()
|
436 |
+
|
437 |
+
user = db.query(User).filter(User.user_id == user_id).first()
|
438 |
+
if not user:
|
439 |
+
return None
|
440 |
+
|
441 |
+
# 轉換為字典格式
|
442 |
+
user_data = {}
|
443 |
+
for column in user.__table__.columns:
|
444 |
+
value = getattr(user, column.name)
|
445 |
+
if hasattr(value, 'isoformat'): # datetime
|
446 |
+
user_data[column.name] = value.isoformat()
|
447 |
+
elif isinstance(value, (int, float, str, bool)) or value is None:
|
448 |
+
user_data[column.name] = value
|
449 |
+
else:
|
450 |
+
user_data[column.name] = str(value)
|
451 |
+
|
452 |
+
return user_data
|
453 |
+
|
454 |
+
except Exception as e:
|
455 |
+
logger.error(f"取得用戶資料錯誤: {str(e)}")
|
456 |
+
return None
|
457 |
+
finally:
|
458 |
+
if db:
|
459 |
+
close_database_session(db)
|
460 |
+
|
461 |
+
def create_user_profile(self, user_data: Dict[str, Any]) -> bool:
|
462 |
+
"""建立用戶資料"""
|
463 |
+
db = None
|
464 |
+
try:
|
465 |
+
db = get_database_session()
|
466 |
+
|
467 |
+
new_user = User(**user_data)
|
468 |
+
db.add(new_user)
|
469 |
+
db.commit()
|
470 |
+
return True
|
471 |
+
|
472 |
+
except Exception as e:
|
473 |
+
if db:
|
474 |
+
db.rollback()
|
475 |
+
logger.error(f"建立用戶資料錯誤: {str(e)}")
|
476 |
+
return False
|
477 |
+
finally:
|
478 |
+
if db:
|
479 |
+
close_database_session(db)
|
480 |
+
|
481 |
+
# ==================== 業務專用查詢方法 ====================
|
482 |
+
|
483 |
+
def search_products(self, query_text: str = None, category: str = None,
|
484 |
+
warehouse: str = None, limit: int = 10) -> DatabaseResult:
|
485 |
+
"""商品查詢 - 支援自然語言查詢"""
|
486 |
+
db = None
|
487 |
+
try:
|
488 |
+
db = get_database_session()
|
489 |
+
|
490 |
+
# 建立基本查詢
|
491 |
+
query = db.query(Product)
|
492 |
+
|
493 |
+
# 如果有查詢文字,進行模糊搜尋
|
494 |
+
if query_text:
|
495 |
+
search_filter = or_(
|
496 |
+
Product.name.ilike(f"%{query_text}%"),
|
497 |
+
Product.description.ilike(f"%{query_text}%")
|
498 |
+
)
|
499 |
+
query = query.filter(search_filter)
|
500 |
+
|
501 |
+
# 類別篩選
|
502 |
+
if category:
|
503 |
+
query = query.filter(Product.category.ilike(f"%{category}%"))
|
504 |
+
|
505 |
+
# 執行查詢
|
506 |
+
products = query.limit(limit).all()
|
507 |
+
|
508 |
+
# 轉換為字典格式
|
509 |
+
data = []
|
510 |
+
for product in products:
|
511 |
+
product_data = {
|
512 |
+
"id": product.id,
|
513 |
+
"name": product.name,
|
514 |
+
"description": product.description,
|
515 |
+
"price": float(product.price) if product.price else 0.0,
|
516 |
+
"category": product.category,
|
517 |
+
"stock": product.stock if hasattr(product, 'stock') else 0,
|
518 |
+
"created_at": product.created_at.isoformat() if product.created_at else None
|
519 |
+
}
|
520 |
+
data.append(product_data)
|
521 |
+
|
522 |
+
return DatabaseResult(
|
523 |
+
success=True,
|
524 |
+
data=data,
|
525 |
+
count=len(data)
|
526 |
+
)
|
527 |
+
|
528 |
+
except Exception as e:
|
529 |
+
logger.error(f"商品查詢錯誤: {str(e)}")
|
530 |
+
return DatabaseResult(
|
531 |
+
success=False,
|
532 |
+
error=f"商品查詢失敗: {str(e)}"
|
533 |
+
)
|
534 |
+
finally:
|
535 |
+
if db:
|
536 |
+
close_database_session(db)
|
537 |
+
|
538 |
+
def check_inventory(self, product_name: str = None, category: str = None) -> DatabaseResult:
|
539 |
+
"""庫存查詢"""
|
540 |
+
db = None
|
541 |
+
try:
|
542 |
+
db = get_database_session()
|
543 |
+
|
544 |
+
# 建立查詢
|
545 |
+
query = db.query(Product)
|
546 |
+
|
547 |
+
# 根據商品名稱查詢
|
548 |
+
if product_name:
|
549 |
+
query = query.filter(Product.name.ilike(f"%{product_name}%"))
|
550 |
+
|
551 |
+
# 類別篩選
|
552 |
+
if category:
|
553 |
+
query = query.filter(Product.category.ilike(f"%{category}%"))
|
554 |
+
|
555 |
+
products = query.all()
|
556 |
+
|
557 |
+
# 準備庫存資料
|
558 |
+
inventory_data = []
|
559 |
+
for product in products:
|
560 |
+
stock_info = {
|
561 |
+
"product_name": product.name,
|
562 |
+
"category": product.category,
|
563 |
+
"current_stock": product.stock if hasattr(product, 'stock') else 0,
|
564 |
+
"price": float(product.price) if product.price else 0.0,
|
565 |
+
"description": product.description,
|
566 |
+
"last_updated": product.updated_at.isoformat() if product.updated_at else None
|
567 |
+
}
|
568 |
+
inventory_data.append(stock_info)
|
569 |
+
|
570 |
+
return DatabaseResult(
|
571 |
+
success=True,
|
572 |
+
data=inventory_data,
|
573 |
+
count=len(inventory_data)
|
574 |
+
)
|
575 |
+
|
576 |
+
except Exception as e:
|
577 |
+
logger.error(f"庫存查詢錯誤: {str(e)}")
|
578 |
+
return DatabaseResult(
|
579 |
+
success=False,
|
580 |
+
error=f"庫存查詢失敗: {str(e)}"
|
581 |
+
)
|
582 |
+
finally:
|
583 |
+
if db:
|
584 |
+
close_database_session(db)
|
585 |
+
|
586 |
+
def search_orders(self, user_id: str = None, status: str = None, limit: int = 10) -> DatabaseResult:
|
587 |
+
"""訂單查詢"""
|
588 |
+
db = None
|
589 |
+
try:
|
590 |
+
db = get_database_session()
|
591 |
+
|
592 |
+
query = db.query(Order)
|
593 |
+
|
594 |
+
# 用戶篩選
|
595 |
+
if user_id:
|
596 |
+
query = query.filter(Order.user_id == user_id)
|
597 |
+
|
598 |
+
# 狀態篩選
|
599 |
+
if status:
|
600 |
+
query = query.filter(Order.status.ilike(f"%{status}%"))
|
601 |
+
|
602 |
+
orders = query.limit(limit).all()
|
603 |
+
|
604 |
+
# 轉換為字典格式
|
605 |
+
order_data = []
|
606 |
+
for order in orders:
|
607 |
+
order_info = {
|
608 |
+
"order_id": order.order_id,
|
609 |
+
"user_id": order.user_id,
|
610 |
+
"status": order.status,
|
611 |
+
"total_amount": float(order.total_amount) if order.total_amount else 0.0,
|
612 |
+
"created_at": order.created_at.isoformat() if order.created_at else None,
|
613 |
+
"updated_at": order.updated_at.isoformat() if order.updated_at else None
|
614 |
+
}
|
615 |
+
order_data.append(order_info)
|
616 |
+
|
617 |
+
return DatabaseResult(
|
618 |
+
success=True,
|
619 |
+
data=order_data,
|
620 |
+
count=len(order_data)
|
621 |
+
)
|
622 |
+
|
623 |
+
except Exception as e:
|
624 |
+
logger.error(f"訂單查詢錯誤: {str(e)}")
|
625 |
+
return DatabaseResult(
|
626 |
+
success=False,
|
627 |
+
error=f"訂單查詢失敗: {str(e)}"
|
628 |
+
)
|
629 |
+
finally:
|
630 |
+
if db:
|
631 |
+
close_database_session(db)
|
632 |
+
|
633 |
+
def get_low_stock_products(self, threshold: int = 10) -> DatabaseResult:
|
634 |
+
"""低庫存商品查詢"""
|
635 |
+
db = None
|
636 |
+
try:
|
637 |
+
db = get_database_session()
|
638 |
+
|
639 |
+
# 查詢庫存低於閾值的商品
|
640 |
+
# 注意:這裡假設 Product 模型有 stock 欄位,需要根據實際情況調整
|
641 |
+
query = db.query(Product)
|
642 |
+
if hasattr(Product, 'stock'):
|
643 |
+
query = query.filter(Product.stock <= threshold)
|
644 |
+
|
645 |
+
low_stock_products = query.all()
|
646 |
+
|
647 |
+
data = []
|
648 |
+
for product in low_stock_products:
|
649 |
+
product_data = {
|
650 |
+
"product_name": product.name,
|
651 |
+
"current_stock": product.stock if hasattr(product, 'stock') else 0,
|
652 |
+
"category": product.category,
|
653 |
+
"price": float(product.price) if product.price else 0.0,
|
654 |
+
"status": "庫存不足"
|
655 |
+
}
|
656 |
+
data.append(product_data)
|
657 |
+
|
658 |
+
return DatabaseResult(
|
659 |
+
success=True,
|
660 |
+
data=data,
|
661 |
+
count=len(data)
|
662 |
+
)
|
663 |
+
|
664 |
+
except Exception as e:
|
665 |
+
logger.error(f"低庫存查詢錯誤: {str(e)}")
|
666 |
+
return DatabaseResult(
|
667 |
+
success=False,
|
668 |
+
error=f"低庫存查詢失敗: {str(e)}"
|
669 |
+
)
|
670 |
+
finally:
|
671 |
+
if db:
|
672 |
+
close_database_session(db)
|
673 |
+
|
674 |
+
def get_business_summary(self) -> DatabaseResult:
|
675 |
+
"""業務摘要統計"""
|
676 |
+
db = None
|
677 |
+
try:
|
678 |
+
db = get_database_session()
|
679 |
+
|
680 |
+
# 基本統計
|
681 |
+
total_products = db.query(Product).count()
|
682 |
+
total_orders = db.query(Order).count()
|
683 |
+
total_users = db.query(User).count()
|
684 |
+
|
685 |
+
# 計算低庫存商品數量
|
686 |
+
low_stock_count = 0
|
687 |
+
if hasattr(Product, 'stock'):
|
688 |
+
low_stock_count = db.query(Product).filter(Product.stock <= 10).count()
|
689 |
+
|
690 |
+
# 準備摘要資料
|
691 |
+
summary_data = [{
|
692 |
+
"total_products": total_products,
|
693 |
+
"total_orders": total_orders,
|
694 |
+
"total_users": total_users,
|
695 |
+
"low_stock_items": low_stock_count,
|
696 |
+
"report_date": datetime.now().isoformat()
|
697 |
+
}]
|
698 |
+
|
699 |
+
return DatabaseResult(
|
700 |
+
success=True,
|
701 |
+
data=summary_data,
|
702 |
+
count=1
|
703 |
+
)
|
704 |
+
|
705 |
+
except Exception as e:
|
706 |
+
logger.error(f"業務摘要查詢錯誤: {str(e)}")
|
707 |
+
return DatabaseResult(
|
708 |
+
success=False,
|
709 |
+
error=f"業務摘要查詢失敗: {str(e)}"
|
710 |
+
)
|
711 |
+
finally:
|
712 |
+
if db:
|
713 |
+
close_database_session(db)
|
714 |
+
|
715 |
+
def process_natural_language_query(self, user_message: str, user_id: str = None) -> DatabaseResult:
|
716 |
+
"""處理自然語言查詢的統一入口"""
|
717 |
+
try:
|
718 |
+
# 簡單的關鍵字匹配來判斷查詢意圖
|
719 |
+
message_lower = user_message.lower()
|
720 |
+
|
721 |
+
# 商品查詢
|
722 |
+
if any(keyword in message_lower for keyword in ['商品', '產品', '貨品', '物品']):
|
723 |
+
if any(keyword in message_lower for keyword in ['庫存', '存貨', '剩餘']):
|
724 |
+
# 庫存查詢
|
725 |
+
product_name = self._extract_product_name(user_message)
|
726 |
+
return self.check_inventory(product_name=product_name)
|
727 |
+
else:
|
728 |
+
# 一般商品查詢
|
729 |
+
product_name = self._extract_product_name(user_message)
|
730 |
+
return self.search_products(query_text=product_name)
|
731 |
+
|
732 |
+
# 訂單查詢
|
733 |
+
elif any(keyword in message_lower for keyword in ['訂單', '訂購', '購買']):
|
734 |
+
return self.search_orders(user_id=user_id)
|
735 |
+
|
736 |
+
# 低庫存查詢
|
737 |
+
elif any(keyword in message_lower for keyword in ['低庫存', '缺貨', '不足']):
|
738 |
+
return self.get_low_stock_products()
|
739 |
+
|
740 |
+
# 統計查詢
|
741 |
+
elif any(keyword in message_lower for keyword in ['統計', '摘要', '總計', '報表']):
|
742 |
+
return self.get_business_summary()
|
743 |
+
|
744 |
+
else:
|
745 |
+
# 預設進行商品搜尋
|
746 |
+
return self.search_products(query_text=user_message)
|
747 |
+
|
748 |
+
except Exception as e:
|
749 |
+
logger.error(f"自然語言查詢處理錯誤: {str(e)}")
|
750 |
+
return DatabaseResult(
|
751 |
+
success=False,
|
752 |
+
error=f"查詢處理失敗: {str(e)}"
|
753 |
+
)
|
754 |
+
|
755 |
+
def _extract_product_name(self, message: str) -> str:
|
756 |
+
"""從訊息中提取商品名稱"""
|
757 |
+
# 簡單的商品名稱提取邏輯
|
758 |
+
# 移除常見的查詢關鍵字
|
759 |
+
keywords_to_remove = ['查詢', '搜尋', '找', '商品', '產品', '庫存', '有沒有', '請問']
|
760 |
+
|
761 |
+
cleaned_message = message
|
762 |
+
for keyword in keywords_to_remove:
|
763 |
+
cleaned_message = cleaned_message.replace(keyword, '')
|
764 |
+
|
765 |
+
return cleaned_message.strip()
|
backend/services/line_bot_service.py
ADDED
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
LINE Bot 服務 - 整合業務查詢功能
|
3 |
+
處理來自 LINE 官方帳號的用戶訊息
|
4 |
+
"""
|
5 |
+
|
6 |
+
import logging
|
7 |
+
from typing import Dict, Any, Optional
|
8 |
+
from backend.services.business_query_service import BusinessQueryService
|
9 |
+
from backend.services.database_service import DatabaseService
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class LineBotService:
|
14 |
+
"""LINE Bot 服務類"""
|
15 |
+
|
16 |
+
def __init__(self):
|
17 |
+
self.business_query_service = BusinessQueryService()
|
18 |
+
self.db_service = DatabaseService()
|
19 |
+
|
20 |
+
def handle_text_message(self, user_id: str, message_text: str, display_name: str = None) -> Dict[str, Any]:
|
21 |
+
"""
|
22 |
+
處理 LINE 用戶的文字訊息
|
23 |
+
|
24 |
+
Args:
|
25 |
+
user_id: LINE 用戶ID
|
26 |
+
message_text: 用戶發送的訊息內容
|
27 |
+
display_name: 用戶顯示名稱(可選)
|
28 |
+
|
29 |
+
Returns:
|
30 |
+
包含回應訊息和相關資料的字典
|
31 |
+
"""
|
32 |
+
try:
|
33 |
+
# 記錄用戶訊息
|
34 |
+
logger.info(f"收到來自用戶 {user_id} 的訊息: {message_text}")
|
35 |
+
|
36 |
+
# 檢查是否為特殊指令
|
37 |
+
if message_text.lower() in ['help', '幫助', '說明', '指令']:
|
38 |
+
return self._handle_help_command(user_id)
|
39 |
+
|
40 |
+
elif message_text.lower() in ['menu', '選單', '功能']:
|
41 |
+
return self._handle_menu_command(user_id)
|
42 |
+
|
43 |
+
# 確保用戶資料存在
|
44 |
+
self._ensure_user_profile(user_id, display_name)
|
45 |
+
|
46 |
+
# 處理業務查詢
|
47 |
+
query_result = self.business_query_service.process_user_query(message_text, user_id)
|
48 |
+
|
49 |
+
# 準備回應
|
50 |
+
response = {
|
51 |
+
"type": "text",
|
52 |
+
"text": query_result["response_message"],
|
53 |
+
"user_id": user_id,
|
54 |
+
"success": query_result["success"],
|
55 |
+
"intent": query_result["intent"],
|
56 |
+
"confidence": query_result["confidence"]
|
57 |
+
}
|
58 |
+
|
59 |
+
# 如果查詢成功且有資料,可以添加快速回覆按鈕
|
60 |
+
if query_result["success"] and query_result["data"]:
|
61 |
+
response["quick_reply"] = self._generate_quick_reply_buttons(query_result["intent"])
|
62 |
+
|
63 |
+
return response
|
64 |
+
|
65 |
+
except Exception as e:
|
66 |
+
logger.error(f"處理 LINE 訊息時發生錯誤: {str(e)}")
|
67 |
+
return {
|
68 |
+
"type": "text",
|
69 |
+
"text": "抱歉,系統暫時無法處理您的請求,請稍後再試。",
|
70 |
+
"user_id": user_id,
|
71 |
+
"success": False,
|
72 |
+
"error": str(e)
|
73 |
+
}
|
74 |
+
|
75 |
+
def _handle_help_command(self, user_id: str) -> Dict[str, Any]:
|
76 |
+
"""處理幫助指令"""
|
77 |
+
help_message = self.business_query_service.get_help_message()
|
78 |
+
|
79 |
+
return {
|
80 |
+
"type": "text",
|
81 |
+
"text": help_message,
|
82 |
+
"user_id": user_id,
|
83 |
+
"success": True,
|
84 |
+
"intent": "help"
|
85 |
+
}
|
86 |
+
|
87 |
+
def _handle_menu_command(self, user_id: str) -> Dict[str, Any]:
|
88 |
+
"""處理選單指令"""
|
89 |
+
menu_message = """
|
90 |
+
🏪 智能查詢系統主選單
|
91 |
+
|
92 |
+
請選擇您需要的功能:
|
93 |
+
|
94 |
+
1️⃣ 商品查詢
|
95 |
+
輸入:查詢商品 [商品名稱]
|
96 |
+
|
97 |
+
2️⃣ 庫存查詢
|
98 |
+
輸入:庫存查詢 [商品名稱]
|
99 |
+
|
100 |
+
3️⃣ 訂單查詢
|
101 |
+
輸入:我的訂單
|
102 |
+
|
103 |
+
4️⃣ 低庫存警告
|
104 |
+
輸入:低庫存商品
|
105 |
+
|
106 |
+
5️⃣ 業務統計
|
107 |
+
輸入:業務摘要
|
108 |
+
|
109 |
+
💡 您也可以直接用自然語言提問!
|
110 |
+
例如:「iPhone 還有多少庫存?」
|
111 |
+
|
112 |
+
輸入「幫助」查看詳細說明
|
113 |
+
"""
|
114 |
+
|
115 |
+
return {
|
116 |
+
"type": "text",
|
117 |
+
"text": menu_message.strip(),
|
118 |
+
"user_id": user_id,
|
119 |
+
"success": True,
|
120 |
+
"intent": "menu",
|
121 |
+
"quick_reply": {
|
122 |
+
"items": [
|
123 |
+
{"type": "action", "action": {"type": "message", "label": "商品查詢", "text": "查詢商品"}},
|
124 |
+
{"type": "action", "action": {"type": "message", "label": "庫存查詢", "text": "庫存查詢"}},
|
125 |
+
{"type": "action", "action": {"type": "message", "label": "我的訂單", "text": "我的訂單"}},
|
126 |
+
{"type": "action", "action": {"type": "message", "label": "低庫存", "text": "低庫存商品"}},
|
127 |
+
{"type": "action", "action": {"type": "message", "label": "業務統計", "text": "業務摘要"}}
|
128 |
+
]
|
129 |
+
}
|
130 |
+
}
|
131 |
+
|
132 |
+
def _ensure_user_profile(self, user_id: str, display_name: str = None):
|
133 |
+
"""確保用戶資料存在於資料庫中"""
|
134 |
+
try:
|
135 |
+
# 檢查用戶是否已存在
|
136 |
+
existing_user = self.db_service.get_user_profile(user_id)
|
137 |
+
|
138 |
+
if not existing_user:
|
139 |
+
# 建立新用戶資料
|
140 |
+
user_data = {
|
141 |
+
"user_id": user_id,
|
142 |
+
"display_name": display_name or f"用戶_{user_id[:8]}",
|
143 |
+
"created_at": "now()"
|
144 |
+
}
|
145 |
+
|
146 |
+
success = self.db_service.create_user_profile(user_data)
|
147 |
+
if success:
|
148 |
+
logger.info(f"已建立新用戶資料: {user_id}")
|
149 |
+
else:
|
150 |
+
logger.warning(f"建立用戶資料失敗: {user_id}")
|
151 |
+
|
152 |
+
except Exception as e:
|
153 |
+
logger.error(f"處理用戶資料時發生錯誤: {str(e)}")
|
154 |
+
|
155 |
+
def _generate_quick_reply_buttons(self, intent: str) -> Dict[str, Any]:
|
156 |
+
"""根據查詢意圖生成快速回覆按鈕"""
|
157 |
+
|
158 |
+
if intent == "product_search":
|
159 |
+
return {
|
160 |
+
"items": [
|
161 |
+
{"type": "action", "action": {"type": "message", "label": "查看庫存", "text": "庫存查詢"}},
|
162 |
+
{"type": "action", "action": {"type": "message", "label": "相關商品", "text": "查詢商品"}},
|
163 |
+
{"type": "action", "action": {"type": "message", "label": "主選單", "text": "選單"}}
|
164 |
+
]
|
165 |
+
}
|
166 |
+
|
167 |
+
elif intent == "inventory_check":
|
168 |
+
return {
|
169 |
+
"items": [
|
170 |
+
{"type": "action", "action": {"type": "message", "label": "低庫存警告", "text": "低庫存商品"}},
|
171 |
+
{"type": "action", "action": {"type": "message", "label": "商品詳情", "text": "查詢商品"}},
|
172 |
+
{"type": "action", "action": {"type": "message", "label": "主選單", "text": "選單"}}
|
173 |
+
]
|
174 |
+
}
|
175 |
+
|
176 |
+
elif intent == "order_search":
|
177 |
+
return {
|
178 |
+
"items": [
|
179 |
+
{"type": "action", "action": {"type": "message", "label": "業務統計", "text": "業務摘要"}},
|
180 |
+
{"type": "action", "action": {"type": "message", "label": "主選單", "text": "選單"}}
|
181 |
+
]
|
182 |
+
}
|
183 |
+
|
184 |
+
else:
|
185 |
+
return {
|
186 |
+
"items": [
|
187 |
+
{"type": "action", "action": {"type": "message", "label": "商品查詢", "text": "查詢商品"}},
|
188 |
+
{"type": "action", "action": {"type": "message", "label": "庫存查詢", "text": "庫存查詢"}},
|
189 |
+
{"type": "action", "action": {"type": "message", "label": "主選單", "text": "選單"}}
|
190 |
+
]
|
191 |
+
}
|
192 |
+
|
193 |
+
def handle_postback_action(self, user_id: str, postback_data: str) -> Dict[str, Any]:
|
194 |
+
"""處理 Postback 動作(按鈕點擊等)"""
|
195 |
+
try:
|
196 |
+
# 解析 postback 資料
|
197 |
+
if postback_data.startswith("query_"):
|
198 |
+
query_type = postback_data.replace("query_", "")
|
199 |
+
|
200 |
+
if query_type == "products":
|
201 |
+
return self.handle_text_message(user_id, "查詢商品")
|
202 |
+
elif query_type == "inventory":
|
203 |
+
return self.handle_text_message(user_id, "庫存查詢")
|
204 |
+
elif query_type == "orders":
|
205 |
+
return self.handle_text_message(user_id, "我的訂單")
|
206 |
+
elif query_type == "summary":
|
207 |
+
return self.handle_text_message(user_id, "業務摘要")
|
208 |
+
|
209 |
+
# 預設回應
|
210 |
+
return self._handle_menu_command(user_id)
|
211 |
+
|
212 |
+
except Exception as e:
|
213 |
+
logger.error(f"處理 Postback 動作時發生錯誤: {str(e)}")
|
214 |
+
return {
|
215 |
+
"type": "text",
|
216 |
+
"text": "抱歉,無法處理此操作。",
|
217 |
+
"user_id": user_id,
|
218 |
+
"success": False,
|
219 |
+
"error": str(e)
|
220 |
+
}
|
221 |
+
|
222 |
+
def get_user_activity_summary(self, user_id: str, days: int = 7) -> Dict[str, Any]:
|
223 |
+
"""取得用戶活動摘要"""
|
224 |
+
try:
|
225 |
+
# 這裡可以實作用戶活動統計
|
226 |
+
# 例如:查詢次數、常用功能等
|
227 |
+
|
228 |
+
summary = {
|
229 |
+
"user_id": user_id,
|
230 |
+
"period_days": days,
|
231 |
+
"total_queries": 0, # 從資料庫查詢
|
232 |
+
"most_used_features": [], # 最常使用的功能
|
233 |
+
"last_activity": None # 最後活動時間
|
234 |
+
}
|
235 |
+
|
236 |
+
return {
|
237 |
+
"success": True,
|
238 |
+
"data": summary
|
239 |
+
}
|
240 |
+
|
241 |
+
except Exception as e:
|
242 |
+
logger.error(f"取得用戶活動摘要時發生錯誤: {str(e)}")
|
243 |
+
return {
|
244 |
+
"success": False,
|
245 |
+
"error": str(e)
|
246 |
+
}
|
backend/services/nlp_service.py
ADDED
@@ -0,0 +1,653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
import json
|
3 |
+
from typing import Dict, Any, List
|
4 |
+
from backend.models.schemas import NLPAnalysisResult, QueryType, DatabaseResult
|
5 |
+
from backend.config import settings
|
6 |
+
from backend.services.openrouter_service import OpenRouterService
|
7 |
+
import logging
|
8 |
+
import asyncio
|
9 |
+
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
class NLPService:
|
13 |
+
"""自然語言處理服務"""
|
14 |
+
|
15 |
+
def __init__(self):
|
16 |
+
self.openrouter_service = OpenRouterService()
|
17 |
+
self.business_intent_patterns = {
|
18 |
+
"product_search": [
|
19 |
+
r"查詢.*商品|找.*商品|搜尋.*商品|商品.*資料",
|
20 |
+
r"商品.*查詢|商品.*搜尋|產品.*查詢|產品.*搜尋",
|
21 |
+
r"有什麼.*商品|商品.*價格|產品.*價格"
|
22 |
+
],
|
23 |
+
"inventory_check": [
|
24 |
+
r"庫存.*查詢|查詢.*庫存|庫存.*多少|剩餘.*數量",
|
25 |
+
r"存貨.*查詢|查詢.*存貨|還有.*多少|庫存.*狀況",
|
26 |
+
r".*庫存|.*存貨|.*剩餘"
|
27 |
+
],
|
28 |
+
"order_search": [
|
29 |
+
r"查詢.*訂單|找.*訂單|搜尋.*訂單|訂單.*資料",
|
30 |
+
r"訂單.*查詢|訂單.*搜尋|訂單.*狀態|我的.*訂單",
|
31 |
+
r"訂單編號|購買.*記錄"
|
32 |
+
],
|
33 |
+
"customer_search": [
|
34 |
+
r"查詢.*客戶|找.*客戶|搜尋.*客戶|客戶.*資料",
|
35 |
+
r"客戶.*查詢|客戶.*搜尋|客戶.*聯絡"
|
36 |
+
],
|
37 |
+
"low_stock_alert": [
|
38 |
+
r"低庫存|缺貨|庫存.*不足|存貨.*不足",
|
39 |
+
r"快.*沒有|即將.*缺貨|庫存.*警告"
|
40 |
+
],
|
41 |
+
"business_summary": [
|
42 |
+
r"統計|分析|報表|數據|摘要",
|
43 |
+
r"總計|總數|多少.*筆|幾.*筆|業務.*狀況"
|
44 |
+
]
|
45 |
+
}
|
46 |
+
self.intent_patterns = {
|
47 |
+
"search_user": [
|
48 |
+
r"查詢.*用戶|找.*用戶|搜尋.*用戶|用戶.*資料",
|
49 |
+
r"用戶.*查詢|用戶.*搜尋|用戶.*找",
|
50 |
+
r"誰是|哪個用戶|用戶名.*是"
|
51 |
+
],
|
52 |
+
"search_order": [
|
53 |
+
r"查詢.*訂單|找.*訂單|搜尋.*訂單|訂單.*資料",
|
54 |
+
r"訂單.*查詢|訂單.*搜尋|訂單.*狀態",
|
55 |
+
r"我的訂單|訂單編號"
|
56 |
+
],
|
57 |
+
"search_product": [
|
58 |
+
r"查詢.*商品|找.*商品|搜尋.*商品|商品.*資料",
|
59 |
+
r"商品.*查詢|商品.*搜尋|產品.*查詢",
|
60 |
+
r"有什麼.*商品|商品.*價格"
|
61 |
+
],
|
62 |
+
"create_order": [
|
63 |
+
r"建立.*訂單|新增.*訂單|下訂|購買",
|
64 |
+
r"我要.*買|我想.*買|訂購"
|
65 |
+
],
|
66 |
+
"update_profile": [
|
67 |
+
r"更新.*資料|修改.*資料|變更.*資料",
|
68 |
+
r"更新.*個人|修改.*個人|個人.*資料"
|
69 |
+
],
|
70 |
+
"analytics": [
|
71 |
+
r"統計|分析|報表|數據",
|
72 |
+
r"總計|總數|多少.*筆|幾.*筆"
|
73 |
+
]
|
74 |
+
}
|
75 |
+
|
76 |
+
self.entity_patterns = {
|
77 |
+
"user_id": r"用戶ID[::]?\s*([A-Za-z0-9]+)",
|
78 |
+
"user_name": r"用戶名[::]?\s*([^\s]+)|名字[::]?\s*([^\s]+)",
|
79 |
+
"order_id": r"訂單[編號ID][::]?\s*([A-Za-z0-9\-]+)",
|
80 |
+
"product_name": r"商品[::]?\s*([^\s]+)|產品[::]?\s*([^\s]+)",
|
81 |
+
"price_range": r"價格.*?(\d+).*?到.*?(\d+)|(\d+).*?元.*?到.*?(\d+).*?元",
|
82 |
+
"date_range": r"(\d{4}[-/]\d{1,2}[-/]\d{1,2})",
|
83 |
+
"number": r"(\d+)"
|
84 |
+
}
|
85 |
+
|
86 |
+
def analyze_message(self, message: str, use_advanced: bool = True) -> NLPAnalysisResult:
|
87 |
+
"""分析用戶訊息"""
|
88 |
+
try:
|
89 |
+
# 如果啟用進階分析且有 OpenRouter API Key
|
90 |
+
if use_advanced and self.openrouter_service.api_key:
|
91 |
+
try:
|
92 |
+
# 使用 asyncio 執行異步分析
|
93 |
+
loop = asyncio.new_event_loop()
|
94 |
+
asyncio.set_event_loop(loop)
|
95 |
+
advanced_result = loop.run_until_complete(
|
96 |
+
self.openrouter_service.analyze_intent_advanced(message)
|
97 |
+
)
|
98 |
+
loop.close()
|
99 |
+
|
100 |
+
if not advanced_result.get("fallback", False):
|
101 |
+
return NLPAnalysisResult(
|
102 |
+
query_type=QueryType(advanced_result.get("query_type", "unknown")),
|
103 |
+
intent=advanced_result.get("intent", "unknown"),
|
104 |
+
entities=advanced_result.get("entities", {}),
|
105 |
+
confidence=advanced_result.get("confidence", 0.5),
|
106 |
+
parameters=advanced_result.get("parameters", {})
|
107 |
+
)
|
108 |
+
except Exception as e:
|
109 |
+
logger.warning(f"進階 NLP 分析失敗,使用基礎分析: {str(e)}")
|
110 |
+
|
111 |
+
# 使用基礎規則引擎分析
|
112 |
+
return self._basic_analyze_message(message)
|
113 |
+
|
114 |
+
except Exception as e:
|
115 |
+
logger.error(f"NLP 分析錯誤: {str(e)}")
|
116 |
+
return NLPAnalysisResult(
|
117 |
+
query_type=QueryType.UNKNOWN,
|
118 |
+
intent="unknown",
|
119 |
+
entities={},
|
120 |
+
confidence=0.0,
|
121 |
+
parameters={}
|
122 |
+
)
|
123 |
+
|
124 |
+
def _basic_analyze_message(self, message: str) -> NLPAnalysisResult:
|
125 |
+
"""基礎訊息分析(規則引擎)"""
|
126 |
+
# 清理訊息
|
127 |
+
cleaned_message = self._clean_message(message)
|
128 |
+
|
129 |
+
# 識別意圖
|
130 |
+
intent, confidence = self._identify_intent(cleaned_message)
|
131 |
+
|
132 |
+
# 提取實體
|
133 |
+
entities = self._extract_entities(cleaned_message)
|
134 |
+
|
135 |
+
# 確定查詢類型
|
136 |
+
query_type = self._determine_query_type(intent)
|
137 |
+
|
138 |
+
# 生成查詢參數
|
139 |
+
parameters = self._generate_parameters(intent, entities)
|
140 |
+
|
141 |
+
return NLPAnalysisResult(
|
142 |
+
query_type=query_type,
|
143 |
+
intent=intent,
|
144 |
+
entities=entities,
|
145 |
+
confidence=confidence,
|
146 |
+
parameters=parameters
|
147 |
+
)
|
148 |
+
|
149 |
+
def _clean_message(self, message: str) -> str:
|
150 |
+
"""清理訊息"""
|
151 |
+
# 移除多餘空白
|
152 |
+
message = re.sub(r'\s+', ' ', message.strip())
|
153 |
+
return message
|
154 |
+
|
155 |
+
def _identify_intent(self, message: str) -> tuple[str, float]:
|
156 |
+
"""識別用戶意圖"""
|
157 |
+
best_intent = "unknown"
|
158 |
+
best_score = 0.0
|
159 |
+
|
160 |
+
for intent, patterns in self.intent_patterns.items():
|
161 |
+
for pattern in patterns:
|
162 |
+
if re.search(pattern, message, re.IGNORECASE):
|
163 |
+
score = len(re.findall(pattern, message, re.IGNORECASE)) / len(message.split())
|
164 |
+
if score > best_score:
|
165 |
+
best_score = score
|
166 |
+
best_intent = intent
|
167 |
+
|
168 |
+
# 如果沒有匹配到任何模式,設定基本信心度
|
169 |
+
confidence = max(best_score, 0.3) if best_intent != "unknown" else 0.1
|
170 |
+
|
171 |
+
return best_intent, min(confidence, 1.0)
|
172 |
+
|
173 |
+
def _extract_entities(self, message: str) -> Dict[str, Any]:
|
174 |
+
"""提取實體"""
|
175 |
+
entities = {}
|
176 |
+
|
177 |
+
for entity_type, pattern in self.entity_patterns.items():
|
178 |
+
matches = re.findall(pattern, message, re.IGNORECASE)
|
179 |
+
if matches:
|
180 |
+
if entity_type == "price_range":
|
181 |
+
# 處理價格範圍
|
182 |
+
for match in matches:
|
183 |
+
if isinstance(match, tuple):
|
184 |
+
prices = [p for p in match if p]
|
185 |
+
if len(prices) >= 2:
|
186 |
+
entities["min_price"] = int(prices[0])
|
187 |
+
entities["max_price"] = int(prices[1])
|
188 |
+
elif entity_type == "user_name":
|
189 |
+
# 處理用戶名(可能有多個捕獲組)
|
190 |
+
for match in matches:
|
191 |
+
if isinstance(match, tuple):
|
192 |
+
name = next((n for n in match if n), None)
|
193 |
+
if name:
|
194 |
+
entities[entity_type] = name
|
195 |
+
else:
|
196 |
+
entities[entity_type] = match
|
197 |
+
else:
|
198 |
+
entities[entity_type] = matches[0] if isinstance(matches[0], str) else matches[0][0]
|
199 |
+
|
200 |
+
return entities
|
201 |
+
|
202 |
+
def _determine_query_type(self, intent: str) -> QueryType:
|
203 |
+
"""確定查詢類型"""
|
204 |
+
if intent.startswith("search_"):
|
205 |
+
return QueryType.SEARCH
|
206 |
+
elif intent.startswith("create_"):
|
207 |
+
return QueryType.CREATE
|
208 |
+
elif intent.startswith("update_"):
|
209 |
+
return QueryType.UPDATE
|
210 |
+
elif intent.startswith("delete_"):
|
211 |
+
return QueryType.DELETE
|
212 |
+
elif intent == "analytics":
|
213 |
+
return QueryType.ANALYTICS
|
214 |
+
else:
|
215 |
+
return QueryType.UNKNOWN
|
216 |
+
|
217 |
+
def _generate_parameters(self, intent: str, entities: Dict[str, Any]) -> Dict[str, Any]:
|
218 |
+
"""生成查詢參數"""
|
219 |
+
parameters = {}
|
220 |
+
|
221 |
+
# 根據意圖設定表名
|
222 |
+
if "user" in intent:
|
223 |
+
parameters["table"] = "users"
|
224 |
+
elif "order" in intent:
|
225 |
+
parameters["table"] = "orders"
|
226 |
+
elif "product" in intent:
|
227 |
+
parameters["table"] = "products"
|
228 |
+
|
229 |
+
# 設定查詢條件
|
230 |
+
conditions = {}
|
231 |
+
if "user_id" in entities:
|
232 |
+
conditions["user_id"] = entities["user_id"]
|
233 |
+
if "user_name" in entities:
|
234 |
+
conditions["name"] = entities["user_name"]
|
235 |
+
if "order_id" in entities:
|
236 |
+
conditions["order_id"] = entities["order_id"]
|
237 |
+
if "product_name" in entities:
|
238 |
+
conditions["name"] = entities["product_name"]
|
239 |
+
if "min_price" in entities and "max_price" in entities:
|
240 |
+
conditions["price"] = {
|
241 |
+
"gte": entities["min_price"],
|
242 |
+
"lte": entities["max_price"]
|
243 |
+
}
|
244 |
+
|
245 |
+
if conditions:
|
246 |
+
parameters["conditions"] = conditions
|
247 |
+
|
248 |
+
# 設定限制
|
249 |
+
if "number" in entities:
|
250 |
+
parameters["limit"] = min(int(entities["number"]), 50) # 最多50筆
|
251 |
+
else:
|
252 |
+
parameters["limit"] = 10 # 預設10筆
|
253 |
+
|
254 |
+
return parameters
|
255 |
+
|
256 |
+
def format_response(self, db_result: DatabaseResult, analysis_result: NLPAnalysisResult, user_message: str = "", use_advanced: bool = True) -> str:
|
257 |
+
"""格式化回應訊息"""
|
258 |
+
try:
|
259 |
+
# 如果啟用進階回應且有 OpenRouter API Key
|
260 |
+
if use_advanced and user_message and self.openrouter_service.api_key:
|
261 |
+
try:
|
262 |
+
# 準備查詢結果資料
|
263 |
+
query_result = {
|
264 |
+
"success": db_result.success,
|
265 |
+
"data": db_result.data,
|
266 |
+
"count": db_result.count,
|
267 |
+
"error": db_result.error
|
268 |
+
}
|
269 |
+
|
270 |
+
# 使用 asyncio 執行異步回應生成
|
271 |
+
loop = asyncio.new_event_loop()
|
272 |
+
asyncio.set_event_loop(loop)
|
273 |
+
advanced_response = loop.run_until_complete(
|
274 |
+
self.openrouter_service.generate_response(query_result, user_message)
|
275 |
+
)
|
276 |
+
loop.close()
|
277 |
+
|
278 |
+
if advanced_response and len(advanced_response.strip()) > 0:
|
279 |
+
return advanced_response
|
280 |
+
except Exception as e:
|
281 |
+
logger.warning(f"進階回應生成失敗,使用基礎格式: {str(e)}")
|
282 |
+
|
283 |
+
# 使用基礎格式化
|
284 |
+
return self._basic_format_response(db_result, analysis_result)
|
285 |
+
|
286 |
+
except Exception as e:
|
287 |
+
logger.error(f"格式化回應錯誤: {str(e)}")
|
288 |
+
return "資料處理時發生錯誤,請稍後再試。"
|
289 |
+
|
290 |
+
def _basic_format_response(self, db_result: DatabaseResult, analysis_result: NLPAnalysisResult) -> str:
|
291 |
+
"""基礎回應格式化"""
|
292 |
+
if not db_result.success:
|
293 |
+
return f"抱歉,查詢時發生錯誤:{db_result.error}"
|
294 |
+
|
295 |
+
if not db_result.data:
|
296 |
+
return "沒有找到相關資料。"
|
297 |
+
|
298 |
+
intent = analysis_result.intent
|
299 |
+
data = db_result.data
|
300 |
+
|
301 |
+
if intent.startswith("search_user"):
|
302 |
+
return self._format_user_response(data)
|
303 |
+
elif intent.startswith("search_order"):
|
304 |
+
return self._format_order_response(data)
|
305 |
+
elif intent.startswith("search_product"):
|
306 |
+
return self._format_product_response(data)
|
307 |
+
elif intent == "analytics":
|
308 |
+
return self._format_analytics_response(data)
|
309 |
+
else:
|
310 |
+
return f"找到 {len(data)} 筆資料。"
|
311 |
+
|
312 |
+
def _format_user_response(self, data: List[Dict[str, Any]]) -> str:
|
313 |
+
"""格式化用戶查詢回應"""
|
314 |
+
if len(data) == 1:
|
315 |
+
user = data[0]
|
316 |
+
return f"用戶資料:\n名稱:{user.get('name', 'N/A')}\nID:{user.get('user_id', 'N/A')}\n電子郵件:{user.get('email', 'N/A')}"
|
317 |
+
else:
|
318 |
+
response = f"找到 {len(data)} 位用戶:\n"
|
319 |
+
for i, user in enumerate(data[:5], 1): # 最多顯示5筆
|
320 |
+
response += f"{i}. {user.get('name', 'N/A')} (ID: {user.get('user_id', 'N/A')})\n"
|
321 |
+
if len(data) > 5:
|
322 |
+
response += f"... 還有 {len(data) - 5} 筆資料"
|
323 |
+
return response
|
324 |
+
|
325 |
+
def _format_order_response(self, data: List[Dict[str, Any]]) -> str:
|
326 |
+
"""格式化訂單查詢回應"""
|
327 |
+
if len(data) == 1:
|
328 |
+
order = data[0]
|
329 |
+
return f"訂單資料:\n訂單編號:{order.get('order_id', 'N/A')}\n狀態:{order.get('status', 'N/A')}\n金額:${order.get('total_amount', 'N/A')}"
|
330 |
+
else:
|
331 |
+
response = f"找到 {len(data)} 筆訂單:\n"
|
332 |
+
for i, order in enumerate(data[:5], 1):
|
333 |
+
response += f"{i}. {order.get('order_id', 'N/A')} - ${order.get('total_amount', 'N/A')}\n"
|
334 |
+
if len(data) > 5:
|
335 |
+
response += f"... 還有 {len(data) - 5} 筆資料"
|
336 |
+
return response
|
337 |
+
|
338 |
+
def _format_product_response(self, data: List[Dict[str, Any]]) -> str:
|
339 |
+
"""格式化商品查詢回應"""
|
340 |
+
if len(data) == 1:
|
341 |
+
product = data[0]
|
342 |
+
return f"商品資料:\n名稱:{product.get('name', 'N/A')}\n價格:${product.get('price', 'N/A')}\n庫存:{product.get('stock', 'N/A')}"
|
343 |
+
else:
|
344 |
+
response = f"找到 {len(data)} 項商品:\n"
|
345 |
+
for i, product in enumerate(data[:5], 1):
|
346 |
+
response += f"{i}. {product.get('name', 'N/A')} - ${product.get('price', 'N/A')}\n"
|
347 |
+
if len(data) > 5:
|
348 |
+
response += f"... 還有 {len(data) - 5} 筆���料"
|
349 |
+
return response
|
350 |
+
|
351 |
+
def _format_analytics_response(self, data: List[Dict[str, Any]]) -> str:
|
352 |
+
"""格式化分析回應"""
|
353 |
+
if data and len(data) > 0:
|
354 |
+
if 'count' in data[0]:
|
355 |
+
return f"統計結果:共 {data[0]['count']} 筆資料"
|
356 |
+
else:
|
357 |
+
return f"分析結果:找到 {len(data)} 筆相關資料"
|
358 |
+
return "無統計資料"
|
359 |
+
|
360 |
+
def analyze_business_query(self, message: str, user_id: str = None) -> NLPAnalysisResult:
|
361 |
+
"""分析業務相關的自然語言查詢"""
|
362 |
+
try:
|
363 |
+
# 檢測查詢意圖
|
364 |
+
intent = self._detect_business_intent(message)
|
365 |
+
|
366 |
+
# 提取實體
|
367 |
+
entities = self._extract_business_entities(message, intent)
|
368 |
+
|
369 |
+
# 根據意圖設定查詢類型和參數
|
370 |
+
query_type, parameters = self._build_query_parameters(intent, entities, user_id)
|
371 |
+
|
372 |
+
# 計算信心度
|
373 |
+
confidence = self._calculate_confidence(message, intent, entities)
|
374 |
+
|
375 |
+
return NLPAnalysisResult(
|
376 |
+
query_type=query_type,
|
377 |
+
intent=intent,
|
378 |
+
entities=entities,
|
379 |
+
confidence=confidence,
|
380 |
+
parameters=parameters
|
381 |
+
)
|
382 |
+
|
383 |
+
except Exception as e:
|
384 |
+
logger.error(f"業務查詢分析錯誤: {str(e)}")
|
385 |
+
return NLPAnalysisResult(
|
386 |
+
query_type=QueryType.UNKNOWN,
|
387 |
+
intent="unknown",
|
388 |
+
entities={},
|
389 |
+
confidence=0.0,
|
390 |
+
parameters={}
|
391 |
+
)
|
392 |
+
|
393 |
+
def _detect_business_intent(self, message: str) -> str:
|
394 |
+
"""檢測業務查詢意圖"""
|
395 |
+
message_lower = message.lower()
|
396 |
+
|
397 |
+
# 檢查業務相關的意圖模式
|
398 |
+
for intent, patterns in self.business_intent_patterns.items():
|
399 |
+
for pattern in patterns:
|
400 |
+
if re.search(pattern, message_lower):
|
401 |
+
return intent
|
402 |
+
|
403 |
+
# 如果沒有匹配到業務意圖,使用原有的意圖檢測
|
404 |
+
for intent, patterns in self.intent_patterns.items():
|
405 |
+
for pattern in patterns:
|
406 |
+
if re.search(pattern, message_lower):
|
407 |
+
return intent
|
408 |
+
|
409 |
+
return "general_search"
|
410 |
+
|
411 |
+
def _extract_business_entities(self, message: str, intent: str) -> Dict[str, Any]:
|
412 |
+
"""提取業務相關實體"""
|
413 |
+
entities = {}
|
414 |
+
|
415 |
+
# 商品名稱提取
|
416 |
+
product_patterns = [
|
417 |
+
r"商品[::]?\s*([^\s,。!?]+)",
|
418 |
+
r"產品[::]?\s*([^\s,。!?]+)",
|
419 |
+
r"貨品[::]?\s*([^\s,。!?]+)"
|
420 |
+
]
|
421 |
+
|
422 |
+
for pattern in product_patterns:
|
423 |
+
match = re.search(pattern, message)
|
424 |
+
if match:
|
425 |
+
entities["product_name"] = match.group(1)
|
426 |
+
break
|
427 |
+
|
428 |
+
# 如果沒有明確的商品名稱,嘗試提取關鍵字
|
429 |
+
if "product_name" not in entities:
|
430 |
+
# 移除查詢關鍵字後的剩餘內容可能是商品名稱
|
431 |
+
keywords_to_remove = ['查詢', '搜尋', '找', '商品', '產品', '庫存', '有沒有', '請問', '的', '嗎']
|
432 |
+
cleaned_message = message
|
433 |
+
for keyword in keywords_to_remove:
|
434 |
+
cleaned_message = cleaned_message.replace(keyword, '')
|
435 |
+
|
436 |
+
cleaned_message = cleaned_message.strip()
|
437 |
+
if cleaned_message and len(cleaned_message) > 0:
|
438 |
+
entities["search_text"] = cleaned_message
|
439 |
+
|
440 |
+
# 客戶相關實體
|
441 |
+
customer_patterns = [
|
442 |
+
r"客戶[::]?\s*([^\s,。!?]+)",
|
443 |
+
r"客戶編號[::]?\s*([A-Za-z0-9]+)",
|
444 |
+
r"客戶名稱[::]?\s*([^\s,。!?]+)"
|
445 |
+
]
|
446 |
+
|
447 |
+
for pattern in customer_patterns:
|
448 |
+
match = re.search(pattern, message)
|
449 |
+
if match:
|
450 |
+
entities["customer_info"] = match.group(1)
|
451 |
+
break
|
452 |
+
|
453 |
+
# 訂單相關實體
|
454 |
+
order_patterns = [
|
455 |
+
r"訂單[編號ID][::]?\s*([A-Za-z0-9\-]+)",
|
456 |
+
r"訂單[::]?\s*([A-Za-z0-9\-]+)"
|
457 |
+
]
|
458 |
+
|
459 |
+
for pattern in order_patterns:
|
460 |
+
match = re.search(pattern, message)
|
461 |
+
if match:
|
462 |
+
entities["order_id"] = match.group(1)
|
463 |
+
break
|
464 |
+
|
465 |
+
# 數量相關實體
|
466 |
+
quantity_patterns = [
|
467 |
+
r"(\d+)\s*個",
|
468 |
+
r"(\d+)\s*件",
|
469 |
+
r"(\d+)\s*箱",
|
470 |
+
r"數量[::]?\s*(\d+)"
|
471 |
+
]
|
472 |
+
|
473 |
+
for pattern in quantity_patterns:
|
474 |
+
match = re.search(pattern, message)
|
475 |
+
if match:
|
476 |
+
entities["quantity"] = int(match.group(1))
|
477 |
+
break
|
478 |
+
|
479 |
+
# 狀態相關實體
|
480 |
+
status_keywords = {
|
481 |
+
"待處理": ["待處理", "pending"],
|
482 |
+
"已確認": ["已確認", "confirmed"],
|
483 |
+
"已出貨": ["已出貨", "shipped"],
|
484 |
+
"已完成": ["已完成", "completed"],
|
485 |
+
"已取消": ["已取消", "cancelled"]
|
486 |
+
}
|
487 |
+
|
488 |
+
message_lower = message.lower()
|
489 |
+
for status, keywords in status_keywords.items():
|
490 |
+
if any(keyword in message_lower for keyword in keywords):
|
491 |
+
entities["status"] = status
|
492 |
+
break
|
493 |
+
|
494 |
+
return entities
|
495 |
+
|
496 |
+
def _build_query_parameters(self, intent: str, entities: Dict[str, Any], user_id: str = None) -> tuple:
|
497 |
+
"""根據意圖和實體建立查詢參數"""
|
498 |
+
|
499 |
+
if intent == "product_search":
|
500 |
+
return QueryType.SEARCH, {
|
501 |
+
"method": "search_products",
|
502 |
+
"query_text": entities.get("product_name") or entities.get("search_text"),
|
503 |
+
"category": entities.get("category"),
|
504 |
+
"limit": 10
|
505 |
+
}
|
506 |
+
|
507 |
+
elif intent == "inventory_check":
|
508 |
+
return QueryType.SEARCH, {
|
509 |
+
"method": "check_inventory",
|
510 |
+
"product_name": entities.get("product_name") or entities.get("search_text"),
|
511 |
+
"category": entities.get("category")
|
512 |
+
}
|
513 |
+
|
514 |
+
elif intent == "order_search":
|
515 |
+
return QueryType.SEARCH, {
|
516 |
+
"method": "search_orders",
|
517 |
+
"user_id": user_id,
|
518 |
+
"order_id": entities.get("order_id"),
|
519 |
+
"status": entities.get("status"),
|
520 |
+
"limit": 10
|
521 |
+
}
|
522 |
+
|
523 |
+
elif intent == "low_stock_alert":
|
524 |
+
return QueryType.SEARCH, {
|
525 |
+
"method": "get_low_stock_products",
|
526 |
+
"threshold": 10
|
527 |
+
}
|
528 |
+
|
529 |
+
elif intent == "business_summary":
|
530 |
+
return QueryType.ANALYTICS, {
|
531 |
+
"method": "get_business_summary"
|
532 |
+
}
|
533 |
+
|
534 |
+
else:
|
535 |
+
# 預設為商品搜尋
|
536 |
+
return QueryType.SEARCH, {
|
537 |
+
"method": "search_products",
|
538 |
+
"query_text": entities.get("search_text") or entities.get("product_name"),
|
539 |
+
"limit": 10
|
540 |
+
}
|
541 |
+
|
542 |
+
def _calculate_confidence(self, message: str, intent: str, entities: Dict[str, Any]) -> float:
|
543 |
+
"""計算查詢信心度"""
|
544 |
+
confidence = 0.5 # 基礎信心度
|
545 |
+
|
546 |
+
# 如果有明確的意圖匹配,增加信心度
|
547 |
+
if intent in self.business_intent_patterns:
|
548 |
+
confidence += 0.3
|
549 |
+
|
550 |
+
# 如果提取到實體,增加信心度
|
551 |
+
if entities:
|
552 |
+
confidence += 0.2 * len(entities)
|
553 |
+
|
554 |
+
# 如果訊息長度適中,增加信心度
|
555 |
+
if 2 <= len(message) <= 50:
|
556 |
+
confidence += 0.1
|
557 |
+
|
558 |
+
return min(confidence, 1.0)
|
559 |
+
|
560 |
+
def format_response_message(self, result: DatabaseResult, intent: str) -> str:
|
561 |
+
"""格式化回應訊息"""
|
562 |
+
if not result.success:
|
563 |
+
return f"抱歉,查詢時發生錯誤:{result.error}"
|
564 |
+
|
565 |
+
if not result.data or result.count == 0:
|
566 |
+
return "沒有找到相關資料。"
|
567 |
+
|
568 |
+
# 根據不同的查詢意圖格式化回應
|
569 |
+
if intent == "product_search":
|
570 |
+
return self._format_product_response(result.data)
|
571 |
+
elif intent == "inventory_check":
|
572 |
+
return self._format_inventory_response(result.data)
|
573 |
+
elif intent == "order_search":
|
574 |
+
return self._format_order_response(result.data)
|
575 |
+
elif intent == "low_stock_alert":
|
576 |
+
return self._format_low_stock_response(result.data)
|
577 |
+
elif intent == "business_summary":
|
578 |
+
return self._format_summary_response(result.data)
|
579 |
+
else:
|
580 |
+
return self._format_general_response(result.data)
|
581 |
+
|
582 |
+
def _format_product_response(self, data: List[Dict[str, Any]]) -> str:
|
583 |
+
"""格式化商品查詢回應"""
|
584 |
+
if len(data) == 1:
|
585 |
+
product = data[0]
|
586 |
+
return f"找到商品:\n" \
|
587 |
+
f"名稱:{product.get('name', 'N/A')}\n" \
|
588 |
+
f"描述:{product.get('description', 'N/A')}\n" \
|
589 |
+
f"價格:${product.get('price', 0)}\n" \
|
590 |
+
f"類別:{product.get('category', 'N/A')}"
|
591 |
+
else:
|
592 |
+
response = f"找到 {len(data)} 個商品:\n"
|
593 |
+
for i, product in enumerate(data[:5], 1):
|
594 |
+
response += f"{i}. {product.get('name', 'N/A')} - ${product.get('price', 0)}\n"
|
595 |
+
if len(data) > 5:
|
596 |
+
response += f"... 還有 {len(data) - 5} 個商品"
|
597 |
+
return response
|
598 |
+
|
599 |
+
def _format_inventory_response(self, data: List[Dict[str, Any]]) -> str:
|
600 |
+
"""格式化庫存查詢回應"""
|
601 |
+
if len(data) == 1:
|
602 |
+
item = data[0]
|
603 |
+
return f"庫存資訊:\n" \
|
604 |
+
f"商品:{item.get('product_name', 'N/A')}\n" \
|
605 |
+
f"目前庫存:{item.get('current_stock', 0)} 件\n" \
|
606 |
+
f"類別:{item.get('category', 'N/A')}\n" \
|
607 |
+
f"價格:${item.get('price', 0)}"
|
608 |
+
else:
|
609 |
+
response = f"找到 {len(data)} 個商品的庫存:\n"
|
610 |
+
for i, item in enumerate(data[:5], 1):
|
611 |
+
response += f"{i}. {item.get('product_name', 'N/A')} - 庫存:{item.get('current_stock', 0)}\n"
|
612 |
+
return response
|
613 |
+
|
614 |
+
def _format_order_response(self, data: List[Dict[str, Any]]) -> str:
|
615 |
+
"""格式化訂單查詢回應"""
|
616 |
+
if len(data) == 1:
|
617 |
+
order = data[0]
|
618 |
+
return f"訂單資訊:\n" \
|
619 |
+
f"訂單編號:{order.get('order_id', 'N/A')}\n" \
|
620 |
+
f"狀態:{order.get('status', 'N/A')}\n" \
|
621 |
+
f"總金額:${order.get('total_amount', 0)}\n" \
|
622 |
+
f"建立時間:{order.get('created_at', 'N/A')}"
|
623 |
+
else:
|
624 |
+
response = f"找到 {len(data)} 筆訂單:\n"
|
625 |
+
for i, order in enumerate(data[:5], 1):
|
626 |
+
response += f"{i}. {order.get('order_id', 'N/A')} - {order.get('status', 'N/A')} - ${order.get('total_amount', 0)}\n"
|
627 |
+
return response
|
628 |
+
|
629 |
+
def _format_low_stock_response(self, data: List[Dict[str, Any]]) -> str:
|
630 |
+
"""格式化低庫存警告回應"""
|
631 |
+
if not data:
|
632 |
+
return "目前沒有低庫存商品。"
|
633 |
+
|
634 |
+
response = f"⚠️ 發現 {len(data)} 個低庫存商品:\n"
|
635 |
+
for i, item in enumerate(data[:10], 1):
|
636 |
+
response += f"{i}. {item.get('product_name', 'N/A')} - 剩餘:{item.get('current_stock', 0)} 件\n"
|
637 |
+
return response
|
638 |
+
|
639 |
+
def _format_summary_response(self, data: List[Dict[str, Any]]) -> str:
|
640 |
+
"""格式化業務摘要回應"""
|
641 |
+
if data:
|
642 |
+
summary = data[0]
|
643 |
+
return f"📊 業務摘要:\n" \
|
644 |
+
f"商品總數:{summary.get('total_products', 0)} 個\n" \
|
645 |
+
f"訂單總數:{summary.get('total_orders', 0)} 筆\n" \
|
646 |
+
f"用戶總數:{summary.get('total_users', 0)} 人\n" \
|
647 |
+
f"低庫存商品:{summary.get('low_stock_items', 0)} 個\n" \
|
648 |
+
f"統計時間:{summary.get('report_date', 'N/A')}"
|
649 |
+
return "無法取得業務摘要資料。"
|
650 |
+
|
651 |
+
def _format_general_response(self, data: List[Dict[str, Any]]) -> str:
|
652 |
+
"""格式化一般查詢回應"""
|
653 |
+
return f"找到 {len(data)} 筆資料。"
|
backend/services/openrouter_service.py
ADDED
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
OpenRouter API 服務
|
3 |
+
用於進階自然語言處理功能
|
4 |
+
"""
|
5 |
+
|
6 |
+
import httpx
|
7 |
+
import json
|
8 |
+
from typing import Dict, Any, Optional
|
9 |
+
from backend.config import settings
|
10 |
+
import logging
|
11 |
+
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
class OpenRouterService:
|
15 |
+
"""OpenRouter API 服務類"""
|
16 |
+
|
17 |
+
def __init__(self):
|
18 |
+
self.api_key = settings.OPENROUTER_API_KEY
|
19 |
+
self.model = settings.OPENROUTER_MODEL
|
20 |
+
self.base_url = "https://openrouter.ai/api/v1"
|
21 |
+
self.headers = {
|
22 |
+
"Authorization": f"Bearer {self.api_key}",
|
23 |
+
"Content-Type": "application/json",
|
24 |
+
"HTTP-Referer": "https://huggingface.co/spaces",
|
25 |
+
"X-Title": "LINE Bot NLP Service"
|
26 |
+
}
|
27 |
+
|
28 |
+
async def analyze_intent_advanced(self, message: str, context: Dict[str, Any] = None) -> Dict[str, Any]:
|
29 |
+
"""
|
30 |
+
使用 OpenRouter 進行進階意圖分析
|
31 |
+
|
32 |
+
Args:
|
33 |
+
message: 用戶訊息
|
34 |
+
context: 上下文資訊
|
35 |
+
|
36 |
+
Returns:
|
37 |
+
分析結果字典
|
38 |
+
"""
|
39 |
+
if not self.api_key:
|
40 |
+
logger.warning("OpenRouter API Key 未設定,使用基礎 NLP 服務")
|
41 |
+
return self._fallback_analysis(message)
|
42 |
+
|
43 |
+
try:
|
44 |
+
prompt = self._build_analysis_prompt(message, context)
|
45 |
+
|
46 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
47 |
+
response = await client.post(
|
48 |
+
f"{self.base_url}/chat/completions",
|
49 |
+
headers=self.headers,
|
50 |
+
json={
|
51 |
+
"model": self.model,
|
52 |
+
"messages": [
|
53 |
+
{
|
54 |
+
"role": "system",
|
55 |
+
"content": "你是一個專業的中文自然語言處理助手,專門分析用戶查詢意圖並提取相關實體。請以 JSON 格式回應。"
|
56 |
+
},
|
57 |
+
{
|
58 |
+
"role": "user",
|
59 |
+
"content": prompt
|
60 |
+
}
|
61 |
+
],
|
62 |
+
"temperature": 0.1,
|
63 |
+
"max_tokens": 500
|
64 |
+
}
|
65 |
+
)
|
66 |
+
|
67 |
+
if response.status_code == 200:
|
68 |
+
result = response.json()
|
69 |
+
content = result["choices"][0]["message"]["content"]
|
70 |
+
|
71 |
+
try:
|
72 |
+
# 嘗試解析 JSON 回應
|
73 |
+
analysis = json.loads(content)
|
74 |
+
return self._validate_analysis_result(analysis)
|
75 |
+
except json.JSONDecodeError:
|
76 |
+
logger.error(f"無法解析 OpenRouter 回應: {content}")
|
77 |
+
return self._fallback_analysis(message)
|
78 |
+
else:
|
79 |
+
logger.error(f"OpenRouter API 錯誤: {response.status_code}")
|
80 |
+
return self._fallback_analysis(message)
|
81 |
+
|
82 |
+
except Exception as e:
|
83 |
+
logger.error(f"OpenRouter 服務錯誤: {str(e)}")
|
84 |
+
return self._fallback_analysis(message)
|
85 |
+
|
86 |
+
def _build_analysis_prompt(self, message: str, context: Dict[str, Any] = None) -> str:
|
87 |
+
"""建構分析提示詞"""
|
88 |
+
context_info = ""
|
89 |
+
if context:
|
90 |
+
context_info = f"\n上下文資訊: {json.dumps(context, ensure_ascii=False)}"
|
91 |
+
|
92 |
+
prompt = f"""
|
93 |
+
請分析以下中文訊息的意圖和實體:
|
94 |
+
|
95 |
+
訊息: "{message}"{context_info}
|
96 |
+
|
97 |
+
請以以下 JSON 格式回應:
|
98 |
+
{{
|
99 |
+
"intent": "查詢意圖類型 (search_user/search_product/search_order/create_order/update_profile/analytics/unknown)",
|
100 |
+
"confidence": 0.0-1.0之間的信心度,
|
101 |
+
"entities": {{
|
102 |
+
"user_id": "提取的用戶ID",
|
103 |
+
"user_name": "提取的用戶名稱",
|
104 |
+
"product_name": "提取的商品名稱",
|
105 |
+
"order_id": "提取的訂單ID",
|
106 |
+
"min_price": 最低價格數字,
|
107 |
+
"max_price": 最高價格數字,
|
108 |
+
"category": "商品類別",
|
109 |
+
"number": 數量
|
110 |
+
}},
|
111 |
+
"query_type": "search/create/update/delete/analytics",
|
112 |
+
"parameters": {{
|
113 |
+
"table": "目標資料表名稱",
|
114 |
+
"conditions": "查詢條件",
|
115 |
+
"limit": 查詢限制數量
|
116 |
+
}}
|
117 |
+
}}
|
118 |
+
|
119 |
+
範例:
|
120 |
+
- "查詢用戶張三" → intent: "search_user", entities: {{"user_name": "張三"}}
|
121 |
+
- "找價格1000到5000的手機" → intent: "search_product", entities: {{"min_price": 1000, "max_price": 5000, "category": "手機"}}
|
122 |
+
- "統計訂單數量" → intent: "analytics", query_type: "analytics"
|
123 |
+
"""
|
124 |
+
return prompt
|
125 |
+
|
126 |
+
def _validate_analysis_result(self, analysis: Dict[str, Any]) -> Dict[str, Any]:
|
127 |
+
"""驗證分析結果"""
|
128 |
+
# 確保必要欄位存在
|
129 |
+
required_fields = ["intent", "confidence", "entities", "query_type"]
|
130 |
+
for field in required_fields:
|
131 |
+
if field not in analysis:
|
132 |
+
analysis[field] = self._get_default_value(field)
|
133 |
+
|
134 |
+
# 驗證信心度範圍
|
135 |
+
if not 0 <= analysis.get("confidence", 0) <= 1:
|
136 |
+
analysis["confidence"] = 0.5
|
137 |
+
|
138 |
+
# 確保實體是字典
|
139 |
+
if not isinstance(analysis.get("entities"), dict):
|
140 |
+
analysis["entities"] = {}
|
141 |
+
|
142 |
+
return analysis
|
143 |
+
|
144 |
+
def _get_default_value(self, field: str) -> Any:
|
145 |
+
"""取得欄位預設值"""
|
146 |
+
defaults = {
|
147 |
+
"intent": "unknown",
|
148 |
+
"confidence": 0.1,
|
149 |
+
"entities": {},
|
150 |
+
"query_type": "unknown",
|
151 |
+
"parameters": {}
|
152 |
+
}
|
153 |
+
return defaults.get(field, None)
|
154 |
+
|
155 |
+
def _fallback_analysis(self, message: str) -> Dict[str, Any]:
|
156 |
+
"""備用分析方法"""
|
157 |
+
return {
|
158 |
+
"intent": "unknown",
|
159 |
+
"confidence": 0.1,
|
160 |
+
"entities": {},
|
161 |
+
"query_type": "unknown",
|
162 |
+
"parameters": {},
|
163 |
+
"fallback": True
|
164 |
+
}
|
165 |
+
|
166 |
+
async def generate_response(self, query_result: Dict[str, Any], user_message: str) -> str:
|
167 |
+
"""
|
168 |
+
使用 OpenRouter 生成更自然的回應
|
169 |
+
|
170 |
+
Args:
|
171 |
+
query_result: 資料庫查詢結果
|
172 |
+
user_message: 用戶原始訊息
|
173 |
+
|
174 |
+
Returns:
|
175 |
+
生成的回應文字
|
176 |
+
"""
|
177 |
+
if not self.api_key:
|
178 |
+
return self._generate_simple_response(query_result)
|
179 |
+
|
180 |
+
try:
|
181 |
+
prompt = f"""
|
182 |
+
請根據以下資訊生成一個友善、自然的中文回應:
|
183 |
+
|
184 |
+
用戶訊息: "{user_message}"
|
185 |
+
查詢結果: {json.dumps(query_result, ensure_ascii=False)}
|
186 |
+
|
187 |
+
要求:
|
188 |
+
1. 回應要簡潔明瞭
|
189 |
+
2. 使用友善的語調
|
190 |
+
3. 如果有資料,要清楚呈現重要資訊
|
191 |
+
4. 如果沒有資料,要給出建議
|
192 |
+
5. 回應長度控制在 200 字以內
|
193 |
+
|
194 |
+
範例格式:
|
195 |
+
- 找到資料時:「找到 X 筆資料:[列出重要資訊]」
|
196 |
+
- 沒有資料時:「很抱歉,沒有找到相關資料,您可以嘗試...」
|
197 |
+
"""
|
198 |
+
|
199 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
200 |
+
response = await client.post(
|
201 |
+
f"{self.base_url}/chat/completions",
|
202 |
+
headers=self.headers,
|
203 |
+
json={
|
204 |
+
"model": self.model,
|
205 |
+
"messages": [
|
206 |
+
{
|
207 |
+
"role": "system",
|
208 |
+
"content": "你是一個友善的客服助手,專門幫助用戶理解查詢結果。"
|
209 |
+
},
|
210 |
+
{
|
211 |
+
"role": "user",
|
212 |
+
"content": prompt
|
213 |
+
}
|
214 |
+
],
|
215 |
+
"temperature": 0.3,
|
216 |
+
"max_tokens": 300
|
217 |
+
}
|
218 |
+
)
|
219 |
+
|
220 |
+
if response.status_code == 200:
|
221 |
+
result = response.json()
|
222 |
+
generated_response = result["choices"][0]["message"]["content"].strip()
|
223 |
+
return generated_response
|
224 |
+
else:
|
225 |
+
logger.error(f"OpenRouter 回應生成錯誤: {response.status_code}")
|
226 |
+
return self._generate_simple_response(query_result)
|
227 |
+
|
228 |
+
except Exception as e:
|
229 |
+
logger.error(f"OpenRouter 回應生成失敗: {str(e)}")
|
230 |
+
return self._generate_simple_response(query_result)
|
231 |
+
|
232 |
+
def _generate_simple_response(self, query_result: Dict[str, Any]) -> str:
|
233 |
+
"""簡單回應生成"""
|
234 |
+
if query_result.get("success"):
|
235 |
+
data_count = len(query_result.get("data", []))
|
236 |
+
if data_count > 0:
|
237 |
+
return f"找到 {data_count} 筆相關資料。"
|
238 |
+
else:
|
239 |
+
return "沒有找到相關資料。"
|
240 |
+
else:
|
241 |
+
return "查詢時發生錯誤,請稍後再試。"
|
backend/utils/__init__.py
ADDED
File without changes
|
backend/utils/database_init.py
ADDED
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
資料庫初始化腳本
|
3 |
+
用於在 Supabase 中建立必要的資料表
|
4 |
+
"""
|
5 |
+
|
6 |
+
from supabase import create_client
|
7 |
+
from backend.config import settings
|
8 |
+
import logging
|
9 |
+
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
# SQL 建表語句
|
13 |
+
CREATE_TABLES_SQL = {
|
14 |
+
"users": """
|
15 |
+
CREATE TABLE IF NOT EXISTS users (
|
16 |
+
user_id VARCHAR(255) PRIMARY KEY,
|
17 |
+
name VARCHAR(255),
|
18 |
+
email VARCHAR(255),
|
19 |
+
display_name VARCHAR(255),
|
20 |
+
picture_url TEXT,
|
21 |
+
status_message TEXT,
|
22 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
23 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
24 |
+
);
|
25 |
+
""",
|
26 |
+
|
27 |
+
"products": """
|
28 |
+
CREATE TABLE IF NOT EXISTS products (
|
29 |
+
product_id SERIAL PRIMARY KEY,
|
30 |
+
name VARCHAR(255) NOT NULL,
|
31 |
+
description TEXT,
|
32 |
+
price DECIMAL(10,2) NOT NULL,
|
33 |
+
stock INTEGER DEFAULT 0,
|
34 |
+
category VARCHAR(100),
|
35 |
+
image_url TEXT,
|
36 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
37 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
38 |
+
);
|
39 |
+
""",
|
40 |
+
|
41 |
+
"orders": """
|
42 |
+
CREATE TABLE IF NOT EXISTS orders (
|
43 |
+
order_id VARCHAR(255) PRIMARY KEY,
|
44 |
+
user_id VARCHAR(255) REFERENCES users(user_id),
|
45 |
+
total_amount DECIMAL(10,2) NOT NULL,
|
46 |
+
status VARCHAR(50) DEFAULT 'pending',
|
47 |
+
shipping_address TEXT,
|
48 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
49 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
50 |
+
);
|
51 |
+
""",
|
52 |
+
|
53 |
+
"order_items": """
|
54 |
+
CREATE TABLE IF NOT EXISTS order_items (
|
55 |
+
id SERIAL PRIMARY KEY,
|
56 |
+
order_id VARCHAR(255) REFERENCES orders(order_id),
|
57 |
+
product_id INTEGER REFERENCES products(product_id),
|
58 |
+
quantity INTEGER NOT NULL,
|
59 |
+
unit_price DECIMAL(10,2) NOT NULL,
|
60 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
61 |
+
);
|
62 |
+
""",
|
63 |
+
|
64 |
+
"line_messages": """
|
65 |
+
CREATE TABLE IF NOT EXISTS line_messages (
|
66 |
+
id SERIAL PRIMARY KEY,
|
67 |
+
user_id VARCHAR(255),
|
68 |
+
message TEXT NOT NULL,
|
69 |
+
message_type VARCHAR(50) DEFAULT 'text',
|
70 |
+
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
71 |
+
processed BOOLEAN DEFAULT FALSE
|
72 |
+
);
|
73 |
+
""",
|
74 |
+
|
75 |
+
"user_sessions": """
|
76 |
+
CREATE TABLE IF NOT EXISTS user_sessions (
|
77 |
+
id SERIAL PRIMARY KEY,
|
78 |
+
user_id VARCHAR(255) REFERENCES users(user_id),
|
79 |
+
session_data JSONB,
|
80 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
81 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
82 |
+
);
|
83 |
+
"""
|
84 |
+
}
|
85 |
+
|
86 |
+
# 範例資料
|
87 |
+
SAMPLE_DATA = {
|
88 |
+
"products": [
|
89 |
+
{
|
90 |
+
"name": "iPhone 15 Pro",
|
91 |
+
"description": "最新款 iPhone,配備 A17 Pro 晶片",
|
92 |
+
"price": 35900,
|
93 |
+
"stock": 50,
|
94 |
+
"category": "手機"
|
95 |
+
},
|
96 |
+
{
|
97 |
+
"name": "MacBook Air M2",
|
98 |
+
"description": "輕薄筆記型電腦,搭載 M2 晶片",
|
99 |
+
"price": 37900,
|
100 |
+
"stock": 30,
|
101 |
+
"category": "筆電"
|
102 |
+
},
|
103 |
+
{
|
104 |
+
"name": "AirPods Pro",
|
105 |
+
"description": "主動降噪無線耳機",
|
106 |
+
"price": 7490,
|
107 |
+
"stock": 100,
|
108 |
+
"category": "耳機"
|
109 |
+
}
|
110 |
+
]
|
111 |
+
}
|
112 |
+
|
113 |
+
def init_database():
|
114 |
+
"""初始化資料庫"""
|
115 |
+
try:
|
116 |
+
supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY)
|
117 |
+
|
118 |
+
logger.info("開始初始化資料庫...")
|
119 |
+
|
120 |
+
# 建立資料表
|
121 |
+
for table_name, sql in CREATE_TABLES_SQL.items():
|
122 |
+
try:
|
123 |
+
logger.info(f"建立資料表: {table_name}")
|
124 |
+
# 注意:Supabase 的 Python 客戶端可能不直接支援 SQL 執行
|
125 |
+
# 您需要在 Supabase Dashboard 的 SQL Editor 中執行這些 SQL
|
126 |
+
print(f"\n=== {table_name.upper()} TABLE SQL ===")
|
127 |
+
print(sql)
|
128 |
+
|
129 |
+
except Exception as e:
|
130 |
+
logger.error(f"建立資料表 {table_name} 失敗: {str(e)}")
|
131 |
+
|
132 |
+
# 插入範例資料
|
133 |
+
logger.info("插入範例資料...")
|
134 |
+
for table_name, data in SAMPLE_DATA.items():
|
135 |
+
try:
|
136 |
+
result = supabase.table(table_name).insert(data).execute()
|
137 |
+
logger.info(f"成功插入 {len(result.data)} 筆資料到 {table_name}")
|
138 |
+
except Exception as e:
|
139 |
+
logger.warning(f"插入範例資料到 {table_name} 失敗: {str(e)}")
|
140 |
+
|
141 |
+
logger.info("資料庫初始化完成!")
|
142 |
+
return True
|
143 |
+
|
144 |
+
except Exception as e:
|
145 |
+
logger.error(f"資料庫初始化失敗: {str(e)}")
|
146 |
+
return False
|
147 |
+
|
148 |
+
if __name__ == "__main__":
|
149 |
+
init_database()
|
backend/utils/line_utils.py
ADDED
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
LINE Bot 相關工具函數
|
3 |
+
"""
|
4 |
+
|
5 |
+
from linebot.models import (
|
6 |
+
TextSendMessage, QuickReply, QuickReplyButton, MessageAction,
|
7 |
+
FlexSendMessage, BubbleContainer, BoxComponent, TextComponent,
|
8 |
+
ButtonComponent, URIAction
|
9 |
+
)
|
10 |
+
from typing import List, Dict, Any
|
11 |
+
import logging
|
12 |
+
|
13 |
+
logger = logging.getLogger(__name__)
|
14 |
+
|
15 |
+
class LineMessageBuilder:
|
16 |
+
"""LINE 訊息建構器"""
|
17 |
+
|
18 |
+
@staticmethod
|
19 |
+
def create_quick_reply_message(text: str, quick_reply_items: List[Dict[str, str]]) -> TextSendMessage:
|
20 |
+
"""建立快速回覆訊息"""
|
21 |
+
quick_reply_buttons = []
|
22 |
+
|
23 |
+
for item in quick_reply_items:
|
24 |
+
button = QuickReplyButton(
|
25 |
+
action=MessageAction(
|
26 |
+
label=item["label"],
|
27 |
+
text=item["text"]
|
28 |
+
)
|
29 |
+
)
|
30 |
+
quick_reply_buttons.append(button)
|
31 |
+
|
32 |
+
quick_reply = QuickReply(items=quick_reply_buttons)
|
33 |
+
|
34 |
+
return TextSendMessage(
|
35 |
+
text=text,
|
36 |
+
quick_reply=quick_reply
|
37 |
+
)
|
38 |
+
|
39 |
+
@staticmethod
|
40 |
+
def create_product_flex_message(products: List[Dict[str, Any]]) -> FlexSendMessage:
|
41 |
+
"""建立商品展示的 Flex 訊息"""
|
42 |
+
contents = []
|
43 |
+
|
44 |
+
for product in products[:10]: # 最多顯示10個商品
|
45 |
+
bubble = BubbleContainer(
|
46 |
+
body=BoxComponent(
|
47 |
+
layout="vertical",
|
48 |
+
contents=[
|
49 |
+
TextComponent(
|
50 |
+
text=product.get("name", "商品名稱"),
|
51 |
+
weight="bold",
|
52 |
+
size="lg"
|
53 |
+
),
|
54 |
+
TextComponent(
|
55 |
+
text=f"NT$ {product.get('price', 0):,}",
|
56 |
+
size="md",
|
57 |
+
color="#ff5551"
|
58 |
+
),
|
59 |
+
TextComponent(
|
60 |
+
text=f"庫存: {product.get('stock', 0)}",
|
61 |
+
size="sm",
|
62 |
+
color="#aaaaaa"
|
63 |
+
)
|
64 |
+
]
|
65 |
+
),
|
66 |
+
footer=BoxComponent(
|
67 |
+
layout="vertical",
|
68 |
+
contents=[
|
69 |
+
ButtonComponent(
|
70 |
+
action=MessageAction(
|
71 |
+
label="查看詳情",
|
72 |
+
text=f"查詢商品 {product.get('name', '')}"
|
73 |
+
),
|
74 |
+
style="primary"
|
75 |
+
)
|
76 |
+
]
|
77 |
+
)
|
78 |
+
)
|
79 |
+
contents.append(bubble)
|
80 |
+
|
81 |
+
return FlexSendMessage(
|
82 |
+
alt_text="商品列表",
|
83 |
+
contents={
|
84 |
+
"type": "carousel",
|
85 |
+
"contents": [bubble.as_json_dict() for bubble in contents]
|
86 |
+
}
|
87 |
+
)
|
88 |
+
|
89 |
+
@staticmethod
|
90 |
+
def create_help_message() -> TextSendMessage:
|
91 |
+
"""建立說明訊息"""
|
92 |
+
help_text = """
|
93 |
+
🤖 歡迎使用智能查詢機器人!
|
94 |
+
|
95 |
+
📝 支援的查詢類型:
|
96 |
+
• 用戶查詢:「查詢用戶 張三」
|
97 |
+
• 訂單查詢:「查詢訂單 ORD001」
|
98 |
+
• 商品查詢:「查詢商品 iPhone」
|
99 |
+
• 價格範圍:「價格 1000 到 5000 的商品」
|
100 |
+
• 統計分析:「統計用戶數量」
|
101 |
+
|
102 |
+
💡 使用範例:
|
103 |
+
• 「找用戶名叫王小明的資料」
|
104 |
+
• 「我的訂單狀態如何」
|
105 |
+
• 「有什麼手機商品」
|
106 |
+
• 「總共有多少筆訂單」
|
107 |
+
|
108 |
+
❓ 如需協助,請輸入「幫助」或「說明」
|
109 |
+
"""
|
110 |
+
|
111 |
+
quick_reply_items = [
|
112 |
+
{"label": "查詢用戶", "text": "查詢用戶"},
|
113 |
+
{"label": "查詢訂單", "text": "查詢訂單"},
|
114 |
+
{"label": "查詢商品", "text": "查詢商品"},
|
115 |
+
{"label": "統計資料", "text": "統計"}
|
116 |
+
]
|
117 |
+
|
118 |
+
return LineMessageBuilder.create_quick_reply_message(help_text, quick_reply_items)
|
119 |
+
|
120 |
+
@staticmethod
|
121 |
+
def create_error_message(error_msg: str = None) -> TextSendMessage:
|
122 |
+
"""建立錯誤訊息"""
|
123 |
+
default_msg = "抱歉,我無法理解您的訊息。請輸入「幫助」查看使用說明。"
|
124 |
+
text = error_msg if error_msg else default_msg
|
125 |
+
|
126 |
+
quick_reply_items = [
|
127 |
+
{"label": "幫助", "text": "幫助"},
|
128 |
+
{"label": "重新開始", "text": "開始"}
|
129 |
+
]
|
130 |
+
|
131 |
+
return LineMessageBuilder.create_quick_reply_message(text, quick_reply_items)
|
132 |
+
|
133 |
+
def get_user_display_name(line_bot_api, user_id: str) -> str:
|
134 |
+
"""取得用戶顯示名稱"""
|
135 |
+
try:
|
136 |
+
profile = line_bot_api.get_profile(user_id)
|
137 |
+
return profile.display_name
|
138 |
+
except Exception as e:
|
139 |
+
logger.error(f"取得用戶資料失敗: {str(e)}")
|
140 |
+
return "用戶"
|
141 |
+
|
142 |
+
def validate_line_signature(body: bytes, signature: str, channel_secret: str) -> bool:
|
143 |
+
"""驗證 LINE Webhook 簽名"""
|
144 |
+
import hashlib
|
145 |
+
import hmac
|
146 |
+
import base64
|
147 |
+
|
148 |
+
hash_value = hmac.new(
|
149 |
+
channel_secret.encode('utf-8'),
|
150 |
+
body,
|
151 |
+
hashlib.sha256
|
152 |
+
).digest()
|
153 |
+
|
154 |
+
expected_signature = base64.b64encode(hash_value).decode('utf-8')
|
155 |
+
return hmac.compare_digest(signature, expected_signature)
|
requirements.txt
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi==0.104.1
|
2 |
+
uvicorn[standard]==0.24.0
|
3 |
+
line-bot-sdk==3.5.0
|
4 |
+
sqlalchemy==2.0.23
|
5 |
+
psycopg==3.1.13
|
6 |
+
alembic==1.13.0
|
7 |
+
pydantic==2.5.0
|
8 |
+
pydantic-settings==2.1.0
|
9 |
+
python-multipart==0.0.6
|
10 |
+
python-dotenv==1.0.0
|
11 |
+
requests==2.31.0
|
12 |
+
openai==1.3.0
|
13 |
+
httpx==0.25.2
|
setup_guide.md
ADDED
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# LINE Bot + FastAPI + Supabase 設定指南
|
2 |
+
|
3 |
+
## 🚀 快速開始
|
4 |
+
|
5 |
+
### 1. 必要的 API Keys 和設定
|
6 |
+
|
7 |
+
您需要準備以下 API Keys 和設定:
|
8 |
+
|
9 |
+
#### LINE Developers Console
|
10 |
+
1. 前往 [LINE Developers Console](https://developers.line.biz/)
|
11 |
+
2. 建立新的 Provider 和 Messaging API Channel
|
12 |
+
3. 取得以下資訊:
|
13 |
+
- `Channel Access Token` (長期)
|
14 |
+
- `Channel Secret`
|
15 |
+
|
16 |
+
#### Supabase
|
17 |
+
1. 前往 [Supabase](https://supabase.com/)
|
18 |
+
2. 建立新專案
|
19 |
+
3. 取得以下資訊:
|
20 |
+
- `Project URL`
|
21 |
+
- `Anon/Public Key`
|
22 |
+
|
23 |
+
#### OpenRouter (可選)
|
24 |
+
1. 前往 [OpenRouter](https://openrouter.ai/)
|
25 |
+
2. 註冊帳號並取得 `API Key` (用於進階 NLP 功能)
|
26 |
+
3. 選擇適合的模型 (預設: anthropic/claude-3-haiku)
|
27 |
+
|
28 |
+
### 2. Hugging Face Spaces 環境變數設定
|
29 |
+
|
30 |
+
在 Hugging Face Spaces 的 Settings 中設定以下環境變數:
|
31 |
+
|
32 |
+
```
|
33 |
+
LINE_CHANNEL_ACCESS_TOKEN=你的_LINE_Channel_Access_Token
|
34 |
+
LINE_CHANNEL_SECRET=你的_LINE_Channel_Secret
|
35 |
+
SUPABASE_URL=你的_Supabase_專案_URL
|
36 |
+
SUPABASE_KEY=你的_Supabase_Anon_Key
|
37 |
+
OPENROUTER_API_KEY=你的_OpenRouter_API_Key (可選)
|
38 |
+
OPENROUTER_MODEL=anthropic/claude-3-haiku (可選)
|
39 |
+
DEBUG=False
|
40 |
+
LOG_LEVEL=INFO
|
41 |
+
```
|
42 |
+
|
43 |
+
### 3. Supabase 資料庫設定
|
44 |
+
|
45 |
+
在 Supabase Dashboard 的 SQL Editor 中執行以下 SQL:
|
46 |
+
|
47 |
+
```sql
|
48 |
+
-- 建立用戶表
|
49 |
+
CREATE TABLE IF NOT EXISTS users (
|
50 |
+
user_id VARCHAR(255) PRIMARY KEY,
|
51 |
+
name VARCHAR(255),
|
52 |
+
email VARCHAR(255),
|
53 |
+
display_name VARCHAR(255),
|
54 |
+
picture_url TEXT,
|
55 |
+
status_message TEXT,
|
56 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
57 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
58 |
+
);
|
59 |
+
|
60 |
+
-- 建立商品表
|
61 |
+
CREATE TABLE IF NOT EXISTS products (
|
62 |
+
product_id SERIAL PRIMARY KEY,
|
63 |
+
name VARCHAR(255) NOT NULL,
|
64 |
+
description TEXT,
|
65 |
+
price DECIMAL(10,2) NOT NULL,
|
66 |
+
stock INTEGER DEFAULT 0,
|
67 |
+
category VARCHAR(100),
|
68 |
+
image_url TEXT,
|
69 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
70 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
71 |
+
);
|
72 |
+
|
73 |
+
-- 建立訂單表
|
74 |
+
CREATE TABLE IF NOT EXISTS orders (
|
75 |
+
order_id VARCHAR(255) PRIMARY KEY,
|
76 |
+
user_id VARCHAR(255) REFERENCES users(user_id),
|
77 |
+
total_amount DECIMAL(10,2) NOT NULL,
|
78 |
+
status VARCHAR(50) DEFAULT 'pending',
|
79 |
+
shipping_address TEXT,
|
80 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
81 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
82 |
+
);
|
83 |
+
|
84 |
+
-- 建立訂單項目表
|
85 |
+
CREATE TABLE IF NOT EXISTS order_items (
|
86 |
+
id SERIAL PRIMARY KEY,
|
87 |
+
order_id VARCHAR(255) REFERENCES orders(order_id),
|
88 |
+
product_id INTEGER REFERENCES products(product_id),
|
89 |
+
quantity INTEGER NOT NULL,
|
90 |
+
unit_price DECIMAL(10,2) NOT NULL,
|
91 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
92 |
+
);
|
93 |
+
|
94 |
+
-- 建立訊息記錄表
|
95 |
+
CREATE TABLE IF NOT EXISTS line_messages (
|
96 |
+
id SERIAL PRIMARY KEY,
|
97 |
+
user_id VARCHAR(255),
|
98 |
+
message TEXT NOT NULL,
|
99 |
+
message_type VARCHAR(50) DEFAULT 'text',
|
100 |
+
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
101 |
+
processed BOOLEAN DEFAULT FALSE
|
102 |
+
);
|
103 |
+
|
104 |
+
-- 建立用戶會話表
|
105 |
+
CREATE TABLE IF NOT EXISTS user_sessions (
|
106 |
+
id SERIAL PRIMARY KEY,
|
107 |
+
user_id VARCHAR(255) REFERENCES users(user_id),
|
108 |
+
session_data JSONB,
|
109 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
110 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
111 |
+
);
|
112 |
+
|
113 |
+
-- 插入範例商品資料
|
114 |
+
INSERT INTO products (name, description, price, stock, category) VALUES
|
115 |
+
('iPhone 15 Pro', '最新款 iPhone,配備 A17 Pro 晶片', 35900, 50, '手機'),
|
116 |
+
('MacBook Air M2', '輕薄筆記型電腦,搭載 M2 晶片', 37900, 30, '筆電'),
|
117 |
+
('AirPods Pro', '主動降噪無線耳機', 7490, 100, '耳機');
|
118 |
+
```
|
119 |
+
|
120 |
+
### 4. LINE Bot Webhook 設定
|
121 |
+
|
122 |
+
1. 部署到 Hugging Face Spaces 後,取得您的應用程式 URL
|
123 |
+
2. 在 LINE Developers Console 中設定 Webhook URL:
|
124 |
+
```
|
125 |
+
https://你的用戶名-你的空間名稱.hf.space/webhook
|
126 |
+
```
|
127 |
+
3. 啟用 Webhook 和 Auto-reply messages
|
128 |
+
|
129 |
+
### 5. 測試功能
|
130 |
+
|
131 |
+
部署完成後,您可以測試以下功能:
|
132 |
+
|
133 |
+
#### 用戶查詢
|
134 |
+
- "查詢用戶 張三"
|
135 |
+
- "找用戶名叫王小明的資料"
|
136 |
+
|
137 |
+
#### 商品查詢
|
138 |
+
- "查詢商品 iPhone"
|
139 |
+
- "有什麼手機商品"
|
140 |
+
- "價格 1000 到 5000 的商品"
|
141 |
+
|
142 |
+
#### 訂單查詢
|
143 |
+
- "查詢訂單 ORD001"
|
144 |
+
- "我的訂單狀態如何"
|
145 |
+
|
146 |
+
#### 統計分析
|
147 |
+
- "統計用戶數量"
|
148 |
+
- "總共有多少筆訂單"
|
149 |
+
|
150 |
+
#### 說明功能
|
151 |
+
- "幫助"
|
152 |
+
- "說明"
|
153 |
+
|
154 |
+
## 🔧 本地開發
|
155 |
+
|
156 |
+
如果要在本地開發:
|
157 |
+
|
158 |
+
1. 複製專案
|
159 |
+
2. 建立 `.env` 檔案(參考 `.env.example`)
|
160 |
+
3. 安裝依賴:`pip install -r requirements.txt`
|
161 |
+
4. 執行:`python -m uvicorn backend.main:app --reload --port 7860`
|
162 |
+
5. 使用 ngrok 建立公開 URL 用於 LINE Webhook 測試
|
163 |
+
|
164 |
+
## 📝 架構說明
|
165 |
+
|
166 |
+
- **FastAPI**: Web 框架和 API 端點
|
167 |
+
- **Pydantic**: 資料驗證和序列化
|
168 |
+
- **LINE Bot SDK**: 處理 LINE 訊息
|
169 |
+
- **Supabase**: PostgreSQL 資料庫
|
170 |
+
- **NLP Service**: 自然語言處理和意圖識別
|
171 |
+
- **Database Service**: 資料庫操作抽象層
|
172 |
+
|
173 |
+
## 🚨 注意事項
|
174 |
+
|
175 |
+
1. 確保所有環境變數都��確設定
|
176 |
+
2. Supabase 資料表必須先建立
|
177 |
+
3. LINE Bot Webhook URL 必須是 HTTPS
|
178 |
+
4. 測試時注意 LINE 訊息的回應時間限制(30秒)
|
test_api.py
ADDED
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
API 測試腳本
|
3 |
+
用於測試 LINE Bot 的各種功能
|
4 |
+
"""
|
5 |
+
|
6 |
+
import requests
|
7 |
+
import json
|
8 |
+
from typing import Dict, Any
|
9 |
+
|
10 |
+
class APITester:
|
11 |
+
def __init__(self, base_url: str = "http://localhost:7860"):
|
12 |
+
self.base_url = base_url
|
13 |
+
|
14 |
+
def test_health_check(self):
|
15 |
+
"""測試健康檢查端點"""
|
16 |
+
try:
|
17 |
+
response = requests.get(f"{self.base_url}/health")
|
18 |
+
print(f"Health Check: {response.status_code}")
|
19 |
+
print(f"Response: {response.json()}")
|
20 |
+
return response.status_code == 200
|
21 |
+
except Exception as e:
|
22 |
+
print(f"Health check failed: {e}")
|
23 |
+
return False
|
24 |
+
|
25 |
+
def test_root_endpoint(self):
|
26 |
+
"""測試根端點"""
|
27 |
+
try:
|
28 |
+
response = requests.get(f"{self.base_url}/")
|
29 |
+
print(f"Root Endpoint: {response.status_code}")
|
30 |
+
print(f"Response: {response.json()}")
|
31 |
+
return response.status_code == 200
|
32 |
+
except Exception as e:
|
33 |
+
print(f"Root endpoint test failed: {e}")
|
34 |
+
return False
|
35 |
+
|
36 |
+
def simulate_line_message(self, message: str, user_id: str = "test_user"):
|
37 |
+
"""模擬 LINE 訊息"""
|
38 |
+
# 這是一個簡化的 LINE webhook 格式
|
39 |
+
webhook_data = {
|
40 |
+
"events": [
|
41 |
+
{
|
42 |
+
"type": "message",
|
43 |
+
"message": {
|
44 |
+
"type": "text",
|
45 |
+
"text": message
|
46 |
+
},
|
47 |
+
"source": {
|
48 |
+
"type": "user",
|
49 |
+
"userId": user_id
|
50 |
+
},
|
51 |
+
"replyToken": "test_reply_token"
|
52 |
+
}
|
53 |
+
]
|
54 |
+
}
|
55 |
+
|
56 |
+
try:
|
57 |
+
response = requests.post(
|
58 |
+
f"{self.base_url}/webhook",
|
59 |
+
json=webhook_data,
|
60 |
+
headers={"Content-Type": "application/json"}
|
61 |
+
)
|
62 |
+
print(f"LINE Message Test: {response.status_code}")
|
63 |
+
print(f"Message: {message}")
|
64 |
+
return response.status_code == 200
|
65 |
+
except Exception as e:
|
66 |
+
print(f"LINE message test failed: {e}")
|
67 |
+
return False
|
68 |
+
|
69 |
+
def run_tests():
|
70 |
+
"""執行所有測試"""
|
71 |
+
tester = APITester()
|
72 |
+
|
73 |
+
print("=== API 測試開始 ===\n")
|
74 |
+
|
75 |
+
# 測試基本端點
|
76 |
+
print("1. 測試根端點...")
|
77 |
+
tester.test_root_endpoint()
|
78 |
+
print()
|
79 |
+
|
80 |
+
print("2. 測試健康檢查...")
|
81 |
+
tester.test_health_check()
|
82 |
+
print()
|
83 |
+
|
84 |
+
# 測試各種訊息
|
85 |
+
test_messages = [
|
86 |
+
"查詢用戶 張三",
|
87 |
+
"找訂單 ORD001",
|
88 |
+
"查詢商品 iPhone",
|
89 |
+
"價格 1000 到 5000 的商品",
|
90 |
+
"統計用戶數量",
|
91 |
+
"幫助"
|
92 |
+
]
|
93 |
+
|
94 |
+
print("3. 測試 LINE 訊息處理...")
|
95 |
+
for i, message in enumerate(test_messages, 1):
|
96 |
+
print(f"3.{i} 測試訊息: {message}")
|
97 |
+
tester.simulate_line_message(message)
|
98 |
+
print()
|
99 |
+
|
100 |
+
print("=== 測試完成 ===")
|
101 |
+
|
102 |
+
if __name__ == "__main__":
|
103 |
+
run_tests()
|