構(gòu)化賬目)
簡介這是一份面向個人開發(fā)者與財務(wù)自動化愛好者的智能記賬工具源碼包基于 OpenAI 接口與 Python 實現(xiàn)用于將銀行流水、發(fā)票、賬單等非結(jié)構(gòu)化財務(wù)文檔自動解析為可用的記賬數(shù)據(jù)適合具備一定 Python 基礎(chǔ)、希望實踐 AI 財務(wù)場景的讀者。壓縮包共 51 個文件約 37KB以 36 個 py 腳本為核心輔以 yaml、yml 配置定義任務(wù)與賬單流程另有 json、ini、makefile、pylintrc 等工程化文件整體結(jié)構(gòu)輕量、便于二次開發(fā)。目錄中可見 prompts、routers、models、utils、tests 等模塊分別承擔(dān)提示詞管理、接口路由、數(shù)據(jù)模型、日志與錯誤處理、單元測試等職責(zé)并預(yù)留 notion 集成入口方便對接外部記賬服務(wù)。目前已有 238 人學(xué)習(xí)下載。通過該資源可掌握從數(shù)據(jù)獲取、預(yù)處理、信息抽取到結(jié)構(gòu)化落庫與報告可視化的完整鏈路理解 OpenAI 自然語言理解在財務(wù)場景中的落地方式并借鑒其工程組織與測試思路。1. 自動記賬這件事難的不是記賬而是「記」很多人第一次聽到「基于 OpenAI 實現(xiàn)的自動記賬工具」腦子里浮現(xiàn)的是又一個記賬 App。但真正動手做過的人會告訴你記賬類工具最勸退的環(huán)節(jié)從來不是賬本本身而是「記」這個動作——手動選分類、填金額、挑賬戶三步下來十幾秒堅持不過兩周。我見過太多人下載了記賬軟件三天熱度之后賬本停在第一頁。這個方案要解決的就是這個斷點你只需要用自然語言說一句「昨天打車 38午飯跟同事 AA 付了 65」剩下的金額提取、分類判斷、賬戶歸屬、時間解析全部交給模型和一段 Python 邏輯去完成。它適合兩類人一類是想自己搭一套私人記賬流水、又不想被 App 綁架的開發(fā)者另一類是手里已經(jīng)有一堆微信/支付寶導(dǎo)出的 CSV想批量清洗成結(jié)構(gòu)化賬目的數(shù)據(jù)黨。核心鏈路其實很短——文本進、結(jié)構(gòu)化 JSON 出、落庫、對賬難的是讓模型穩(wěn)定地吐出你要的字段。2. 從一句話到一條賬目解析鏈路怎么搭2.1 為什么用「函數(shù)調(diào)用」而不是讓模型直接寫 SQL新手最容易踩的坑是直接給模型一句「把這句話轉(zhuǎn)成 INSERT 語句」。看起來省事實際上你會遇到三個問題模型偶爾會編造不存在的字段名、金額單位時有時無、日期格式每次都不一樣。更麻煩的是一旦模型輸出被拼進 SQL注入風(fēng)險就跟著來了。常見做法是用 OpenAI 的 function calling現(xiàn)在叫 tool calling能力把「記一筆賬」定義成一個帶嚴格 schema 的函數(shù)讓模型只負責(zé)填參數(shù)不負責(zé)拼語句。這樣模型輸出天然是 JSON字段類型由 schema 約束落庫那一步由你自己的代碼控制。我一般會把 schema 寫成這樣# tools.py # 定義記賬工具的參數(shù) schema模型只能按這個結(jié)構(gòu)填 RECORD_TOOL { type: function, function: { name: record_transaction, description: 記錄一筆個人收支流水, parameters: { type: object, properties: { amount: { type: number, description: 金額正數(shù)表示收入負數(shù)表示支出單位元 }, category: { type: string, enum: [餐飲, 交通, 購物, 居住, 醫(yī)療, 娛樂, 工資, 理財, 其他] }, account: { type: string, description: 支付賬戶如 微信/支付寶/銀行卡/現(xiàn)金 }, occurred_at: { type: string, description: 發(fā)生時間ISO8601 格式缺省用當(dāng)前時間 }, note: { type: string, description: 原始描述或補充備注 } }, required: [amount, category, account] } } }這段 schema 的關(guān)鍵在enum和required。enum把分類鎖死在一個閉集里模型沒法自由發(fā)揮造出「吃喝玩樂」這種新分類required保證金額、分類、賬戶三個字段一定存在缺了模型會被強制重試。amount用正負號區(qū)分收支比單獨加一個type字段更省事后面統(tǒng)計時直接sum就行。2.2 調(diào)用模型并拿到結(jié)構(gòu)化結(jié)果有了 schema調(diào)用邏輯就變得很直白。下面這段是核心解析函數(shù)注意tool_choice這個參數(shù)——它強制模型必須調(diào)用這個工具而不是跟你閑聊。# parser.py import json from openai import OpenAI from tools import RECORD_TOOL client OpenAI() # 從環(huán)境變量讀取 API Key SYSTEM_PROMPT 你是一個記賬助手。用戶會用口語描述一筆收支 你需要調(diào)用 record_transaction 工具提取字段。 規(guī)則 1. 支出金額為負數(shù)收入為正數(shù) 2. 無法判斷賬戶時默認填「微信」 3. 相對時間昨天、上周五請換算成具體日期 4. 不要向用戶追問直接給出最合理的判斷。 def parse_text(user_text: str) - dict: resp client.chat.completions.create( modelgpt-4o-mini, # 解析任務(wù)用輕量模型足夠 messages[ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_text}, ], tools[RECORD_TOOL], tool_choice{type: function, function: {name: record_transaction}}, temperature0, # 解析任務(wù)要確定性溫度歸零 ) call resp.choices[0].message.tool_calls[0] return json.loads(call.function.arguments)邏輯說明tool_choice指定具體函數(shù)名模型沒有「不調(diào)用」的選項這比在 prompt 里寫「請務(wù)必輸出 JSON」可靠得多。temperature0是為了讓同樣的輸入盡量得到同樣的輸出記賬這種場景不需要創(chuàng)造性。gpt-4o-mini這類小模型處理字段抽取完全夠用成本比大模型低一個數(shù)量級批量清洗 CSV 時這個差距會被放大到很可觀。參數(shù)說明model換成更強的模型不會讓字段準確率顯著提升反而拖慢速度除非你的輸入里夾雜大量方言和隱喻。temperature不要設(shè)成 0.7 以上否則同一句話兩次解析可能給出不同分類。2.3 落庫SQLite 起步別一上來就上 PostgreSQL個人記賬的數(shù)據(jù)量一年撐死幾千條SQLite 完全夠用而且零運維。表結(jié)構(gòu)建議把原始文本也存下來方便日后回溯模型判斷對不對。# storage.py import sqlite3 from datetime import datetime def init_db(pathledger.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, category TEXT NOT NULL, account TEXT NOT NULL, occurred_at TEXT NOT NULL, note TEXT, raw_text TEXT, -- 保留原始輸入便于復(fù)盤 created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() return conn def insert_tx(conn, tx: dict, raw: str): conn.execute( INSERT INTO transactions (amount, category, account, occurred_at, note, raw_text) VALUES (?, ?, ?, ?, ?, ?), (tx[amount], tx[category], tx[account], tx.get(occurred_at) or datetime.now().isoformat(), tx.get(note, ), raw) ) conn.commit()raw_text這一列是我強烈建議加的。模型分類偶爾會翻車比如把「買藥」歸到「購物」事后你只看到一條錯誤分類根本想不起來當(dāng)時說了什么。留著原文排查時一眼就能定位是模型的問題還是你表述的問題。3. 批量清洗歷史賬單CSV 導(dǎo)入的完整流程3.1 先看清你的 CSV 長什么樣從支付平臺導(dǎo)出的賬單格式五花八門。常見的有「交易時間 / 交易類型 / 交易對方 / 商品說明 / 收/支 / 金額」這種列結(jié)構(gòu)也有把金額和收支方向揉在一列的。動手寫代碼前先花五分鐘用 pandas 看一眼import pandas as pd df pd.read_csv(bill.csv, encodingutf-8) print(df.columns.tolist()) print(df.head(3)) print(df[收/支].value_counts())這一步能幫你確認三件事編碼是不是 UTF-8很多導(dǎo)出是 GBK讀進來會亂碼、金額列有沒有帶「¥」符號、收支方向是單獨一列還是靠正負號。我踩過的坑是直接假設(shè)金額是數(shù)字結(jié)果pd.read_csv把它讀成字符串后面求和全是拼接。3.2 逐行喂給模型但要控制并發(fā)和成本批量場景下逐行調(diào)用模型是最直觀的做法但幾千行串行跑會等到天荒地老。用線程池并發(fā)同時給個上限避免觸發(fā)速率限制。from concurrent.futures import ThreadPoolExecutor from parser import parse_text from storage import init_db, insert_tx import pandas as pd def clean_amount(v): # 去掉貨幣符號和千分位逗號轉(zhuǎn)成 float return float(str(v).replace(¥, ).replace(,, ).strip()) def process_row(row): text f{row[交易時間]} {row[交易對方]} {row[商品說明]} {row[金額]} try: tx parse_text(text) return tx, text except Exception as e: return None, text # 失敗的行先跳過后面單獨處理 def batch_import(csv_path, max_workers5): df pd.read_csv(csv_path, encodingutf-8) conn init_db() with ThreadPoolExecutor(max_workersmax_workers) as pool: for tx, raw in pool.map(process_row, df.to_dict(records)): if tx: insert_tx(conn, tx, raw) conn.close()max_workers5是個保守值實測再往上加容易撞到每分鐘請求數(shù)限制反而因為重試拖慢整體。失敗的行不要直接丟收集起來最后統(tǒng)一重跑往往是因為某一行文本特別長或者含特殊字符。3.3 用規(guī)則兜底別讓模型處理它不擅長的模型擅長理解「星巴克」是餐飲但不擅長做精確的算術(shù)和格式轉(zhuǎn)換。金額清洗、日期標準化這類確定性工作用代碼做比用模型做又快又準。我的分工原則是語義判斷交給模型格式規(guī)整交給代碼。比如日期模型返回的可能是「2024-03-15」也可能是「3月15日」落庫前統(tǒng)一用dateutil解析一遍from dateutil import parser as date_parser def normalize_date(s): try: return date_parser.parse(s).isoformat() except (ValueError, TypeError): return None # 解析不了就交給上層用當(dāng)前時間兜底4. 避坑與排查那些讓賬本對不上的細節(jié)4.1 金額正負號反了月底統(tǒng)計全錯現(xiàn)象月底一看支出是負數(shù)收入也是負數(shù)總支出算出來是個正數(shù)。原因多半是模型把「支出」也返回了正數(shù)而你的統(tǒng)計邏輯假設(shè)支出為負。解決在落庫前加一道校驗如果category屬于支出類且amount 0強制取反反之收入類強制取正。別指望 prompt 里寫一句「支出為負」模型就永遠遵守加代碼校驗才是后悔藥。4.2 同一筆賬被記了兩次現(xiàn)象批量導(dǎo)入時發(fā)現(xiàn)某些行重復(fù)。原因通常是并發(fā)重試——某次請求超時了你的代碼重試但第一次其實已經(jīng)成功落庫。解決給transactions表加一個基于raw_text occurred_at的唯一索引插入時用INSERT OR IGNORE。這樣即使重試也不會產(chǎn)生重復(fù)行。4.3 分類總是往「其他」里塞現(xiàn)象大量賬目分類是「其他」模型像是偷懶。原因有兩個一是你的enum里分類太粗模型找不到合適的就往兜底項放二是 prompt 里沒給分類的定義。解決在 system prompt 里給每個分類配一句說明比如「餐飲指正餐、外賣、咖啡奶茶購物指實物商品」。實測加了定義之后「其他」的占比能從三成降到一成以內(nèi)。4.4 中文編碼把 CSV 讀成亂碼現(xiàn)象pd.read_csv之后列名是??o¤?這種。原因文件是 GBK 編碼你用 UTF-8 讀。解決先試encodingutf-8報UnicodeDecodeError就換encodinggbk。更穩(wěn)的做法是用chardet探測編碼但個人項目里手動試兩次就夠了。4.5 模型偶爾返回空參數(shù)現(xiàn)象json.loads報錯或者amount字段是None。原因輸入文本太短或太模糊比如「花了點錢」模型無法確定金額。解決在parse_text里加一層校驗amount缺失時拋出明確異常讓上層決定是丟棄還是標記為待補充。不要讓它靜默寫入一條金額為 0 的記錄那比報錯更危險。5. 讓賬本自己會說話從流水到月度洞察賬目落庫只是起點真正有價值的是讓模型基于結(jié)構(gòu)化數(shù)據(jù)做二次分析。這一步和前面的解析是反過來的——前面是「文本轉(zhuǎn)結(jié)構(gòu)」這里是「結(jié)構(gòu)轉(zhuǎn)洞察」。我一般會寫一個查詢函數(shù)把某個月的流水拉出來拼成一段緊湊的文本再讓模型生成一段人話總結(jié)。def monthly_insight(conn, year_month2024-03): rows conn.execute( SELECT category, SUM(amount) AS total, COUNT(*) AS cnt FROM transactions WHERE occurred_at LIKE ? GROUP BY category ORDER BY total ASC, (f{year_month}%,) ).fetchall() summary \n.join( f{r[0]}: 合計 {r[1]:.2f} 元, 共 {r[2]} 筆 for r in rows ) prompt f以下是我 {year_month} 的支出分類匯總\n{summary}\n \ 請用三句話指出最值得注意的一點并給一條可執(zhí)行的省錢建議。 resp client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0.5, ) return resp.choices[0].message.content這里temperature可以稍微調(diào)高到 0.5讓總結(jié)讀起來不那么機械。注意查詢里ORDER BY total ASC因為支出是負數(shù)升序排列意味著花得最多的排在最前面模型一眼就能看到大頭在哪。進階一點的做法是把連續(xù)幾個月的分類匯總都喂進去讓模型找趨勢。比如「餐飲支出連續(xù)三個月上升」這種結(jié)論靠人翻賬本很難發(fā)現(xiàn)但模型對比幾組數(shù)字就能指出來。參數(shù)上要注意的是喂給模型的匯總文本別太長超過幾百行反而會稀釋重點按月聚合到分類級別就夠了。還有一個我常用的技巧讓模型給每筆大額支出打一個「必要性」標簽分「必要 / 可選 / 沖動」三檔存回數(shù)據(jù)庫。月底一統(tǒng)計沖動消費占比一目了然。這個標簽同樣用 tool calling 實現(xiàn)schema 里加一個enum字段即可和前面的記賬工具是同一套模式。我自己用這套東西跑了小半年最大的教訓(xùn)是別追求一次把分類體系設(shè)計完美。一開始我列了二十多個分類結(jié)果模型經(jīng)常在相近分類之間搖擺后來砍到九個準確率反而上去了。分類是給人看的夠用就行剩下的細節(jié)交給note字段。希望幫到你。本文還有配套的精品資源點擊獲取