0%

vibe coding langchain + linebot 記帳

 

簡單筆記下 vibe coding 記帳, 感覺效果有限

1
2
3
4
5
6
7
8
CREATE TABLE [dbo].[Expenses](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Category] [nvarchar](50) NULL,
[Amount] [decimal](10, 2) NULL,
[Description] [nvarchar](200) NULL,
[CreatedAt] [datetime] NULL,
[UserId] [nvarchar](250) NULL
)

這邊用 fastapi + langchain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import sys
import os
import re
import urllib.parse
import json
from datetime import datetime, timedelta
from pprint import pprint
# 統一從 typing 導入,並移除冗餘導入
from typing import Literal, List, Dict, Any, Optional

# LangChain Core/Community Imports
from langchain_community.utilities import SQLDatabase
from langchain_community.agent_toolkits import SQLDatabaseToolkit, create_sql_agent
from langchain_core.prompts import PromptTemplate
# 統一使用 Pydantic v2 標準
from pydantic import BaseModel, Field, ValidationError
from langchain_core.output_parsers import PydanticOutputParser

# OpenAI LLM
from langchain_openai import ChatOpenAI # 建議在環境變數設置 OPENAI_API_KEY

# FastAPI / LINE Bot Imports
from fastapi import FastAPI, Request, HTTPException
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextMessage, TextSendMessage

# 查詢結果的單筆資料結構 (Query data list item)
class ExpenseData(BaseModel):
Category: str = Field(description="支出的具體類別。")
Amount: float = Field(description="支出金額。")
Description: str = Field(description="支出事項的簡短描述。")
CreatedAt: str = Field(description="完整日期時間字串。")
UserId : Optional[str] = Field(description="使用者編號")

# 修正後的通用最終回覆結構 (Final Answer Schema)
# 必須使用 Optional/預設值處理 insert 和 query 之間的互斥欄位
class FinalResponse(BaseModel):
# 共同欄位
action: Literal["insert", "query"] = Field(description="要執行的動作,只能是 'insert' 或 'query'。")
message: str = Field(description="給長輩的嘮叨或提醒訊息,必須是中文長字串,每次都必須不同。")

# INSERT 專屬欄位 (QUERY 時為 None/空字串/0.0)
category: Optional[str] = Field(None, description="支出的具體類別。")
amount: Optional[float] = Field(None, description="支出金額。")
description: Optional[str] = Field(None, description="支出事項的簡短描述。")
created_at: Optional[str] = Field(None, description="完整日期時間字串。")
user_id : Optional[str] = Field(None, description="使用者編號")

# QUERY 專屬欄位 (INSERT 時為 None/空列表/空字串)
sql: Optional[str] = Field(None, description="Agent 執行過的 SQL SELECT 語句。")
data: Optional[List[ExpenseData]] = Field(None, description="SQL 查詢回傳的資料列表。")


app = FastAPI(title="長輩版中文記帳助理")

# sql server
params = urllib.parse.quote_plus(
"DRIVER={ODBC Driver 17 for SQL Server};"
"SERVER=(localdb)\\MSSQLLocalDB;"
"DATABASE=ExpenseDB;"
"Trusted_Connection=yes;"
)
connection_str = f"mssql+pyodbc:///?odbc_connect={params}"

try:
db = SQLDatabase.from_uri(connection_str)
except Exception as e:
print(f"🚨 資料庫連線錯誤:{e}")
sys.exit(1)

# llm parser
llm = ChatOpenAI(temperature=0, model="gpt-4o")
# llm = ChatOpenAI(temperature=0)

# PydanticOutputParser 使用單一的 FinalResponse 模型
pydantic_parser = PydanticOutputParser(pydantic_object=FinalResponse)
format_instructions = pydantic_parser.get_format_instructions()


# sql agent
toolkit = SQLDatabaseToolkit(db=db, llm=llm)
agent_executor = create_sql_agent(
llm=llm,
toolkit=toolkit,
agent_type="openai-tools",
verbose=True,
agent_executor_kwargs={"return_intermediate_steps": True},
)


