Init, baseline.

This commit is contained in:
leo 2026-01-21 10:45:30 +08:00
commit 5039612504
9 changed files with 856 additions and 0 deletions

19
.env Normal file
View File

@ -0,0 +1,19 @@
# --- Telegram 配置 ---
TG_TOKEN=8342041421:AAFV1TjMUh5DpTrstl3gZvY8vjuNFxLTyTQ
ADMIN_ID=1067428035 # 你的个人TG ID用于接收系统通知或管理
# --- 数据库配置 ---
DB_URL=postgresql+asyncpg://admin:sWEjhzRXt2.QgJPNvjW@db:5432/accounting
POSTGRES_USER=admin
POSTGRES_PASSWORD=sWEjhzRXt2.QgJPNvjW
POSTGRES_DB=accounting
# --- 本地 AI 配置 ---
VLM_MODEL=qwen3-vl:8b
OLLAMA_BASE_URL=http://vlm-service:11434
# --- 系统配置 ---
MAX_CONCURRENT_INFERENCE=1 # 根据显存决定,一般一张显卡设为 1
# --- 代理配置 ---
GLOBAL_PROXY=http://192.168.2.250:7890

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
bot/__pycache__/database.cpython-311.pyc
bot/__pycache__/vlm.cpython-311.pyc
data/ollama/id_ed25519
data/ollama/id_ed25519.pub
data/ollama/models/blobs/sha256-17e666fbe4f4c95d19936e9e4089c50c980df275d2937734edbe2a8e7f02eb40
data/ollama/models/blobs/sha256-7339fa418c9ad3e8e12e74ad0fd26a9cc4be8703f9c110728a992b193be85cb2
data/ollama/models/blobs/sha256-ed12a4674d727a74ac4816c906094ea9d3119fbea46ca93288c3ce4ffbe38c55
data/ollama/models/blobs/sha256-f6417cb1e26962991f8e875a93f3cb0f92bc9b4955e004881251ccbf934a19d2
data/ollama/models/manifests/registry.ollama.ai/library/qwen3-vl/8b

257
README.md Normal file
View File

