From 5039612504513bb2d9e18b81b025e74a11809246 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 21 Jan 2026 10:45:30 +0800 Subject: [PATCH] Init, baseline. --- .env | 19 +++ .gitignore | 9 + README.md | 257 ++++++++++++++++++++++++++++ bot/Dockerfile | 6 + bot/database.py | 34 ++++ bot/main.py | 398 +++++++++++++++++++++++++++++++++++++++++++ bot/requirements.txt | 6 + bot/vlm.py | 68 ++++++++ docker-compose.yml | 59 +++++++ 9 files changed, 856 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot/Dockerfile create mode 100644 bot/database.py create mode 100644 bot/main.py create mode 100644 bot/requirements.txt create mode 100644 bot/vlm.py create mode 100644 docker-compose.yml diff --git a/.env b/.env new file mode 100644 index 0000000..0904ac0 --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..698d847 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2e0988 --- /dev/null +++ b/README.md @@ -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 +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! \ No newline at end of file diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..696292a --- /dev/null +++ b/bot/Dockerfile @@ -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"] \ No newline at end of file diff --git a/bot/database.py b/bot/database.py new file mode 100644 index 0000000..f503278 --- /dev/null +++ b/bot/database.py @@ -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) \ No newline at end of file diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..b92ad67 --- /dev/null +++ b/bot/main.py @@ -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()) \ No newline at end of file diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..603e8cf --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,6 @@ +aiogram>=3.0.0 +httpx +pillow +sqlalchemy[asyncio] +asyncpg +aiohttp-socks \ No newline at end of file diff --git a/bot/vlm.py b/bot/vlm.py new file mode 100644 index 0000000..5531f21 --- /dev/null +++ b/bot/vlm.py @@ -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 "{}" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..af10b16 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file