prompt = PromptTemplate(
input_variables=["user_input", "today", "user_id", "format_instructions"],
template=(
"你是一位親切但愛嘮叨的台灣長輩記帳助理,會用長輩語氣提醒使用者花費要節省。\n\n"
"資料表結構:[Expenses(UserId NVARCHAR(100), Category NVARCHAR(50), Amount DECIMAL(10,2), Description NVARCHAR(200), CreatedAt DATETIME, UserId NVARCHAR(250))]\n"
"類別(Category) 分為:食、衣、住、行、育、樂、其他。\n"
"CreatedAt 欄位必須為完整日期時間格式,例如 '2025-10-25 10:30:00.000'\n\n"
"【規則】\n"
" 當使用者要求新增支出,生成正確的 SQL INSERT 語句,並在資料中包含 UserId。\n"
" 確保字串用單引號,數字型別正確。\n"
" 執行後回覆 JSON,例如:\n"
"{{'action':'insert','user_id':'{user_id}','category':'...','amount':...,'description':'...','created_at':'...','message':'隨機生成長輩嘮叨語氣,每次都不同'}}\n\n"
" 💡 message 範例可使用以下長輩語氣,但請每次隨機挑選或創造相似語氣,不要固定使用同一句:\n"
" - '唉呀~又花錢啦?要省一點哪~'\n"
" - '年輕人要懂理財啊~今天花太多了喔~'\n"
" - '唉唷~這筆支出有點大,記得下個月要省錢啊~'\n"
" - '哎呀~又出去吃飯啦?要學會自己煮飯省點錢啊~'\n"
" - '你真的很討債~今天不要亂花錢啊~'\n"
" - '歸剛ㄟ~買東西要有計畫,別亂花錢~'\n"
" - '唉唷~小朋友啊,這筆花費有點大,先忍一忍吧~'\n"
" - '年輕人要存錢啦~吃喝玩樂要適可而止~'\n"
"請生成符合規則的 JSON,message 必須使用長輩嘮叨語氣,每次都不同。\n\n"
" 當使用者要求查詢或統計,生成正確 SQL SELECT,並回覆 JSON,例如:\n"
"{{'action':'query','user_id':'{user_id}','sql':'SELECT ...','message':'你的花費','data':[{{'Category':'食','Amount':50.0,'Description':'買飯','CreatedAt':'2025-10-24 12:00:00'}}]}}\n\n"
"回覆 **只包含 JSON**,不要解釋或重複資料表結構。\n"
"如果輸入含時間詞(今天、昨天、前天等),請轉換成完整日期時間格式。\n\n"
"今天日期:{today}\n"
"使用者 ID:{user_id}\n"
"使用者輸入:{user_input}\n\n"
"請回覆 **JSON**,格式如下:\n"
"{format_instructions}"
)
)


# 轉換時間
def replace_time_words(user_input: str):
now = datetime.now()
found_date = False

# 基本日期詞
mapping = {"今天": now, "昨天": now - timedelta(days=1), "前天": now - timedelta(days=2)}
for word, dt in mapping.items():
if re.search(r'\b' + re.escape(word) + r'\b', user_input):
dt_str = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
user_input = re.sub(r'\b' + re.escape(word) + r'\b', dt_str, user_input)
found_date = True

# 星期詞
weekday_mapping = {
"禮拜一": 0, "禮拜二": 1, "禮拜三": 2, "禮拜四": 3,
"禮拜五": 4, "禮拜六": 5, "禮拜日": 6,
"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3,
"星期五": 4, "星期六": 5, "星期日": 6,
"週一": 0, "週二": 1, "週三": 2, "週四": 3,
"週五": 4, "週六": 5, "週日": 6,
}
for word, target_weekday in weekday_mapping.items():
pattern = rf"(上週|本週)?{word}"
for match in re.finditer(pattern, user_input):
prefix = match.group(1)
today_weekday = now.weekday()
delta_days = target_weekday - today_weekday
if prefix == "上週":
delta_days -= 7
elif prefix == "本週" or prefix is None:
if delta_days > 0:
delta_days -= 7
target_date = now + timedelta(days=delta_days)
dt_str = target_date.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
user_input = user_input.replace(match.group(0), dt_str)
found_date = True

# 本週/上週/這個月/上個月
week_month_mapping = {
"這週": now - timedelta(days=now.weekday()),
"上週": now - timedelta(days=now.weekday() + 7),
"這個月": now.replace(day=1),
"上個月": (now.replace(day=1) - timedelta(days=1)).replace(day=1),
}
for word, dt in week_month_mapping.items():
if re.search(r'\b' + re.escape(word) + r'\b', user_input):
dt_str = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
user_input = re.sub(r'\b' + re.escape(word) + r'\b', dt_str, user_input)
found_date = True