@ -0,0 +1,257 @@
# 🤖 AI 记账助手 (AccountingBot)
基于 Telegram Bot 的智能记账助手,支持图片识别和自然语言输入,使用本地部署的 Qwen3-VL 视觉大模型进行账单解析。
## ✨ 功能特点
- 🖼️ **图片识别**拍照上传小票、截图AI 自动识别金额、类别和时间
- 💬 **文本输入**:发送"昨天晚上火锅花了238元"等自然语言,智能解析
- 📊 **数据管理**:查看、编辑、删除历史账单
- 📈 **数据导出**:一键导出 CSV 报表,包含分类统计和占比分析
- 🔐 **多用户支持**:每个用户独立的记账数据
- 🚀 **本地部署**:所有数据和 AI 模型完全本地化,保护隐私
## 🏗️ 技术架构
```
┌─────────────────┐
│ Telegram Bot │ ← 用户交互界面
└────────┬────────┘
┌────────▼────────┐
│ Bot Service │ ← aiogram 3.x + FSM 状态管理
└────┬───────┬────┘
│ │
│ └─────────────┐
│ │
┌────▼──────┐ ┌──────▼───────┐
│PostgreSQL │ │ Ollama + GPU │ ← Qwen3-VL 视觉模型
└───────────┘ └──────────────┘
```
### 核心组件
- **Bot Service**: Python + aiogram 3.x
- **VLM Service**: Ollama + Qwen3-VL (本地 GPU 推理)
- **Database**: PostgreSQL 15
- **容器编排**: Docker Compose + NVIDIA Runtime
## 📦 快速开始
### 环境要求
- Docker & Docker Compose
- NVIDIA GPU + nvidia-docker (用于 VLM 推理)
- Telegram Bot Token
### 1. 克隆项目
```bash
git clone <your-repo-url>
cd AccountingBot
```
### 2. 配置环境变量
创建 `.env` 文件:
```env
# Telegram Bot 配置
TG_TOKEN=your_telegram_bot_token
ADMIN_ID=123456789 # 你的个人TG ID用于接收系统通知或管理
# 数据库配置
POSTGRES_USER=accounting
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=accounting_db
DB_URL=postgresql+asyncpg://accounting:your_secure_password@db:5432/accounting_db
# VLM 模型配置
OLLAMA_BASE_URL=http://vlm-service:11434
VLM_MODEL=qwen3-vl:8b # 或其他 Qwen 视觉模型
MAX_CONCURRENT_INFERENCE=1 # GPU 并发数限制
# 全局代理(可选)
GLOBAL_PROXY=socks5://proxy_host:port
```
### 3. 启动服务
```bash
# 构建并启动所有服务
docker compose up -d
# 首次启动需要拉取 VLM 模型(约 2-5GB
docker exec vlm_service ollama pull qwen2.5-vl:3b
# 查看日志
docker compose logs -f bot
```
### 4. 验证部署
在 Telegram 中向你的 Bot 发送 `/start`,应该会收到欢迎消息。
## 🎮 使用指南
### 基础命令
| 命令 | 说明 |
|------|------|
| `/start` | 开始使用,查看帮助 |
| `/edit` | 管理历史账单(查看、修改、删除) |
| `/export` | 导出 CSV 报表(含分类统计) |
### 记账方式
#### 方式一:拍照上传
直接发送小票照片或消费截图Bot 会自动识别:
- 💰 金额
- 🏷️ 类别(餐饮、交通、购物等)
- 📅 交易时间
#### 方式二:文本输入
发送自然语言描述,例如:
- "今天午饭花了45.5元"
- "昨天打车30块"
- "上周五买书200元"
### 数据管理
1. **查看账单**`/edit` → 分页浏览历史记录
2. **编辑记录**:点击记录编号 → 修改金额/类别
3. **删除记录**:详情页点击 🗑️ 删除按钮
4. **导出数据**`/export` → 下载 CSV 文件
## 📂 项目结构
```
AccountingBot/
├── docker-compose.yml # 容器编排配置
├── .env # 环境变量(需自行创建)
├── bot/
│ ├── Dockerfile # Bot 服务镜像
│ ├── main.py # 主程序FSM 状态机 + 路由)
│ ├── database.py # SQLAlchemy 数据模型
│ ├── vlm.py # VLM 调用封装
│ └── requirements.txt # Python 依赖
└── data/
├── postgres/ # 数据库持久化目录
├── ollama/ # VLM 模型存储
└── temp/ # 临时文件
```
## 🔧 核心依赖
### Python 包
- `aiogram>=3.0.0` - Telegram Bot 框架
- `sqlalchemy[asyncio]` - 异步 ORM
- `asyncpg` - PostgreSQL 异步驱动
- `httpx` - 异步 HTTP 客户端(调用 Ollama
- `pillow` - 图片处理
### Docker 镜像
- `python:3.11-slim` - Bot 运行环境
- `ollama/ollama:latest` - VLM 服务
- `postgres:15-alpine` - 数据库
## 🎯 高级配置
### 模型选择
支持任何 Ollama 兼容的视觉模型:
```bash
# 小模型4B显存约 3GB
ollama pull qwen3-vl:4b
# 中等模型8B显存约 8GB
ollama pull qwen3-vl:8b
# 大模型30B显存约 20GB
ollama pull qwen3-vl:30b
```
修改 `.env` 中的 `VLM_MODEL` 变量即可切换。
### 并发控制
`MAX_CONCURRENT_INFERENCE` 控制同时处理的推理请求数:
- 单卡环境推荐设为 `1`
- 多卡环境可适当增加
### 代理配置
如果 Telegram API 访问受限,配置代理:
```env
GLOBAL_PROXY=http://127.0.0.1:1080
```
## 📊 数据库表结构
```sql
CREATE TABLE records (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- Telegram User ID
amount FLOAT NOT NULL, -- 金额
category VARCHAR(100), -- 类别
transaction_time TIMESTAMP, -- 交易时间
INDEX idx_user_id (user_id)
);
```
## 🐛 故障排查
### Bot 无响应
```bash
# 检查服务状态
docker compose ps
# 查看 Bot 日志
docker compose logs bot
# 检查数据库连接
docker exec accounting_bot python -c "from database import init_db; import asyncio; asyncio.run(init_db())"
```
### VLM 推理失败
```bash
# 检查模型是否已拉取
docker exec vlm_service ollama list
# 测试模型推理
docker exec vlm_service ollama run qwen2.5-vl:3b "hi"
# 查看 GPU 占用
nvidia-smi
```
### 数据库连接失败
```bash
# 检查数据库健康状态
docker compose exec db pg_isready -U accounting
# 重启数据库
docker compose restart db
```
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
## 📄 开源协议
MIT License
## 🙏 致谢
- [aiogram](https://github.com/aiogram/aiogram) - 现代化的 Telegram Bot 框架
- [Ollama](https://ollama.com/) - 本地大模型部署工具
- [Qwen-VL](https://github.com/QwenLM/Qwen-VL) - 阿里通义千问视觉模型
---
⭐ 如果这个项目对你有帮助,请给个 Star

6
bot/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
COPY . .
CMD ["python", "main.py"]

34
bot/database.py Normal file
View File

@ -0,0 +1,34 @@
import os
from datetime import datetime
from sqlalchemy import Column, Integer, Float, BigInteger, String, DateTime
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
# 从环境变量获取数据库连接 URL
DB_URL = os.getenv("DB_URL")
# 初始化 SQLAlchemy 基础类
Base = declarative_base()
class Record(Base):
__tablename__ = 'records'
id = Column(Integer, primary_key=True)
user_id = Column(BigInteger, index=True) # 区分不同用户
amount = Column(Float, nullable=False) # 花了多少钱
category = Column(String(100)) # 属于什么类别
transaction_time = Column(DateTime, default=datetime.now) # 在那个时间
# 创建异步引擎
engine = create_async_engine(DB_URL, echo=False)
# 创建异步会话工厂
AsyncSessionLocal = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
# 初始化数据库表
async def init_db():
async with engine.begin() as conn:
# 如果表不存在则创建
await conn.run_sync(Base.metadata.create_all)

398
bot/main.py Normal file
View File

@ -0,0 +1,398 @@
import asyncio
import os
import re
import io
import csv
import json
from datetime import datetime, timedelta, timezone
from aiogram import Bot, Dispatcher, types, F
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup, default_state
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import BotCommand, BufferedInputFile
from sqlalchemy import select, delete, update
# 导入提供的函数和数据库逻辑
from vlm import call_qwen_vlm
from database import init_db, AsyncSessionLocal, Record
# --- 配置 ---
DEFAULT_CATEGORIES = ["餐饮", "交通", "购物", "娱乐", "住宿", "办公", "学习", "医疗", "居家", "人情", "运动", "其它"]
class RecordState(StatesGroup):
waiting_confirm = State() # 识别后的确认状态
editing_new_amt = State() # 存入前修改金额
# --- 新增/细化修改状态 ---
editing_old_amt = State() # 修改数据库已有记录的金额
editing_old_cat = State() # 修改数据库已有记录的类别
proxy = os.getenv("BOT_PROXY")
session = AiohttpSession(proxy=proxy) if proxy else None
bot = Bot(token=os.getenv("TG_TOKEN"), session=session)
dp = Dispatcher(storage=MemoryStorage())
# --- 辅助函数 ---
def render_confirm_text(data):
return (
f"📝 **AI 识别结果**\n"
f"━━━━━━━━━━━━━━━\n"
f"💰 **金额**`{float(data.get('amount', 0)):.2f}` 元\n"
f"🏷 **类别**`{data.get('category', '其它')}`\n"
f"📅 **时间**`{data.get('transaction_time', '未知')}`\n"
f"━━━━━━━━━━━━━━━\n"
f"💡 请检查,如有误请点击下方按钮修改。"
)
def get_confirm_kb():
builder = InlineKeyboardBuilder()
builder.button(text="✅ 确认保存", callback_data="save_new")
builder.button(text="💰 改金额", callback_data="edit_new_amt")
builder.button(text="📂 改类别", callback_data="edit_new_cat")
builder.button(text="❌ 取消", callback_data="cancel_action")
builder.adjust(1, 2, 1)
return builder.as_markup()
# ================= 1. 指令处理器 =================
@dp.message(Command("start"), StateFilter("*"))
async def cmd_start(message: types.Message, state: FSMContext):
await state.clear()
welcome_text = (
"👋 **AI 记账助手**\n\n"
"● **发图**:识别小票或截图\n"
"● **发文**如“昨天晚上火锅花了238元”\n\n"
"📅 **管理指令**\n"
"/edit - 管理最近账单\n"
"/export - 导出 CSV 报表\n"
"━━━━━━━━━━━━━━━\n"
"请发送内容开始记账:"
)
await message.answer(welcome_text, parse_mode="Markdown")
@dp.message(Command("edit"), StateFilter("*"))
async def cmd_edit(message: types.Message, state: FSMContext):
await state.clear()
await show_record_list(message, message.from_user.id, page=0)
@dp.message(Command("export"), StateFilter("*"))
async def cmd_export(message: types.Message, state: FSMContext):
await state.clear()
user_id = message.from_user.id
processing_msg = await message.answer("📊 正在生成报表...")
async with AsyncSessionLocal() as session:
# 查询该用户的所有记录
stmt = select(Record).where(Record.user_id == user_id).order_by(Record.transaction_time.desc())
res = await session.execute(stmt)
records = res.scalars().all()
if not records:
await processing_msg.edit_text("📭 暂无数据可导出。")
return
# 生成 CSV
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer)
writer.writerow(["ID", "时间", "金额", "类别"])
total_amount = 0.0
for r in records:
writer.writerow([
r.id,
r.transaction_time.strftime("%Y-%m-%d %H:%M:%S"),
f"{r.amount:.2f}",
r.category
])
total_amount += r.amount
# 添加统计信息
writer.writerow([])
writer.writerow(["统计", "", "", ""])
writer.writerow(["总记录数", len(records), "", ""])
writer.writerow(["总金额", f"{total_amount:.2f}", "", ""])
# 按类别统计
category_stats = {}
for r in records:
category_stats[r.category] = category_stats.get(r.category, 0) + r.amount
writer.writerow([])
writer.writerow(["类别统计", "金额", "占比", ""])
for cat, amt in sorted(category_stats.items(), key=lambda x: x[1], reverse=True):
percentage = (amt / total_amount * 100) if total_amount > 0 else 0
writer.writerow([cat, f"{amt:.2f}", f"{percentage:.1f}%", ""])
# 转换为字节并发送
csv_bytes = csv_buffer.getvalue().encode('utf-8-sig') # 使用 UTF-8 BOM 以便 Excel 正确识别中文
filename = f"账单_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
file = BufferedInputFile(csv_bytes, filename=filename)
await processing_msg.delete()
await message.answer_document(
file,
caption=f"📊 **导出完成**\n\n📝 总记录:`{len(records)}` 条\n💰 总金额:`{total_amount:.2f}` 元",
parse_mode="Markdown"
)
# ================= 2. 核心:记账输入处理 (VLM) =================
@dp.message(F.photo | F.text, StateFilter(default_state))
async def handle_input(message: types.Message, state: FSMContext):
if message.text and message.text.startswith("/"): return
processing_msg = await message.answer("🧠 AI 正在分析账单...")
try:
if message.photo:
file_id = message.photo[-1].file_id
file_info = await bot.get_file(file_id)
photo_bytes = io.BytesIO()
await bot.download_file(file_info.file_path, photo_bytes)
vlm_res_str = await call_qwen_vlm(photo_bytes.getvalue(), is_image=True)
else:
vlm_res_str = await call_qwen_vlm(message.text, is_image=False)
data = json.loads(vlm_res_str)
await state.update_data(
amount=data.get("amount", 0.0),
category=data.get("category", "其它"),
transaction_time=data.get("transaction_time", "未知")
)
await processing_msg.delete()
await message.answer(render_confirm_text(data), reply_markup=get_confirm_kb(), parse_mode="Markdown")
await state.set_state(RecordState.waiting_confirm)
except Exception as e:
await processing_msg.edit_text(f"❌ 解析失败: {str(e)}")
# ================= 3. 新增记录的确认与修改 =================
@dp.callback_query(F.data == "save_new", RecordState.waiting_confirm)
async def cb_save_new(callback: types.CallbackQuery, state: FSMContext):
data = await state.get_data()
async with AsyncSessionLocal() as session:
new_rec = Record(
user_id=callback.from_user.id,
amount=abs(float(data['amount'])),
category=data['category'],
transaction_time=datetime.strptime(data['transaction_time'], "%Y-%m-%d %H:%M:%S")
)
session.add(new_rec)
await session.commit()
await callback.message.edit_text(f"✅ 已存入:{abs(float(data['amount']))}元 ({data['category']})")
await state.clear()
@dp.callback_query(F.data == "edit_new_amt", RecordState.waiting_confirm)
async def cb_edit_amt_before_save(callback: types.CallbackQuery, state: FSMContext):
await callback.message.answer("✍️ 请输入新金额(数字):")
await state.set_state(RecordState.editing_new_amt)
@dp.message(RecordState.editing_new_amt)
async def process_new_amt_input(message: types.Message, state: FSMContext):
if not re.match(r"^\d+(\.\d+)?$", message.text.strip()):
return await message.answer("⚠️ 请输入数字。")
await state.update_data(amount=float(message.text.strip()))
data = await state.get_data()
await state.set_state(RecordState.waiting_confirm)
await message.answer(render_confirm_text(data), reply_markup=get_confirm_kb(), parse_mode="Markdown")
@dp.callback_query(F.data == "edit_new_cat", RecordState.waiting_confirm)
async def cb_choose_cat_before_save(callback: types.CallbackQuery):
builder = InlineKeyboardBuilder()
for cat in DEFAULT_CATEGORIES:
builder.button(text=cat, callback_data=f"set_new_cat_{cat}")
builder.adjust(3)
await callback.message.edit_text("📂 请选择新类别:", reply_markup=builder.as_markup())
@dp.callback_query(F.data.startswith("set_new_cat_"), RecordState.waiting_confirm)
async def cb_set_cat_before_save(callback: types.CallbackQuery, state: FSMContext):
new_cat = callback.data.replace("set_new_cat_", "")
await state.update_data(category=new_cat)
data = await state.get_data()
await callback.message.edit_text(render_confirm_text(data), reply_markup=get_confirm_kb(), parse_mode="Markdown")
# ================= 4. 账单列表展示 =================
async def show_record_list(message_or_call, user_id, page=0):
limit = 5
offset = page * limit
async with AsyncSessionLocal() as session:
stmt = select(Record).where(Record.user_id == user_id).order_by(Record.id.desc()).limit(limit).offset(offset)
res = await session.execute(stmt)
records = res.scalars().all()
if not records and page == 0:
text = "📭 暂无账单。"
return await (message_or_call.answer(text) if isinstance(message_or_call, types.Message) else message_or_call.message.edit_text(text))
list_text = [f"📊 **近期账单 (第 {page+1} 页)**", "━━━━━━━━━━━━━━━"]
builder = InlineKeyboardBuilder()
for i, r in enumerate(records):
list_text.append(f"{i+1}️⃣ `{r.transaction_time.strftime('%m-%d')}` | **{r.category}** | `{r.amount:.2f}`")
builder.button(text=f"{i+1}", callback_data=f"manage_{r.id}")
builder.adjust(5)
nav_btns = []
if page > 0: nav_btns.append(types.InlineKeyboardButton(text="⬅️ 上一页", callback_data=f"page_{page-1}"))
if len(records) == limit: nav_btns.append(types.InlineKeyboardButton(text="下一页 ➡️", callback_data=f"page_{page+1}"))
if nav_btns: builder.row(*nav_btns)
builder.row(types.InlineKeyboardButton(text="❌ 关闭并返回", callback_data="close_menu"))
final_text = "\n".join(list_text)
if isinstance(message_or_call, types.Message):
await message_or_call.answer(final_text, reply_markup=builder.as_markup(), parse_mode="Markdown")
else:
await message_or_call.message.edit_text(final_text, reply_markup=builder.as_markup(), parse_mode="Markdown")
# ================= 5. 【修复部分】修改数据库已有记录的逻辑 =================
# 1. 点击“改金额”按钮
@dp.callback_query(F.data.startswith("fld_amt_"))
async def cb_edit_old_amt(callback: types.CallbackQuery, state: FSMContext):
rid = int(callback.data.split("_")[2])
await state.update_data(edit_rid=rid)
await state.set_state(RecordState.editing_old_amt)
# 创建一个取消按钮
builder = InlineKeyboardBuilder()
builder.button(text="❌ 取消修改", callback_data=f"manage_{rid}")
await callback.message.answer(
f"✍️ 请输入 ID:{rid} 的新金额:",
reply_markup=builder.as_markup()
)
await callback.answer()
# 2. 接收用户输入的金额并更新数据库
@dp.message(RecordState.editing_old_amt)
async def process_old_amt_input(message: types.Message, state: FSMContext):
if not re.match(r"^\d+(\.\d+)?$", message.text.strip()):
return await message.answer("⚠️ 请输入有效的数字。")
data = await state.get_data()
rid = data['edit_rid']
new_amt = float(message.text.strip())
async with AsyncSessionLocal() as session:
await session.execute(update(Record).where(Record.id == rid).values(amount=new_amt))
await session.commit()
await message.answer(f"✅ ID:{rid} 金额已修改为 {new_amt}")
await state.clear()
# 修改完后回到该记录的详情页
await refresh_record_detail(message, rid)
@dp.callback_query(F.data.startswith("fld_cat_"))
async def cb_edit_old_cat(callback: types.CallbackQuery, state: FSMContext):
rid = int(callback.data.split("_")[2])
await state.update_data(edit_rid=rid)
await state.set_state(RecordState.editing_old_cat)
builder = InlineKeyboardBuilder()
# 添加分类按钮
for cat in DEFAULT_CATEGORIES:
builder.button(text=cat, callback_data=f"set_old_cat_{cat}")
builder.adjust(3) # 每行3个分类按钮
# --- 新增返回按钮逻辑 ---
# 使用 .row() 确保返回按钮独占一行
builder.row(types.InlineKeyboardButton(text="🔙 取消并返回详情", callback_data=f"manage_{rid}"))
await callback.message.edit_text(
f"📂 请选择 ID:{rid} 的新类别:",
reply_markup=builder.as_markup()
)
# 4. 点击类别按钮进行更新
@dp.callback_query(F.data.startswith("set_old_cat_"), RecordState.editing_old_cat)
async def cb_set_old_cat(callback: types.CallbackQuery, state: FSMContext):
new_cat = callback.data.replace("set_old_cat_", "")
data = await state.get_data()
rid = data['edit_rid']
async with AsyncSessionLocal() as session:
await session.execute(update(Record).where(Record.id == rid).values(category=new_cat))
await session.commit()
await callback.answer(f"✅ 类别已更新为 {new_cat}")
await state.clear()
await refresh_record_detail(callback.message, rid)
# 辅助函数:修改后刷新显示该记录详情
async def refresh_record_detail(message: types.Message, rid: int):
async with AsyncSessionLocal() as session:
res = await session.execute(select(Record).where(Record.id == rid))
r = res.scalar_one_or_none()
if not r: return
builder = InlineKeyboardBuilder()
builder.button(text="💰 改金额", callback_data=f"fld_amt_{rid}")
builder.button(text="📂 改类别", callback_data=f"fld_cat_{rid}")
builder.button(text="🗑 删除", callback_data=f"del_{rid}")
builder.button(text="🔙 返回列表", callback_data="page_0")
builder.adjust(2, 2, 1)
text = (f"🛠 **账单详情 (ID: {rid})**\n━━━━━━━━━━━━━━━\n"
f"时间:`{r.transaction_time}`\n金额:`{r.amount:.2f}` 元\n类别:`{r.category}`")
await message.answer(text, reply_markup=builder.as_markup(), parse_mode="Markdown")
# ================= 原有逻辑保持不变 =================
@dp.callback_query(F.data.startswith("manage_"))
async def cb_manage_record(callback: types.CallbackQuery):
rid = int(callback.data.split("_")[1])
async with AsyncSessionLocal() as session:
res = await session.execute(select(Record).where(Record.id == rid))
r = res.scalar_one_or_none()
if not r: return await callback.answer("记录不存在")
builder = InlineKeyboardBuilder()
builder.button(text="💰 改金额", callback_data=f"fld_amt_{rid}")
builder.button(text="📂 改类别", callback_data=f"fld_cat_{rid}")
builder.button(text="🗑 删除", callback_data=f"del_{rid}")
builder.button(text="🔙 返回列表", callback_data="page_0")
builder.adjust(2, 2, 1)
text = (f"🛠 **账单详情 (ID: {rid})**\n━━━━━━━━━━━━━━━\n"
f"时间:`{r.transaction_time}`\n金额:`{r.amount:.2f}` 元\n类别:`{r.category}`")
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="Markdown")
@dp.callback_query(F.data.startswith("del_"))
async def cb_del(callback: types.CallbackQuery):
rid = int(callback.data.split("_")[1])
async with AsyncSessionLocal() as session:
await session.execute(delete(Record).where(Record.id == rid))
await session.commit()
await callback.answer("🗑 记录已删除")
await show_record_list(callback, callback.from_user.id, page=0)
# 分页和其他回调...
@dp.callback_query(F.data.startswith("page_"))
async def cb_pagination(callback: types.CallbackQuery):
await show_record_list(callback, callback.from_user.id, page=int(callback.data.split("_")[1]))
@dp.callback_query(F.data == "close_menu")
async def cb_close_menu(callback: types.CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("✅ 已退出列表。")
@dp.callback_query(F.data == "cancel_action", StateFilter("*"))
async def cb_cancel(callback: types.CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("❌ 操作已取消。")
async def main():
await init_db()
await bot.set_my_commands([
BotCommand(command="start", description="开始"),
BotCommand(command="edit", description="列表管理"),
BotCommand(command="export", description="导出数据")
])
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())

6
bot/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
aiogram>=3.0.0
httpx
pillow
sqlalchemy[asyncio]
asyncpg
aiohttp-socks

68
bot/vlm.py Normal file
View File

@ -0,0 +1,68 @@
import asyncio
import httpx
import base64
import os
import json
import re
from datetime import datetime, timedelta, timezone
gpu_sem = asyncio.Semaphore(int(os.getenv("MAX_CONCURRENT_INFERENCE", 1)))
async def call_qwen_vlm(content, is_image=True):
async with gpu_sem:
# 获取北京时间 (UTC+8)
tz_beijing = timezone(timedelta(hours=8))
now_beijing = datetime.now(tz_beijing).strftime("%Y-%m-%d %H:%M:%S")
system_prompt = (
"你是一个财务账单解析助手。请提取以下字段并返回 JSON\n"
"1. amount (浮点数)\n"
"2. category (餐饮, 交通, 购物, 娱乐, 医疗, 运动 , 住宿, 人情, 其它)\n"
f"3. transaction_time (格式YYYY-MM-DD HH:MM:SS。当前北京时间为{now_beijing})\n\n"
"时间提取准则:\n"
"- 优先寻找账单截图或文字中明确提到的交易时间。\n"
"- 若信息不全(如只有月日),请结合当前北京时间年份进行补全。\n"
"- 若完全没有时间信息,请直接使用上述提供的北京时间作为默认值。\n"
"注意:\n"
"- 严禁输出任何思维过程,只返回纯 JSON 字典,确保数据完整性。\n"
"- 忽略任何货币符号以及正负号,仅提取数字部分作为 amount。\n"
)
base_url = os.getenv('OLLAMA_BASE_URL', 'http://vlm-service:11434').rstrip('/')
payload = {
"model": os.getenv("VLM_MODEL"),
"prompt": system_prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1}
}
if is_image:
payload["images"] = [base64.b64encode(content).decode('utf-8')]
else:
payload["prompt"] += f"\n用户输入: {content}"
try:
async with httpx.AsyncClient(timeout=300.0, trust_env=False) as client:
resp = await client.post(f"{base_url}/api/generate", json=payload)
if resp.status_code == 200:
res_json = resp.json()
# 兼容 Qwen3-VL 可能将结果放在 thinking 字段的情况
raw_res = res_json.get("response", "") or res_json.get("thinking", "")
# 提取并验证 JSON
match = re.search(r'(\{.*\})', raw_res, re.DOTALL)
clean_json = match.group(1) if match else "{}"
# 检查必要字段是否存在,不存在则补齐
data = json.loads(clean_json)
data.setdefault("amount", 0.0)
data.setdefault("category", "其它")
data.setdefault("transaction_time", now_beijing)
return json.dumps(data)
return "{}"
except Exception as e:
print(f"VLM Error: {e}")
return "{}"

59
docker-compose.yml Normal file
View File

@ -0,0 +1,59 @@
services:
# 1. 机器人主逻辑服务
bot:
build: ./bot
container_name: accounting_bot
restart: always
env_file: .env
depends_on:
db:
condition: service_healthy # 确保数据库就绪后再启动
vlm-service:
condition: service_started
volumes:
- ./bot:/app
- ./data/temp:/app/temp
networks:
- accounting_net
environment:
- BOT_PROXY=${GLOBAL_PROXY} # 注入代理变量
- PYTHONUNBUFFERED=1 # 关键:禁用 Python 的输出缓冲
# 2. 本地视觉大模型 (Ollama + Qwen3-VL)
vlm-service:
image: ollama/ollama:latest
container_name: vlm_service
runtime: nvidia # 关键:直接调用 NVIDIA 运行时,解决权限和 OCI 错误
environment:
- NVIDIA_VISIBLE_DEVICES=all
- OLLAMA_KEEP_ALIVE=24h # 让模型常驻显存,提高多人使用的响应速度
volumes:
- ./data/ollama:/root/.ollama
restart: always
networks:
- accounting_net
# 3. 数据库服务 (PostgreSQL)
db:
image: postgres:15-alpine
container_name: accounting_db
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
restart: always
networks:
- accounting_net
networks:
accounting_net:
driver: bridge