Init, baseline.
This commit is contained in:
commit
5039612504
19
.env
Normal file
19
.env
Normal 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
9
.gitignore
vendored
Normal 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
257
README.md
Normal 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
6
bot/Dockerfile
Normal 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
34
bot/database.py
Normal 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
398
bot/main.py
Normal 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
6
bot/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
aiogram>=3.0.0
|
||||
httpx
|
||||
pillow
|
||||
sqlalchemy[asyncio]
|
||||
asyncpg
|
||||
aiohttp-socks
|
||||
68
bot/vlm.py
Normal file
68
bot/vlm.py
Normal 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
59
docker-compose.yml
Normal 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
|
||||
Reference in New Issue
Block a user