# 處理時間詞
match = re.search(r'(\d+)\s*點', user_input)
if match:
hour = int(match.group(1))
date_match = re.search(r'\d{4}-\d{2}-\d{2}', user_input)
if date_match:
user_input = re.sub(r'(\d+)\s*點', '', user_input)
user_input = user_input.strip() + f" {hour:02d}:00:00.000"
found_date = True

# 沒有日期時間,預設加今天
if not found_date and not re.search(r'\d{4}-\d{2}-\d{2}', user_input):
user_input += f" {now.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}"

return user_input




CHANNEL_ACCESS_TOKEN = os.getenv("CHANNEL_ACCESS_TOKEN")
# print(CHANNEL_ACCESS_TOKEN)
CHANNEL_SECRET = os.getenv("CHANNEL_SECRET")
# print(CHANNEL_SECRET)

line_bot_api = LineBotApi(CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(CHANNEL_SECRET)


# line callback
@app.post("/callback")
async def callback(request: Request):
signature = request.headers.get("X-Line-Signature", "")
body = await request.body()
body_str = body.decode("utf-8")

try:
handler.handle(body_str, signature)
except InvalidSignatureError:
raise HTTPException(status_code=400, detail="Invalid signature")

return "OK"


# 當使用者發送訊息觸發
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event: MessageEvent):
user_id = event.source.user_id
user_text = event.message.text
print(f"收到來自使用者 ID: {user_id} 的訊息: {user_text}")

# 預設訊息
reply_message = "阿公聽不懂你在說什麼啦~"

try:
# 處理時間詞
user_input = replace_time_words(user_text)
# 這裡使用當前的時間作為 TODAY
today = datetime.now().strftime("%Y-%m-%d")

# 準備 final prompt (新增 user_id 傳入)
final_prompt = prompt.format(
user_input=user_input,
today=today,
user_id=user_id,
format_instructions=pydantic_parser.get_format_instructions()
)

# 呼叫 agent
response = agent_executor.invoke({"input": final_prompt})
output_text = response.get("output", "")
pprint(output_text)

# 如果 Agent 輸出格式錯誤,會拋出異常並被 except 捕獲
parsed_data: FinalResponse = pydantic_parser.parse(output_text)
action = parsed_data.action

# 根據 action 選擇對應邏輯 (還原 DB 寫入邏輯)
if action == "insert":
# 確保所有必填欄位存在 (Pydantic 已經做了大部分檢查)
if not all([parsed_data.category, parsed_data.amount, parsed_data.description, parsed_data.created_at]):
raise ValueError("解析到的 INSERT 資料不完整。")

# 構建 SQL 語句 (必須包含 UserId 欄位)
sql_to_run = f"""
INSERT INTO Expenses (Category, Amount, Description, CreatedAt, UserId)
VALUES ('{parsed_data.category}', {parsed_data.amount}, '{parsed_data.description}', '{parsed_data.created_at}', '{user_id}');
"""
# 執行資料庫寫入操作
db.run(sql_to_run)

# 回覆 LLM 生成的長輩嘮叨訊息
reply_message = parsed_data.message

elif action == "query":
# 這裡假設 Agent 已經執行了 SELECT SQL 並將結果填入 data
data: List[ExpenseData] = parsed_data.data if parsed_data.data else []

if data:
reply_message = "你的花費明細如下:\n" + "\n".join(
[f"{d.CreatedAt} {d.Category} {d.Amount}元 {d.Description}" for d in data]
)
else:
reply_message = "阿公查不到你的花費資料~"

else:
# 處理無法識別的 action 或其他回覆
reply_message = parsed_data.message


except Exception as e:
reply_message = f"阿公嘆氣:網路線斷掉了,資料解析失敗。({type(e).__name__})"
print(f"致命錯誤 (可能是 JSON 或 Pydantic 解析失敗):{e}")

# 回覆 LINE
line_bot_api.reply_message(
event.reply_token,
TextSendMessage(text=reply_message)
)

執行

1
uvicorn main:app --reload

啟動 ngork 穿透, 網址會長下面 free.app 那樣, 最後到 line 管理後臺設定 callback 然後點 verify

1
2
3
ngrok http 8000

https://7a9545b1866f.ngrok-free.app
關閉