Spaces:
Sleeping
Sleeping
Upload 40 files
Browse files- Dockerfile +25 -0
- README.md +9 -10
- README_SETUP.md +176 -0
- app/__init__.py +1 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/agent/__pycache__/orchestrator.cpython-311.pyc +0 -0
- app/agent/orchestrator.py +92 -0
- app/config.py +38 -0
- app/db/__pycache__/models.cpython-311.pyc +0 -0
- app/db/__pycache__/repo.cpython-311.pyc +0 -0
- app/db/models.py +78 -0
- app/db/repo.py +254 -0
- app/llm/__pycache__/client.cpython-311.pyc +0 -0
- app/llm/client.py +95 -0
- app/main.py +49 -0
- app/telegram/__pycache__/handler.cpython-311.pyc +0 -0
- app/telegram/handler.py +111 -0
- app/tools/__pycache__/mock_paytm.cpython-311.pyc +0 -0
- app/tools/mock_paytm.py +57 -0
- app/voice/__pycache__/stt.cpython-311.pyc +0 -0
- app/voice/stt.py +77 -0
- context.md +50 -0
- paytm llm api.md +104 -0
- paytm_llm_api_docs.md +152 -0
- requirements.txt +5 -0
- scripts/__pycache__/seed_scenarios.cpython-311.pyc +0 -0
- scripts/seed_scenarios.py +14 -0
- tests/__pycache__/test_context_memory.cpython-311-pytest-9.0.2.pyc +0 -0
- tests/__pycache__/test_mock_tools.cpython-311-pytest-9.0.2.pyc +0 -0
- tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.2.pyc +0 -0
- tests/__pycache__/test_smoke.cpython-311-pytest-9.0.2.pyc +0 -0
- tests/__pycache__/test_telegram_handler.cpython-311-pytest-9.0.2.pyc +0 -0
- tests/test_context_memory.py +148 -0
- tests/test_mock_tools.py +28 -0
- tests/test_orchestrator.py +30 -0
- tests/test_smoke.py +26 -0
- tests/test_telegram_handler.py +36 -0
- via.sqlite3 +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /code
|
| 4 |
+
|
| 5 |
+
# Copy and install requirements as root
|
| 6 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 7 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 8 |
+
|
| 9 |
+
# Set up a new user named "user" with user ID 1000
|
| 10 |
+
RUN useradd -m -u 1000 user
|
| 11 |
+
|
| 12 |
+
# Switch to the "user" user
|
| 13 |
+
USER user
|
| 14 |
+
ENV HOME=/home/user \
|
| 15 |
+
PATH=/home/user/.local/bin:$PATH
|
| 16 |
+
|
| 17 |
+
WORKDIR $HOME/app
|
| 18 |
+
|
| 19 |
+
# Copy the rest of the app with correct ownership
|
| 20 |
+
COPY --chown=user . $HOME/app
|
| 21 |
+
|
| 22 |
+
# Expose port 7860 which is the default for Hugging Face Spaces
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
title: Via Demo
|
| 3 |
-
emoji: 📈
|
| 4 |
-
colorFrom: gray
|
| 5 |
-
colorTo: red
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Via
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
Via is a Python service for handling Telegram workflows and backend integrations.
|
| 4 |
+
|
| 5 |
+
## Quick Start
|
| 6 |
+
|
| 7 |
+
1. Create and activate a virtual environment.
|
| 8 |
+
2. Install dependencies from `requirements.txt`.
|
| 9 |
+
3. Copy `.env.example` to `.env` and fill in required values.
|
| 10 |
+
4. Run the app according to project scripts and setup docs.
|
README_SETUP.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Via Telegram Setup Guide (Simple V1)
|
| 2 |
+
|
| 3 |
+
This guide gets your bot working on Telegram with:
|
| 4 |
+
- mocked Paytm-like MCP tools (local only)
|
| 5 |
+
- Groq inference APIs for LLM (and optional STT)
|
| 6 |
+
|
| 7 |
+
No real Paytm payment APIs are used.
|
| 8 |
+
|
| 9 |
+
## 1) Prerequisites
|
| 10 |
+
|
| 11 |
+
- Python 3.11+
|
| 12 |
+
- A Telegram account
|
| 13 |
+
- A bot token from [@BotFather](https://t.me/BotFather)
|
| 14 |
+
- A public HTTPS URL for webhook testing (for local dev use [ngrok](https://ngrok.com/))
|
| 15 |
+
- A Groq API key from [Groq Console](https://console.groq.com/keys)
|
| 16 |
+
|
| 17 |
+
## 2) Install and configure
|
| 18 |
+
|
| 19 |
+
From project root:
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
python3 -m venv .venv
|
| 23 |
+
source .venv/bin/activate
|
| 24 |
+
python3 -m pip install -r requirements.txt
|
| 25 |
+
cp .env.example .env
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
Set `.env` values:
|
| 29 |
+
|
| 30 |
+
```env
|
| 31 |
+
APP_ENV=dev
|
| 32 |
+
TELEGRAM_BOT_TOKEN=<your_telegram_bot_token>
|
| 33 |
+
TELEGRAM_WEBHOOK_SECRET=<long_random_string>
|
| 34 |
+
|
| 35 |
+
GROQ_API_KEY=<your_groq_api_key>
|
| 36 |
+
LLM_INFERENCE_URL=https://api.groq.com/openai/v1/chat/completions
|
| 37 |
+
LLM_INFERENCE_API_KEY=
|
| 38 |
+
LLM_MODEL=llama-3.3-70b-versatile
|
| 39 |
+
|
| 40 |
+
STT_INFERENCE_URL=https://api.groq.com/openai/v1/audio/transcriptions
|
| 41 |
+
STT_API_KEY=
|
| 42 |
+
STT_MODEL=whisper-large-v3-turbo
|
| 43 |
+
|
| 44 |
+
DB_PATH=via.sqlite3
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## 3) Run the API
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
source .venv/bin/activate
|
| 51 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Health check:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
curl http://127.0.0.1:8000/health
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Expected:
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{"status":"ok","env":"dev"}
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## 4) Expose local server to Telegram
|
| 67 |
+
|
| 68 |
+
In a second terminal:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
ngrok http 8000
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
Copy the HTTPS forwarding URL, e.g. `https://abc123.ngrok-free.app`.
|
| 75 |
+
|
| 76 |
+
## 5) Register Telegram webhook
|
| 77 |
+
|
| 78 |
+
Replace placeholders and run:
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
|
| 82 |
+
-H "Content-Type: application/json" \
|
| 83 |
+
-d '{
|
| 84 |
+
"url": "https://<YOUR_PUBLIC_HOST>/telegram/webhook",
|
| 85 |
+
"secret_token": "<TELEGRAM_WEBHOOK_SECRET>"
|
| 86 |
+
}'
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Verify webhook:
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
`url` should point to `/telegram/webhook` and `last_error_message` should be empty.
|
| 96 |
+
|
| 97 |
+
## 6) Test from Telegram app
|
| 98 |
+
|
| 99 |
+
Open your bot chat and send:
|
| 100 |
+
|
| 101 |
+
- `show recent orders`
|
| 102 |
+
- `show settlement summary`
|
| 103 |
+
- `create payment link 250`
|
| 104 |
+
- `refund txn-2001 amount 50`
|
| 105 |
+
- `confirm`
|
| 106 |
+
- `/debug last_update_type` (shows current Telegram update type and last processed type)
|
| 107 |
+
|
| 108 |
+
Voice test:
|
| 109 |
+
- Send a voice note like: `refund txn-2001 amount 30`
|
| 110 |
+
- Bot should reply with transcript + action prompt.
|
| 111 |
+
|
| 112 |
+
## 7) Expected behavior
|
| 113 |
+
|
| 114 |
+
- Orders/links/refunds/settlements come from local mock DB.
|
| 115 |
+
- Refund mutation is gated:
|
| 116 |
+
- first message prepares action
|
| 117 |
+
- `confirm` executes it
|
| 118 |
+
- Tool calls are audited in `tool_audit` table.
|
| 119 |
+
|
| 120 |
+
## 8) Quick troubleshooting
|
| 121 |
+
|
| 122 |
+
- `401 invalid webhook secret`
|
| 123 |
+
- `.env` secret and Telegram `setWebhook secret_token` must match exactly.
|
| 124 |
+
|
| 125 |
+
- Telegram webhook not hitting local app
|
| 126 |
+
- ensure ngrok URL is alive and `/telegram/webhook` is reachable.
|
| 127 |
+
- re-run `setWebhook` each time ngrok URL changes.
|
| 128 |
+
|
| 129 |
+
- LLM fallback generic/error response
|
| 130 |
+
- verify `GROQ_API_KEY` is valid.
|
| 131 |
+
- verify `LLM_INFERENCE_URL` is `.../chat/completions`.
|
| 132 |
+
- check model name in `LLM_MODEL`.
|
| 133 |
+
|
| 134 |
+
- Voice transcription unavailable
|
| 135 |
+
- verify `STT_INFERENCE_URL` ends with `/audio/transcriptions`.
|
| 136 |
+
- verify `STT_API_KEY` (or `GROQ_API_KEY`) is present.
|
| 137 |
+
- ensure your bot received a real Telegram `voice` message (not audio/file attachment).
|
| 138 |
+
|
| 139 |
+
## 9) Local validation before Telegram
|
| 140 |
+
|
| 141 |
+
Run tests:
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
python3 -m pytest -q
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Seed richer mock scenarios (recommended before Telegram testing):
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
python3 -m scripts.seed_scenarios
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
This seeds multiple situations:
|
| 154 |
+
- healthy collections day
|
| 155 |
+
- UPI failure dip day
|
| 156 |
+
- pending + successful refunds
|
| 157 |
+
- multiple settlement payouts
|
| 158 |
+
- active/expired payment links
|
| 159 |
+
|
| 160 |
+
Manual webhook simulation:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
curl -X POST http://127.0.0.1:8000/telegram/webhook \
|
| 164 |
+
-H "Content-Type: application/json" \
|
| 165 |
+
-H "X-Telegram-Bot-Api-Secret-Token: <TELEGRAM_WEBHOOK_SECRET>" \
|
| 166 |
+
-d '{
|
| 167 |
+
"message": {
|
| 168 |
+
"chat": {"id": 12345},
|
| 169 |
+
"text": "show settlement summary"
|
| 170 |
+
}
|
| 171 |
+
}'
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
If you want, next step can be a production-ready `README.md` with Render deploy instructions and persistent Postgres instead of SQLite.
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Via application package."""
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (187 Bytes). View file
|
|
|
app/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (2.24 kB). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (2.91 kB). View file
|
|
|
app/agent/__pycache__/orchestrator.cpython-311.pyc
ADDED
|
Binary file (6.91 kB). View file
|
|
|
app/agent/orchestrator.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from app.db.repo import Repository
|
| 5 |
+
from app.llm.client import LlmClient
|
| 6 |
+
from app.tools.mock_paytm import MockPaytmTools
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Orchestrator:
|
| 10 |
+
def __init__(self, repo: Repository, tools: MockPaytmTools, llm: LlmClient) -> None:
|
| 11 |
+
self.repo = repo
|
| 12 |
+
self.tools = tools
|
| 13 |
+
self.llm = llm
|
| 14 |
+
|
| 15 |
+
def handle_user_text(self, telegram_id: str, text: str, input_type: str = "text") -> str:
|
| 16 |
+
user_id = self.repo.ensure_user(telegram_id)
|
| 17 |
+
cleaned = (text or "").strip()
|
| 18 |
+
lowered = cleaned.lower()
|
| 19 |
+
|
| 20 |
+
if lowered.startswith("confirm"):
|
| 21 |
+
response = self._handle_confirmation(telegram_id)
|
| 22 |
+
self.repo.save_message(user_id, input_type, cleaned, response)
|
| 23 |
+
return response
|
| 24 |
+
|
| 25 |
+
if "settlement" in lowered:
|
| 26 |
+
summary = self.tools.get_settlement_summary()
|
| 27 |
+
s = summary["summary"]
|
| 28 |
+
response = (
|
| 29 |
+
f"📊 <b>Settlement</b>\n"
|
| 30 |
+
f"Gross: <b>₹{s['gross']:,.0f}</b> | Fee: ₹{s['fee']:,.0f} | Net: <b>₹{s['net']:,.0f}</b>"
|
| 31 |
+
)
|
| 32 |
+
self.repo.save_message(user_id, input_type, cleaned, response)
|
| 33 |
+
return response
|
| 34 |
+
|
| 35 |
+
if "order" in lowered:
|
| 36 |
+
data = self.tools.fetch_order_list()
|
| 37 |
+
top = ", ".join(f"<code>{o['order_id']}</code> {o['status']}" for o in data["orders"][:3])
|
| 38 |
+
response = f"📋 <b>{data['count']} orders</b>\n{top}"
|
| 39 |
+
self.repo.save_message(user_id, input_type, cleaned, response)
|
| 40 |
+
return response
|
| 41 |
+
|
| 42 |
+
if "link" in lowered and any(ch.isdigit() for ch in lowered):
|
| 43 |
+
amount = self._extract_first_amount(cleaned) or 100.0
|
| 44 |
+
link = self.tools.create_link(amount=amount)
|
| 45 |
+
response = f"✅ Payment link <code>{link['link_id']}</code> created for <b>₹{link['amount']:,.0f}</b>"
|
| 46 |
+
self.repo.save_message(user_id, input_type, cleaned, response)
|
| 47 |
+
return response
|
| 48 |
+
|
| 49 |
+
if "refund" in lowered:
|
| 50 |
+
txn_id = self._extract_token(cleaned, r"(TXN[-_ ]?\d+)")
|
| 51 |
+
amount = self._extract_first_amount(cleaned) or 100.0
|
| 52 |
+
if not txn_id:
|
| 53 |
+
response = "⚠️ Need a transaction ID to refund — e.g. <code>TXN-2001</code>"
|
| 54 |
+
self.repo.save_message(user_id, input_type, cleaned, response)
|
| 55 |
+
return response
|
| 56 |
+
self.repo.upsert_pending_action(
|
| 57 |
+
telegram_id,
|
| 58 |
+
action_type="initiate_refund",
|
| 59 |
+
payload={"txn_id": txn_id.replace(" ", "-").replace("_", "-"), "amount": amount},
|
| 60 |
+
)
|
| 61 |
+
response = f"💸 Refund <code>{txn_id}</code> for <b>₹{amount:,.0f}</b>\n\nReply <b>confirm</b> to proceed."
|
| 62 |
+
self.repo.save_message(user_id, input_type, cleaned, response)
|
| 63 |
+
return response
|
| 64 |
+
|
| 65 |
+
context = self.repo.get_recent_context(user_id)
|
| 66 |
+
llm_text = self.llm.generate(cleaned, history=context)
|
| 67 |
+
self.repo.save_message(user_id, input_type, cleaned, llm_text)
|
| 68 |
+
return llm_text
|
| 69 |
+
|
| 70 |
+
def _handle_confirmation(self, telegram_id: str) -> str:
|
| 71 |
+
pending = self.repo.pop_pending_action(telegram_id)
|
| 72 |
+
if not pending:
|
| 73 |
+
return "Nothing pending to confirm."
|
| 74 |
+
if pending["action_type"] == "initiate_refund":
|
| 75 |
+
payload = pending["payload"]
|
| 76 |
+
result = self.tools.initiate_refund(payload["txn_id"], float(payload["amount"]))
|
| 77 |
+
return f"✅ Refund initiated\n<code>{result['refund_id']}</code> — {result['status']}"
|
| 78 |
+
return "Pending action type not supported."
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def _extract_first_amount(text: str) -> float | None:
|
| 82 |
+
match = re.search(r"(\d+(?:\.\d{1,2})?)", text)
|
| 83 |
+
if not match:
|
| 84 |
+
return None
|
| 85 |
+
return float(match.group(1))
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def _extract_token(text: str, pattern: str) -> str | None:
|
| 89 |
+
match = re.search(pattern, text, re.IGNORECASE)
|
| 90 |
+
if not match:
|
| 91 |
+
return None
|
| 92 |
+
return str(match.group(1)).upper()
|
app/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass(frozen=True)
|
| 6 |
+
class Settings:
|
| 7 |
+
app_env: str
|
| 8 |
+
telegram_bot_token: str
|
| 9 |
+
telegram_webhook_secret: str
|
| 10 |
+
llm_inference_url: str
|
| 11 |
+
llm_inference_api_key: str
|
| 12 |
+
llm_model: str
|
| 13 |
+
stt_inference_url: str
|
| 14 |
+
stt_api_key: str
|
| 15 |
+
stt_model: str
|
| 16 |
+
db_path: str
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_settings() -> Settings:
|
| 20 |
+
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 21 |
+
llm_key = os.getenv("LLM_INFERENCE_API_KEY", "") or groq_key
|
| 22 |
+
stt_key = os.getenv("STT_API_KEY", "") or llm_key or groq_key
|
| 23 |
+
return Settings(
|
| 24 |
+
app_env=os.getenv("APP_ENV", "dev"),
|
| 25 |
+
telegram_bot_token=os.getenv("TELEGRAM_BOT_TOKEN", ""),
|
| 26 |
+
telegram_webhook_secret=os.getenv("TELEGRAM_WEBHOOK_SECRET", "local-secret"),
|
| 27 |
+
llm_inference_url=os.getenv(
|
| 28 |
+
"LLM_INFERENCE_URL", "https://api.groq.com/openai/v1/chat/completions"
|
| 29 |
+
),
|
| 30 |
+
llm_inference_api_key=llm_key,
|
| 31 |
+
llm_model=os.getenv("LLM_MODEL", "llama-3.3-70b-versatile"),
|
| 32 |
+
stt_inference_url=os.getenv(
|
| 33 |
+
"STT_INFERENCE_URL", "https://api.groq.com/openai/v1/audio/transcriptions"
|
| 34 |
+
),
|
| 35 |
+
stt_api_key=stt_key,
|
| 36 |
+
stt_model=os.getenv("STT_MODEL", "whisper-large-v3-turbo"),
|
| 37 |
+
db_path=os.getenv("DB_PATH", "via.sqlite3"),
|
| 38 |
+
)
|
app/db/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (2.32 kB). View file
|
|
|
app/db/__pycache__/repo.cpython-311.pyc
ADDED
|
Binary file (20.6 kB). View file
|
|
|
app/db/models.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SCHEMA_SQL = """
|
| 2 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 3 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 4 |
+
telegram_id TEXT UNIQUE NOT NULL,
|
| 5 |
+
preferred_language TEXT DEFAULT 'en'
|
| 6 |
+
);
|
| 7 |
+
|
| 8 |
+
CREATE TABLE IF NOT EXISTS messages (
|
| 9 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 10 |
+
user_id INTEGER NOT NULL,
|
| 11 |
+
input_type TEXT NOT NULL,
|
| 12 |
+
transcript_text TEXT,
|
| 13 |
+
response_text TEXT,
|
| 14 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 15 |
+
FOREIGN KEY(user_id) REFERENCES users(id)
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
CREATE TABLE IF NOT EXISTS orders (
|
| 19 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 20 |
+
order_id TEXT UNIQUE NOT NULL,
|
| 21 |
+
amount REAL NOT NULL,
|
| 22 |
+
status TEXT NOT NULL,
|
| 23 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
CREATE TABLE IF NOT EXISTS payments (
|
| 27 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 28 |
+
txn_id TEXT UNIQUE NOT NULL,
|
| 29 |
+
order_id TEXT NOT NULL,
|
| 30 |
+
mode TEXT NOT NULL,
|
| 31 |
+
status TEXT NOT NULL,
|
| 32 |
+
gateway TEXT NOT NULL,
|
| 33 |
+
amount REAL NOT NULL
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
CREATE TABLE IF NOT EXISTS payment_links (
|
| 37 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 38 |
+
link_id TEXT UNIQUE NOT NULL,
|
| 39 |
+
amount REAL NOT NULL,
|
| 40 |
+
customer_name TEXT,
|
| 41 |
+
status TEXT NOT NULL DEFAULT 'ACTIVE',
|
| 42 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
CREATE TABLE IF NOT EXISTS refunds (
|
| 46 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 47 |
+
refund_id TEXT UNIQUE NOT NULL,
|
| 48 |
+
txn_id TEXT NOT NULL,
|
| 49 |
+
amount REAL NOT NULL,
|
| 50 |
+
status TEXT NOT NULL,
|
| 51 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
CREATE TABLE IF NOT EXISTS settlements (
|
| 55 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 56 |
+
payout_id TEXT UNIQUE NOT NULL,
|
| 57 |
+
payout_date TEXT NOT NULL,
|
| 58 |
+
gross REAL NOT NULL,
|
| 59 |
+
fee REAL NOT NULL,
|
| 60 |
+
net REAL NOT NULL
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
CREATE TABLE IF NOT EXISTS tool_audit (
|
| 64 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 65 |
+
tool_name TEXT NOT NULL,
|
| 66 |
+
args_json TEXT NOT NULL,
|
| 67 |
+
result_json TEXT NOT NULL,
|
| 68 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 69 |
+
);
|
| 70 |
+
|
| 71 |
+
CREATE TABLE IF NOT EXISTS pending_actions (
|
| 72 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 73 |
+
telegram_id TEXT UNIQUE NOT NULL,
|
| 74 |
+
action_type TEXT NOT NULL,
|
| 75 |
+
payload_json TEXT NOT NULL,
|
| 76 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 77 |
+
);
|
| 78 |
+
"""
|
app/db/repo.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import sqlite3
|
| 3 |
+
from contextlib import contextmanager
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from app.db.models import SCHEMA_SQL
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Repository:
|
| 10 |
+
def __init__(self, db_path: str) -> None:
|
| 11 |
+
self.db_path = db_path
|
| 12 |
+
self._initialize()
|
| 13 |
+
|
| 14 |
+
@contextmanager
|
| 15 |
+
def _connect(self):
|
| 16 |
+
conn = sqlite3.connect(self.db_path)
|
| 17 |
+
conn.row_factory = sqlite3.Row
|
| 18 |
+
try:
|
| 19 |
+
yield conn
|
| 20 |
+
finally:
|
| 21 |
+
conn.close()
|
| 22 |
+
|
| 23 |
+
def _initialize(self) -> None:
|
| 24 |
+
with self._connect() as conn:
|
| 25 |
+
conn.executescript(SCHEMA_SQL)
|
| 26 |
+
conn.commit()
|
| 27 |
+
|
| 28 |
+
def ensure_user(self, telegram_id: str) -> int:
|
| 29 |
+
with self._connect() as conn:
|
| 30 |
+
row = conn.execute(
|
| 31 |
+
"SELECT id FROM users WHERE telegram_id = ?", (telegram_id,)
|
| 32 |
+
).fetchone()
|
| 33 |
+
if row:
|
| 34 |
+
return int(row["id"])
|
| 35 |
+
cursor = conn.execute(
|
| 36 |
+
"INSERT INTO users(telegram_id) VALUES(?)",
|
| 37 |
+
(telegram_id,),
|
| 38 |
+
)
|
| 39 |
+
conn.commit()
|
| 40 |
+
return int(cursor.lastrowid)
|
| 41 |
+
|
| 42 |
+
def save_message(
|
| 43 |
+
self, user_id: int, input_type: str, transcript_text: str, response_text: str
|
| 44 |
+
) -> None:
|
| 45 |
+
with self._connect() as conn:
|
| 46 |
+
conn.execute(
|
| 47 |
+
"""
|
| 48 |
+
INSERT INTO messages(user_id, input_type, transcript_text, response_text)
|
| 49 |
+
VALUES(?, ?, ?, ?)
|
| 50 |
+
""",
|
| 51 |
+
(user_id, input_type, transcript_text, response_text),
|
| 52 |
+
)
|
| 53 |
+
conn.commit()
|
| 54 |
+
|
| 55 |
+
def get_last_input_type_for_telegram_user(self, telegram_id: str) -> str | None:
|
| 56 |
+
with self._connect() as conn:
|
| 57 |
+
row = conn.execute(
|
| 58 |
+
"""
|
| 59 |
+
SELECT m.input_type
|
| 60 |
+
FROM messages m
|
| 61 |
+
JOIN users u ON u.id = m.user_id
|
| 62 |
+
WHERE u.telegram_id = ?
|
| 63 |
+
ORDER BY m.id DESC
|
| 64 |
+
LIMIT 1
|
| 65 |
+
""",
|
| 66 |
+
(telegram_id,),
|
| 67 |
+
).fetchone()
|
| 68 |
+
return str(row["input_type"]) if row else None
|
| 69 |
+
|
| 70 |
+
def get_recent_context(
|
| 71 |
+
self, user_id: int, limit: int = 20, max_age_minutes: int = 5,
|
| 72 |
+
) -> list[dict[str, str]]:
|
| 73 |
+
"""Return recent messages as LLM-ready [{role, content}, ...] list.
|
| 74 |
+
|
| 75 |
+
Only includes messages from the last *max_age_minutes* to avoid
|
| 76 |
+
injecting stale context from earlier sessions.
|
| 77 |
+
"""
|
| 78 |
+
with self._connect() as conn:
|
| 79 |
+
rows = conn.execute(
|
| 80 |
+
"""
|
| 81 |
+
SELECT transcript_text, response_text
|
| 82 |
+
FROM messages
|
| 83 |
+
WHERE user_id = ?
|
| 84 |
+
AND created_at >= datetime('now', ?)
|
| 85 |
+
ORDER BY id DESC
|
| 86 |
+
LIMIT ?
|
| 87 |
+
""",
|
| 88 |
+
(user_id, f"-{max_age_minutes} minutes", limit),
|
| 89 |
+
).fetchall()
|
| 90 |
+
messages: list[dict[str, str]] = []
|
| 91 |
+
for row in reversed(rows):
|
| 92 |
+
messages.append({"role": "user", "content": row["transcript_text"]})
|
| 93 |
+
messages.append({"role": "assistant", "content": row["response_text"]})
|
| 94 |
+
return messages
|
| 95 |
+
|
| 96 |
+
def log_tool(self, tool_name: str, args: dict[str, Any], result: dict[str, Any]) -> None:
|
| 97 |
+
with self._connect() as conn:
|
| 98 |
+
conn.execute(
|
| 99 |
+
"""
|
| 100 |
+
INSERT INTO tool_audit(tool_name, args_json, result_json)
|
| 101 |
+
VALUES(?, ?, ?)
|
| 102 |
+
""",
|
| 103 |
+
(tool_name, json.dumps(args), json.dumps(result)),
|
| 104 |
+
)
|
| 105 |
+
conn.commit()
|
| 106 |
+
|
| 107 |
+
def upsert_pending_action(
|
| 108 |
+
self, telegram_id: str, action_type: str, payload: dict[str, Any]
|
| 109 |
+
) -> None:
|
| 110 |
+
payload_json = json.dumps(payload)
|
| 111 |
+
with self._connect() as conn:
|
| 112 |
+
conn.execute(
|
| 113 |
+
"""
|
| 114 |
+
INSERT INTO pending_actions(telegram_id, action_type, payload_json)
|
| 115 |
+
VALUES(?, ?, ?)
|
| 116 |
+
ON CONFLICT(telegram_id) DO UPDATE SET
|
| 117 |
+
action_type = excluded.action_type,
|
| 118 |
+
payload_json = excluded.payload_json
|
| 119 |
+
""",
|
| 120 |
+
(telegram_id, action_type, payload_json),
|
| 121 |
+
)
|
| 122 |
+
conn.commit()
|
| 123 |
+
|
| 124 |
+
def pop_pending_action(self, telegram_id: str) -> dict[str, Any] | None:
|
| 125 |
+
with self._connect() as conn:
|
| 126 |
+
row = conn.execute(
|
| 127 |
+
"SELECT action_type, payload_json FROM pending_actions WHERE telegram_id = ?",
|
| 128 |
+
(telegram_id,),
|
| 129 |
+
).fetchone()
|
| 130 |
+
if not row:
|
| 131 |
+
return None
|
| 132 |
+
conn.execute("DELETE FROM pending_actions WHERE telegram_id = ?", (telegram_id,))
|
| 133 |
+
conn.commit()
|
| 134 |
+
return {
|
| 135 |
+
"action_type": row["action_type"],
|
| 136 |
+
"payload": json.loads(row["payload_json"]),
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def create_payment_link(self, link_id: str, amount: float, customer_name: str) -> dict[str, Any]:
|
| 140 |
+
with self._connect() as conn:
|
| 141 |
+
conn.execute(
|
| 142 |
+
"INSERT INTO payment_links(link_id, amount, customer_name) VALUES(?, ?, ?)",
|
| 143 |
+
(link_id, amount, customer_name),
|
| 144 |
+
)
|
| 145 |
+
conn.commit()
|
| 146 |
+
return {"link_id": link_id, "amount": amount, "customer_name": customer_name, "status": "ACTIVE"}
|
| 147 |
+
|
| 148 |
+
def get_payment_link(self, link_id: str) -> dict[str, Any] | None:
|
| 149 |
+
with self._connect() as conn:
|
| 150 |
+
row = conn.execute(
|
| 151 |
+
"SELECT link_id, amount, customer_name, status FROM payment_links WHERE link_id = ?",
|
| 152 |
+
(link_id,),
|
| 153 |
+
).fetchone()
|
| 154 |
+
if not row:
|
| 155 |
+
return None
|
| 156 |
+
return dict(row)
|
| 157 |
+
|
| 158 |
+
def list_orders(self, limit: int = 10) -> list[dict[str, Any]]:
|
| 159 |
+
with self._connect() as conn:
|
| 160 |
+
rows = conn.execute(
|
| 161 |
+
"SELECT order_id, amount, status, created_at FROM orders ORDER BY id DESC LIMIT ?",
|
| 162 |
+
(limit,),
|
| 163 |
+
).fetchall()
|
| 164 |
+
return [dict(row) for row in rows]
|
| 165 |
+
|
| 166 |
+
def create_refund(self, refund_id: str, txn_id: str, amount: float, status: str) -> dict[str, Any]:
|
| 167 |
+
with self._connect() as conn:
|
| 168 |
+
conn.execute(
|
| 169 |
+
"INSERT INTO refunds(refund_id, txn_id, amount, status) VALUES(?, ?, ?, ?)",
|
| 170 |
+
(refund_id, txn_id, amount, status),
|
| 171 |
+
)
|
| 172 |
+
conn.commit()
|
| 173 |
+
return {"refund_id": refund_id, "txn_id": txn_id, "amount": amount, "status": status}
|
| 174 |
+
|
| 175 |
+
def get_refund(self, refund_id: str) -> dict[str, Any] | None:
|
| 176 |
+
with self._connect() as conn:
|
| 177 |
+
row = conn.execute(
|
| 178 |
+
"SELECT refund_id, txn_id, amount, status FROM refunds WHERE refund_id = ?",
|
| 179 |
+
(refund_id,),
|
| 180 |
+
).fetchone()
|
| 181 |
+
return dict(row) if row else None
|
| 182 |
+
|
| 183 |
+
def list_refunds(self, limit: int = 10) -> list[dict[str, Any]]:
|
| 184 |
+
with self._connect() as conn:
|
| 185 |
+
rows = conn.execute(
|
| 186 |
+
"SELECT refund_id, txn_id, amount, status, created_at FROM refunds ORDER BY id DESC LIMIT ?",
|
| 187 |
+
(limit,),
|
| 188 |
+
).fetchall()
|
| 189 |
+
return [dict(row) for row in rows]
|
| 190 |
+
|
| 191 |
+
def get_settlement_summary(self) -> dict[str, Any]:
|
| 192 |
+
with self._connect() as conn:
|
| 193 |
+
row = conn.execute(
|
| 194 |
+
"SELECT COALESCE(SUM(gross), 0) AS gross, COALESCE(SUM(fee), 0) AS fee, COALESCE(SUM(net), 0) AS net FROM settlements"
|
| 195 |
+
).fetchone()
|
| 196 |
+
return dict(row)
|
| 197 |
+
|
| 198 |
+
def list_settlement_details(self, limit: int = 20) -> list[dict[str, Any]]:
|
| 199 |
+
with self._connect() as conn:
|
| 200 |
+
rows = conn.execute(
|
| 201 |
+
"SELECT payout_id, payout_date, gross, fee, net FROM settlements ORDER BY payout_date DESC LIMIT ?",
|
| 202 |
+
(limit,),
|
| 203 |
+
).fetchall()
|
| 204 |
+
return [dict(row) for row in rows]
|
| 205 |
+
|
| 206 |
+
def clear_mock_data(self) -> None:
|
| 207 |
+
with self._connect() as conn:
|
| 208 |
+
conn.execute("DELETE FROM tool_audit")
|
| 209 |
+
conn.execute("DELETE FROM pending_actions")
|
| 210 |
+
conn.execute("DELETE FROM messages")
|
| 211 |
+
conn.execute("DELETE FROM payment_links")
|
| 212 |
+
conn.execute("DELETE FROM refunds")
|
| 213 |
+
conn.execute("DELETE FROM payments")
|
| 214 |
+
conn.execute("DELETE FROM settlements")
|
| 215 |
+
conn.execute("DELETE FROM orders")
|
| 216 |
+
conn.commit()
|
| 217 |
+
|
| 218 |
+
def seed_demo_data(self, force: bool = False) -> None:
|
| 219 |
+
with self._connect() as conn:
|
| 220 |
+
has_orders = conn.execute("SELECT COUNT(*) AS c FROM orders").fetchone()["c"]
|
| 221 |
+
if has_orders and not force:
|
| 222 |
+
return
|
| 223 |
+
if force:
|
| 224 |
+
conn.execute("DELETE FROM payment_links")
|
| 225 |
+
conn.execute("DELETE FROM refunds")
|
| 226 |
+
conn.execute("DELETE FROM payments")
|
| 227 |
+
conn.execute("DELETE FROM settlements")
|
| 228 |
+
conn.execute("DELETE FROM orders")
|
| 229 |
+
|
| 230 |
+
# Scenario A: Healthy day with mostly successful UPI/card payments.
|
| 231 |
+
conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1001', 799.0, 'SUCCESS')")
|
| 232 |
+
conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1002', 1299.0, 'SUCCESS')")
|
| 233 |
+
conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2001', 'ORD-1001', 'UPI', 'SUCCESS', 'MOCK_GATEWAY_A', 799.0)")
|
| 234 |
+
conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2002', 'ORD-1002', 'CC', 'SUCCESS', 'MOCK_GATEWAY_A', 1299.0)")
|
| 235 |
+
|
| 236 |
+
# Scenario B: UPI dip and checkout failures.
|
| 237 |
+
conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1003', 499.0, 'FAILED')")
|
| 238 |
+
conn.execute("INSERT INTO orders(order_id, amount, status) VALUES ('ORD-1004', 650.0, 'FAILED')")
|
| 239 |
+
conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2003', 'ORD-1003', 'UPI', 'FAILED', 'MOCK_GATEWAY_B', 499.0)")
|
| 240 |
+
conn.execute("INSERT INTO payments(txn_id, order_id, mode, status, gateway, amount) VALUES ('TXN-2004', 'ORD-1004', 'UPI', 'FAILED', 'MOCK_GATEWAY_B', 650.0)")
|
| 241 |
+
|
| 242 |
+
# Scenario C: Refund queue with mixed statuses.
|
| 243 |
+
conn.execute("INSERT INTO refunds(refund_id, txn_id, amount, status) VALUES ('RFND-3001', 'TXN-2001', 99.0, 'SUCCESS')")
|
| 244 |
+
conn.execute("INSERT INTO refunds(refund_id, txn_id, amount, status) VALUES ('RFND-3002', 'TXN-2004', 300.0, 'PENDING')")
|
| 245 |
+
|
| 246 |
+
# Scenario D: Settlement delays and payout variances.
|
| 247 |
+
conn.execute("INSERT INTO settlements(payout_id, payout_date, gross, fee, net) VALUES ('PAYOUT-4001', '2026-03-20', 2098.0, 42.0, 2056.0)")
|
| 248 |
+
conn.execute("INSERT INTO settlements(payout_id, payout_date, gross, fee, net) VALUES ('PAYOUT-4002', '2026-03-19', 1450.0, 31.0, 1419.0)")
|
| 249 |
+
conn.execute("INSERT INTO settlements(payout_id, payout_date, gross, fee, net) VALUES ('PAYOUT-4003', '2026-03-18', 980.0, 22.0, 958.0)")
|
| 250 |
+
|
| 251 |
+
# Scenario E: Existing payment links for follow-up checks.
|
| 252 |
+
conn.execute("INSERT INTO payment_links(link_id, amount, customer_name, status) VALUES ('LINK-5001', 550.0, 'Aman Traders', 'ACTIVE')")
|
| 253 |
+
conn.execute("INSERT INTO payment_links(link_id, amount, customer_name, status) VALUES ('LINK-5002', 1200.0, 'Neha Retail', 'EXPIRED')")
|
| 254 |
+
conn.commit()
|
app/llm/__pycache__/client.cpython-311.pyc
ADDED
|
Binary file (5.25 kB). View file
|
|
|
app/llm/client.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class LlmClient:
|
| 7 |
+
def __init__(self, inference_url: str, api_key: str, model: str) -> None:
|
| 8 |
+
self.inference_url = inference_url
|
| 9 |
+
self.api_key = api_key
|
| 10 |
+
self.model = model
|
| 11 |
+
|
| 12 |
+
def generate(self, prompt: str, history: list[dict[str, str]] | None = None) -> str:
|
| 13 |
+
if not self.inference_url:
|
| 14 |
+
return "I can help with orders, links, refunds, and settlements. Tell me what you want to check."
|
| 15 |
+
try:
|
| 16 |
+
payload = {
|
| 17 |
+
"model": self.model,
|
| 18 |
+
"messages": [
|
| 19 |
+
{"role": "system", "content": (
|
| 20 |
+
"You are Via, a merchant's payment copilot on Telegram.\n\n"
|
| 21 |
+
"RULES:\n"
|
| 22 |
+
"- Be brief. 2-3 lines by default. Only expand if the user asks "
|
| 23 |
+
"\"tell me more\", \"details\", or \"explain\".\n"
|
| 24 |
+
"- Use Telegram HTML formatting ONLY (never markdown):\n"
|
| 25 |
+
" <b>bold</b> for key numbers and labels,\n"
|
| 26 |
+
" <i>italic</i> for emphasis,\n"
|
| 27 |
+
" <code>TXN-2001</code> for IDs.\n"
|
| 28 |
+
"- Lead with the answer. No preamble like \"Here are my insights:\" "
|
| 29 |
+
"or \"Based on the data:\".\n"
|
| 30 |
+
"- Sound human — like a sharp assistant texting, not an AI writing "
|
| 31 |
+
"a report. No bullet-point essays.\n"
|
| 32 |
+
"- Use ₹ for currency (Indian merchants).\n"
|
| 33 |
+
"- If data shows a problem, state it plainly and offer ONE actionable "
|
| 34 |
+
"next step — not a 10-point plan.\n"
|
| 35 |
+
"- Use line breaks to separate thoughts. Never use markdown "
|
| 36 |
+
"(**, ##, -). Only HTML tags.\n"
|
| 37 |
+
"- Emojis sparingly: ✅ ❌ ⚠️ 📊 for status indicators only.\n"
|
| 38 |
+
"- Never end with \"Let me know if you'd like me to elaborate\" or "
|
| 39 |
+
"similar filler.\n"
|
| 40 |
+
"- NEVER make up data, transaction IDs, or order details.\n"
|
| 41 |
+
"- If you do not have the requested information in your chat history, "
|
| 42 |
+
"explicitly state that you don't know or ask the user to provide the exact ID."
|
| 43 |
+
)},
|
| 44 |
+
*(history or []),
|
| 45 |
+
{"role": "user", "content": prompt},
|
| 46 |
+
],
|
| 47 |
+
"temperature": 0.0,
|
| 48 |
+
}
|
| 49 |
+
headers: dict[str, Any] = {"Content-Type": "application/json"}
|
| 50 |
+
if self.api_key:
|
| 51 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 52 |
+
response = requests.post(
|
| 53 |
+
self._normalize_chat_url(self.inference_url),
|
| 54 |
+
json=payload,
|
| 55 |
+
headers=headers,
|
| 56 |
+
timeout=20,
|
| 57 |
+
)
|
| 58 |
+
response.raise_for_status()
|
| 59 |
+
data = response.json()
|
| 60 |
+
except requests.HTTPError as exc:
|
| 61 |
+
status = exc.response.status_code if exc.response is not None else "unknown"
|
| 62 |
+
return (
|
| 63 |
+
f"LLM provider returned HTTP {status}. "
|
| 64 |
+
"Please check API key permissions/quota. I can still run mocked tools "
|
| 65 |
+
"for orders, links, refunds, and settlements."
|
| 66 |
+
)
|
| 67 |
+
except requests.RequestException:
|
| 68 |
+
return (
|
| 69 |
+
"LLM provider is currently unreachable. "
|
| 70 |
+
"I can still run mocked tools for orders, links, refunds, and settlements."
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
if isinstance(data, dict) and "success" in data:
|
| 74 |
+
if not data.get("success"):
|
| 75 |
+
return (
|
| 76 |
+
f"LLM provider error: {data.get('error', 'unknown_error')}. "
|
| 77 |
+
"I can still run mocked tools for orders, links, refunds, and settlements."
|
| 78 |
+
)
|
| 79 |
+
try:
|
| 80 |
+
return data["data"]["choices"][0]["message"]["content"]
|
| 81 |
+
except (KeyError, IndexError, TypeError):
|
| 82 |
+
return str(data.get("data", data))
|
| 83 |
+
try:
|
| 84 |
+
return data["choices"][0]["message"]["content"]
|
| 85 |
+
except (KeyError, IndexError, TypeError):
|
| 86 |
+
return str(data)
|
| 87 |
+
|
| 88 |
+
@staticmethod
|
| 89 |
+
def _normalize_chat_url(url: str) -> str:
|
| 90 |
+
normalized = (url or "").rstrip("/")
|
| 91 |
+
if normalized.endswith("/chat/completions"):
|
| 92 |
+
return normalized
|
| 93 |
+
if normalized.endswith("/openai/v1"):
|
| 94 |
+
return f"{normalized}/chat/completions"
|
| 95 |
+
return normalized
|
app/main.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Header, HTTPException
|
| 2 |
+
|
| 3 |
+
from app.agent.orchestrator import Orchestrator
|
| 4 |
+
from app.config import load_settings
|
| 5 |
+
from app.db.repo import Repository
|
| 6 |
+
from app.llm.client import LlmClient
|
| 7 |
+
from app.telegram.handler import TelegramHandler
|
| 8 |
+
from app.tools.mock_paytm import MockPaytmTools
|
| 9 |
+
from app.voice.stt import SttClient
|
| 10 |
+
|
| 11 |
+
settings = load_settings()
|
| 12 |
+
repo = Repository(settings.db_path)
|
| 13 |
+
repo.seed_demo_data()
|
| 14 |
+
tools = MockPaytmTools(repo)
|
| 15 |
+
llm_client = LlmClient(
|
| 16 |
+
inference_url=settings.llm_inference_url,
|
| 17 |
+
api_key=settings.llm_inference_api_key,
|
| 18 |
+
model=settings.llm_model,
|
| 19 |
+
)
|
| 20 |
+
stt_client = SttClient(
|
| 21 |
+
settings.stt_inference_url,
|
| 22 |
+
settings.stt_api_key,
|
| 23 |
+
model=settings.stt_model,
|
| 24 |
+
)
|
| 25 |
+
orchestrator = Orchestrator(repo, tools, llm_client)
|
| 26 |
+
telegram_handler = TelegramHandler(orchestrator, stt_client, settings.telegram_bot_token)
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title="Via Copilot API", version="0.1.0")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/")
|
| 32 |
+
def read_root() -> dict[str, str]:
|
| 33 |
+
return {"message": "Via Copilot webhook server is running!"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@app.get("/health")
|
| 37 |
+
def health() -> dict[str, str]:
|
| 38 |
+
return {"status": "ok", "env": settings.app_env}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@app.post("/telegram/webhook")
|
| 42 |
+
def telegram_webhook(
|
| 43 |
+
update: dict,
|
| 44 |
+
x_telegram_bot_api_secret_token: str | None = Header(default=None),
|
| 45 |
+
) -> dict:
|
| 46 |
+
expected_secret = settings.telegram_webhook_secret
|
| 47 |
+
if expected_secret and x_telegram_bot_api_secret_token != expected_secret:
|
| 48 |
+
raise HTTPException(status_code=401, detail="invalid webhook secret")
|
| 49 |
+
return telegram_handler.process_update(update)
|
app/telegram/__pycache__/handler.cpython-311.pyc
ADDED
|
Binary file (6.02 kB). View file
|
|
|
app/telegram/handler.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
|
| 5 |
+
from app.agent.orchestrator import Orchestrator
|
| 6 |
+
from app.voice.stt import SttClient, SttProviderError
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TelegramHandler:
|
| 10 |
+
def __init__(
|
| 11 |
+
self,
|
| 12 |
+
orchestrator: Orchestrator,
|
| 13 |
+
stt_client: SttClient,
|
| 14 |
+
bot_token: str,
|
| 15 |
+
) -> None:
|
| 16 |
+
self.orchestrator = orchestrator
|
| 17 |
+
self.stt_client = stt_client
|
| 18 |
+
self.bot_token = bot_token
|
| 19 |
+
|
| 20 |
+
def process_update(self, update: dict[str, Any]) -> dict[str, Any]:
|
| 21 |
+
message = update.get("message") or {}
|
| 22 |
+
chat = message.get("chat") or {}
|
| 23 |
+
chat_id = chat.get("id")
|
| 24 |
+
if chat_id is None:
|
| 25 |
+
return {"ok": True, "skipped": "no_chat"}
|
| 26 |
+
telegram_id = str(chat_id)
|
| 27 |
+
|
| 28 |
+
if "text" in message:
|
| 29 |
+
text = str(message.get("text", ""))
|
| 30 |
+
if text.strip().lower() == "/debug last_update_type":
|
| 31 |
+
last_type = self.orchestrator.repo.get_last_input_type_for_telegram_user(
|
| 32 |
+
telegram_id
|
| 33 |
+
)
|
| 34 |
+
current_type = "text"
|
| 35 |
+
debug_msg = (
|
| 36 |
+
f"debug current_update_type={current_type}, "
|
| 37 |
+
f"last_processed_input_type={last_type or 'none'}"
|
| 38 |
+
)
|
| 39 |
+
self._send_message(chat_id, debug_msg)
|
| 40 |
+
return {
|
| 41 |
+
"ok": True,
|
| 42 |
+
"input_type": "text",
|
| 43 |
+
"reply": debug_msg,
|
| 44 |
+
"debug": True,
|
| 45 |
+
}
|
| 46 |
+
reply = self.orchestrator.handle_user_text(telegram_id, text, input_type="text")
|
| 47 |
+
self._send_message(chat_id, reply)
|
| 48 |
+
return {"ok": True, "input_type": "text", "reply": reply}
|
| 49 |
+
|
| 50 |
+
if "voice" in message:
|
| 51 |
+
transcript = self._transcribe_media(message["voice"], "voice.ogg")
|
| 52 |
+
reply = self.orchestrator.handle_user_text(
|
| 53 |
+
telegram_id, transcript, input_type="voice"
|
| 54 |
+
)
|
| 55 |
+
self._send_message(chat_id, f"Transcribed: {transcript}\n\n{reply}")
|
| 56 |
+
return {"ok": True, "input_type": "voice", "reply": reply, "transcript": transcript}
|
| 57 |
+
|
| 58 |
+
if "audio" in message:
|
| 59 |
+
transcript = self._transcribe_media(message["audio"], "audio.m4a")
|
| 60 |
+
reply = self.orchestrator.handle_user_text(
|
| 61 |
+
telegram_id, transcript, input_type="voice"
|
| 62 |
+
)
|
| 63 |
+
self._send_message(chat_id, f"Transcribed: {transcript}\n\n{reply}")
|
| 64 |
+
return {"ok": True, "input_type": "voice", "reply": reply, "transcript": transcript}
|
| 65 |
+
|
| 66 |
+
return {"ok": True, "skipped": "unsupported_message_type"}
|
| 67 |
+
|
| 68 |
+
def _transcribe_media(self, media_obj: dict[str, Any], default_filename: str) -> str:
|
| 69 |
+
file_id = media_obj.get("file_id")
|
| 70 |
+
if not file_id or not self.bot_token:
|
| 71 |
+
return "voice transcription unavailable in local mode"
|
| 72 |
+
try:
|
| 73 |
+
file_resp = requests.get(
|
| 74 |
+
f"https://api.telegram.org/bot{self.bot_token}/getFile",
|
| 75 |
+
params={"file_id": file_id},
|
| 76 |
+
timeout=20,
|
| 77 |
+
)
|
| 78 |
+
file_resp.raise_for_status()
|
| 79 |
+
file_path = file_resp.json().get("result", {}).get("file_path")
|
| 80 |
+
if not file_path:
|
| 81 |
+
return "unable to fetch media file path from Telegram"
|
| 82 |
+
|
| 83 |
+
dl_resp = requests.get(
|
| 84 |
+
f"https://api.telegram.org/file/bot{self.bot_token}/{file_path}", timeout=30
|
| 85 |
+
)
|
| 86 |
+
dl_resp.raise_for_status()
|
| 87 |
+
filename = file_path.split("/")[-1] if "/" in file_path else default_filename
|
| 88 |
+
if "." not in filename:
|
| 89 |
+
filename = default_filename
|
| 90 |
+
transcript = self.stt_client.transcribe_bytes(dl_resp.content, filename=filename)
|
| 91 |
+
return transcript or "empty transcription"
|
| 92 |
+
except SttProviderError as exc:
|
| 93 |
+
return (
|
| 94 |
+
"voice transcription failed. "
|
| 95 |
+
f"provider_detail={exc}. "
|
| 96 |
+
"Tip: Telegram voice notes are usually OGG; if this keeps failing, send an audio file in m4a/mp3."
|
| 97 |
+
)
|
| 98 |
+
except requests.RequestException:
|
| 99 |
+
return "voice transcription failed due to Telegram network issue"
|
| 100 |
+
|
| 101 |
+
def _send_message(self, chat_id: int, text: str) -> None:
|
| 102 |
+
if not self.bot_token:
|
| 103 |
+
return
|
| 104 |
+
try:
|
| 105 |
+
requests.post(
|
| 106 |
+
f"https://api.telegram.org/bot{self.bot_token}/sendMessage",
|
| 107 |
+
json={"chat_id": chat_id, "text": text, "parse_mode": "HTML"},
|
| 108 |
+
timeout=20,
|
| 109 |
+
)
|
| 110 |
+
except requests.RequestException:
|
| 111 |
+
return
|
app/tools/__pycache__/mock_paytm.cpython-311.pyc
ADDED
|
Binary file (5.05 kB). View file
|
|
|
app/tools/mock_paytm.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from app.db.repo import Repository
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class MockPaytmTools:
|
| 8 |
+
def __init__(self, repo: Repository) -> None:
|
| 9 |
+
self.repo = repo
|
| 10 |
+
|
| 11 |
+
def create_link(self, amount: float, customer_name: str = "merchant_customer") -> dict[str, Any]:
|
| 12 |
+
link_id = f"LINK-{int(time.time())}"
|
| 13 |
+
result = self.repo.create_payment_link(link_id=link_id, amount=amount, customer_name=customer_name)
|
| 14 |
+
self.repo.log_tool("create_link", {"amount": amount, "customer_name": customer_name}, result)
|
| 15 |
+
return result
|
| 16 |
+
|
| 17 |
+
def fetch_link(self, link_id: str) -> dict[str, Any]:
|
| 18 |
+
result = self.repo.get_payment_link(link_id)
|
| 19 |
+
payload = result or {"error": "link_not_found", "link_id": link_id}
|
| 20 |
+
self.repo.log_tool("fetch_link", {"link_id": link_id}, payload)
|
| 21 |
+
return payload
|
| 22 |
+
|
| 23 |
+
def fetch_order_list(self, limit: int = 10) -> dict[str, Any]:
|
| 24 |
+
rows = self.repo.list_orders(limit=limit)
|
| 25 |
+
result = {"orders": rows, "count": len(rows)}
|
| 26 |
+
self.repo.log_tool("fetch_order_list", {"limit": limit}, result)
|
| 27 |
+
return result
|
| 28 |
+
|
| 29 |
+
def initiate_refund(self, txn_id: str, amount: float) -> dict[str, Any]:
|
| 30 |
+
refund_id = f"RFND-{int(time.time())}"
|
| 31 |
+
result = self.repo.create_refund(refund_id=refund_id, txn_id=txn_id, amount=amount, status="PENDING")
|
| 32 |
+
self.repo.log_tool("initiate_refund", {"txn_id": txn_id, "amount": amount}, result)
|
| 33 |
+
return result
|
| 34 |
+
|
| 35 |
+
def check_refund_status(self, refund_id: str) -> dict[str, Any]:
|
| 36 |
+
result = self.repo.get_refund(refund_id)
|
| 37 |
+
payload = result or {"error": "refund_not_found", "refund_id": refund_id}
|
| 38 |
+
self.repo.log_tool("check_refund_status", {"refund_id": refund_id}, payload)
|
| 39 |
+
return payload
|
| 40 |
+
|
| 41 |
+
def fetch_refund_list(self, limit: int = 10) -> dict[str, Any]:
|
| 42 |
+
rows = self.repo.list_refunds(limit=limit)
|
| 43 |
+
result = {"refunds": rows, "count": len(rows)}
|
| 44 |
+
self.repo.log_tool("fetch_refund_list", {"limit": limit}, result)
|
| 45 |
+
return result
|
| 46 |
+
|
| 47 |
+
def get_settlement_summary(self) -> dict[str, Any]:
|
| 48 |
+
summary = self.repo.get_settlement_summary()
|
| 49 |
+
result = {"summary": summary}
|
| 50 |
+
self.repo.log_tool("get_settlement_summary", {}, result)
|
| 51 |
+
return result
|
| 52 |
+
|
| 53 |
+
def get_settlement_detail(self, limit: int = 20) -> dict[str, Any]:
|
| 54 |
+
rows = self.repo.list_settlement_details(limit=limit)
|
| 55 |
+
result = {"settlements": rows, "count": len(rows)}
|
| 56 |
+
self.repo.log_tool("get_settlement_detail", {"limit": limit}, result)
|
| 57 |
+
return result
|
app/voice/__pycache__/stt.cpython-311.pyc
ADDED
|
Binary file (4.84 kB). View file
|
|
|
app/voice/stt.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SttProviderError(Exception):
|
| 7 |
+
pass
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SttClient:
|
| 11 |
+
def __init__(self, inference_url: str, api_key: str, model: str = "whisper-large-v3") -> None:
|
| 12 |
+
self.inference_url = inference_url
|
| 13 |
+
self.api_key = api_key
|
| 14 |
+
self.model = model
|
| 15 |
+
|
| 16 |
+
def transcribe_bytes(self, audio_bytes: bytes, filename: str = "voice.ogg") -> str:
|
| 17 |
+
if not self.inference_url:
|
| 18 |
+
return "voice transcription unavailable in local mode"
|
| 19 |
+
headers: dict[str, Any] = {}
|
| 20 |
+
if self.api_key:
|
| 21 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 22 |
+
data = {
|
| 23 |
+
"model": self.model,
|
| 24 |
+
"response_format": "verbose_json",
|
| 25 |
+
"temperature": "0",
|
| 26 |
+
}
|
| 27 |
+
safe_filename = self._ensure_supported_extension(filename)
|
| 28 |
+
mime_candidates = [None] + self._mime_candidates(safe_filename)
|
| 29 |
+
last_error: str | None = None
|
| 30 |
+
for mime in mime_candidates:
|
| 31 |
+
try:
|
| 32 |
+
if mime is None:
|
| 33 |
+
files = {"file": (safe_filename, audio_bytes)}
|
| 34 |
+
else:
|
| 35 |
+
files = {"file": (safe_filename, audio_bytes, mime)}
|
| 36 |
+
response = requests.post(
|
| 37 |
+
self.inference_url, headers=headers, files=files, data=data, timeout=40
|
| 38 |
+
)
|
| 39 |
+
response.raise_for_status()
|
| 40 |
+
payload = response.json()
|
| 41 |
+
text = str(payload.get("text", "")).strip()
|
| 42 |
+
if text:
|
| 43 |
+
return text
|
| 44 |
+
return "empty transcription"
|
| 45 |
+
except requests.HTTPError as exc:
|
| 46 |
+
status = exc.response.status_code if exc.response is not None else "unknown"
|
| 47 |
+
body = ""
|
| 48 |
+
if exc.response is not None:
|
| 49 |
+
body = exc.response.text[:300]
|
| 50 |
+
last_error = f"stt_http_{status}: {body}"
|
| 51 |
+
except requests.RequestException as exc:
|
| 52 |
+
last_error = f"stt_network_error: {exc}"
|
| 53 |
+
raise SttProviderError(last_error or "stt_unknown_error")
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def _mime_candidates(filename: str) -> list[str]:
|
| 57 |
+
lower = (filename or "").lower()
|
| 58 |
+
if lower.endswith(".ogg") or lower.endswith(".oga"):
|
| 59 |
+
# Telegram voice notes are typically OGG/OPUS.
|
| 60 |
+
return ["audio/ogg", "audio/webm", "application/octet-stream"]
|
| 61 |
+
if lower.endswith(".opus"):
|
| 62 |
+
return ["audio/ogg", "application/octet-stream"]
|
| 63 |
+
if lower.endswith(".mp3"):
|
| 64 |
+
return ["audio/mpeg", "application/octet-stream"]
|
| 65 |
+
if lower.endswith(".m4a"):
|
| 66 |
+
return ["audio/mp4", "application/octet-stream"]
|
| 67 |
+
if lower.endswith(".wav"):
|
| 68 |
+
return ["audio/wav", "application/octet-stream"]
|
| 69 |
+
return ["application/octet-stream"]
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _ensure_supported_extension(filename: str) -> str:
|
| 73 |
+
lower = (filename or "").lower()
|
| 74 |
+
supported = (".flac", ".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".ogg", ".opus", ".wav", ".webm")
|
| 75 |
+
if any(lower.endswith(ext) for ext in supported):
|
| 76 |
+
return filename
|
| 77 |
+
return "voice.ogg"
|
context.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Via: Paytm Merchant Copilot - Project Context
|
| 2 |
+
|
| 3 |
+
## 1. Problem Statement
|
| 4 |
+
SMB merchants on the Paytm platform struggle with payment failures, delayed settlements, manual reconciliation, and invoicing/collections. Existing dashboards are passive, overwhelming, and require proactive monitoring. When things go wrong (e.g., a drop in UPI success rate or a pending settlement), merchants often lack the time or expertise to analyze the data and take immediate action.
|
| 5 |
+
|
| 6 |
+
## 2. The Solution: 'Via'
|
| 7 |
+
**Via** is an Actionable Intelligence Agent (an MCP Client) accessed directly through Telegram. It serves as a conversational, voice-first control layer over merchants' Paytm infrastructure.
|
| 8 |
+
Instead of just being a Q&A chatbot, Via follows a core philosophy of:
|
| 9 |
+
**Insight → Reason → Action → Execution**
|
| 10 |
+
|
| 11 |
+
For example, when asked "Why was yesterday's collection low?", Via doesn't just return a number. It gives the insight (collection dropped by 18%), the reason (UPI success rate dropped), and immediately offers an actionable execution (e.g., "Would you like me to send payment links for the failed orders?").
|
| 12 |
+
|
| 13 |
+
## 3. UX & Interface
|
| 14 |
+
- **Platform:** Telegram Bot
|
| 15 |
+
- **Input Types:** Text and Voice (`.ogg` files)
|
| 16 |
+
- **Voice UX:** SMB merchants (often speaking Hindi or Hinglish) can simply send voice notes like *"refund last payment"*. The bot uses Telegram's voice capabilities smoothly to deliver a natural, low-friction experience.
|
| 17 |
+
|
| 18 |
+
## 4. Multi-Model AI Architecture
|
| 19 |
+
To balance cost, speed, and intelligence, Via utilizes a layered model stack:
|
| 20 |
+
1. **Input Layer (Speech-to-Text):** `whisper-large-v3-turbo`
|
| 21 |
+
- Transcribes Telegram `.ogg` voice notes, excellent for Hinglish natively.
|
| 22 |
+
2. **Intent & Routing (Fast & Cheap):** `llama-3.1-8b-instant`
|
| 23 |
+
- Rapidly classifies the merchant's intent, extracts entities, and routes queries.
|
| 24 |
+
3. **Reasoning & Execution (The "Brain"):** `gpt-oss-20b` or **Gemini**
|
| 25 |
+
- Handles the complex financial logic, analyzes data patterns, generates insights, and formats the final responses.
|
| 26 |
+
4. **Safety Verification (Optional/Critical Tasks):** `gpt-oss-safeguard-20b`
|
| 27 |
+
- Ensures sensitive operations like "initiate refund" are double-checked for authorization and intent.
|
| 28 |
+
|
| 29 |
+
## 5. Backend Architecture & Tool Integration
|
| 30 |
+
- **Stack:** Python, FastAPI, python-telegram-bot.
|
| 31 |
+
- **MCP Mock Tools (Simulating Paytm APIs):**
|
| 32 |
+
- **Orders:** `fetch_order_list`
|
| 33 |
+
- **Payment Links:** `create_link`, `fetch_link`, `fetch_transaction`
|
| 34 |
+
- **Refunds:** `initiate_refund`, `check_refund_status`, `fetch_refund_list`
|
| 35 |
+
- **Settlements:** `get_settlement_summary`, `get_settlement_detail`
|
| 36 |
+
|
| 37 |
+
*Note: The backend avoids heavy frameworks like React in favor of a clean, instantly demo-able chat interface that highlights the actual value: the MCP tool-calling and the multi-model intelligence.*
|
| 38 |
+
|
| 39 |
+
## 6. Conversational Memory & Context Awareness
|
| 40 |
+
To ensure Via is not a stateless bot, it maintains a **rolling conversational memory** per user (using Telegram's `chat_id`):
|
| 41 |
+
- **Short-Term Memory (Session State):** A fast, in-memory buffer (e.g., Python `dict` or Redis) stores the last 20 interactions or can store the summary till this point in chat of all the conversations. This payload is passed to the LLM's `messages` array so the AI always remembers the *tone* of the user, *recent tool results*, and *previously asked questions*.
|
| 42 |
+
- **Tool-Call Continuity:** If a user says "Refund the last one we talked about," the AI can inspect the most recent `initiate_refund` or `fetch_transaction` MCP tool execution to determine precisely which order the user meant.Just ensure that its not stale data... like time wise... can use last 5 mins
|
| 43 |
+
- **Context Injection:** When an order or settlement requires action, the backend injects system prompts framing the exact state: *"The user just saw a drop in UPI success; guide them to routing updates."*
|
| 44 |
+
|
| 45 |
+
- **Sensitive stuff** redirect to relevent section of paytm app, so that user continues from there...
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
## 7. Voice UX & Hinglish Support
|
| 49 |
+
|
| 50 |
+
|
paytm llm api.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
|
| 3 |
+
# Configuration
|
| 4 |
+
API_KEY = "<YOUR_API_KEY>"
|
| 5 |
+
BASE_URL = "https://api.paytmai.com"
|
| 6 |
+
|
| 7 |
+
# Audio file
|
| 8 |
+
audio_file = open("audio.mp3", "rb")
|
| 9 |
+
|
| 10 |
+
# Request payload
|
| 11 |
+
files = {"file": audio_file}
|
| 12 |
+
data = {
|
| 13 |
+
"model": "whisper-large-v3",
|
| 14 |
+
"response_format": "json",
|
| 15 |
+
"temperature": 0
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
headers = {"Authorization": f"Bearer {API_KEY}"}
|
| 19 |
+
response = requests.post(f"{BASE_URL}/v1/audio/transcriptions", files=files, data=data, headers=headers)
|
| 20 |
+
|
| 21 |
+
result = response.json()
|
| 22 |
+
print(result["text"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
THE ABOVE IS FOR WHISPER...
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
import requests
|
| 29 |
+
|
| 30 |
+
# Configuration
|
| 31 |
+
API_KEY = "<YOUR_API_KEY>" # Get from /v1/user/me/api-key
|
| 32 |
+
BASE_URL = "https://api.inference.paytm.com/v1"
|
| 33 |
+
|
| 34 |
+
# Request payload
|
| 35 |
+
payload = {
|
| 36 |
+
"model": "llama-3.1-8b-instant",
|
| 37 |
+
"messages": [
|
| 38 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 39 |
+
{"role": "user", "content": "Your message here"}
|
| 40 |
+
],
|
| 41 |
+
"temperature": 0.7,
|
| 42 |
+
"max_tokens": 1024,
|
| 43 |
+
"top_p": 0.95,
|
| 44 |
+
"top_k": 50,
|
| 45 |
+
"frequency_penalty": 0,
|
| 46 |
+
"presence_penalty": 0,
|
| 47 |
+
"repetition_penalty": 1,
|
| 48 |
+
"stream": True
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
# Make request
|
| 52 |
+
headers = {
|
| 53 |
+
"Authorization": f"Bearer {API_KEY}",
|
| 54 |
+
"Content-Type": "application/json"
|
| 55 |
+
}
|
| 56 |
+
response = requests.post(
|
| 57 |
+
f"{BASE_URL}/ai/playground",
|
| 58 |
+
json=payload,
|
| 59 |
+
headers=headers
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
result = response.json()
|
| 63 |
+
if result["success"]:
|
| 64 |
+
print(result["data"]["choices"][0]["message"]["content"])
|
| 65 |
+
else:
|
| 66 |
+
print(f"Error: {result['error']}")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
THE ABOVE IS FOR LLM
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
BELOW ARE CURL
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
curl -X POST https://api.inference.paytm.com/v1/ai/playground \
|
| 77 |
+
-H "Authorization: Bearer <YOUR_API_KEY>" \
|
| 78 |
+
-H "Content-Type: application/json" \
|
| 79 |
+
-d '{
|
| 80 |
+
"model": "llama-3.1-8b-instant",
|
| 81 |
+
"messages": [
|
| 82 |
+
{"role": "system", "content": "You are a helpful assistant."},
|
| 83 |
+
{"role": "user", "content": "Your message here"}
|
| 84 |
+
],
|
| 85 |
+
"temperature": 0.7,
|
| 86 |
+
"max_tokens": 1024,
|
| 87 |
+
"top_p": 0.95,
|
| 88 |
+
"top_k": 50,
|
| 89 |
+
"frequency_penalty": 0,
|
| 90 |
+
"presence_penalty": 0,
|
| 91 |
+
"repetition_penalty": 1,
|
| 92 |
+
"stream": true
|
| 93 |
+
}'
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
curl -X POST https://api.paytmai.com/v1/audio/transcriptions \
|
| 97 |
+
-H "Authorization: Bearer <YOUR_API_KEY>" \
|
| 98 |
+
-F "file=@audio.mp3" \
|
| 99 |
+
-F "model=whisper-large-v3" \
|
| 100 |
+
-F "response_format=json" \
|
| 101 |
+
-F "temperature=0"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+

|
paytm_llm_api_docs.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Paytm Payment MCP Server & AI Router API Documentation
|
| 2 |
+
|
| 3 |
+
This documentation is curated for Large Language Models (LLMs) and developers integrating Paytm's Model Context Protocol (MCP) Server and AI Router services. It covers everything from conversational AI integration to specific transactional workflows and routing configurations.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Part 1: Paytm MCP Server
|
| 8 |
+
|
| 9 |
+
Paytm MCP Server enables AI agents and developers to securely access Paytm's Payments and Business Payments APIs securely via the Model Context Protocol (MCP).
|
| 10 |
+
|
| 11 |
+
### Main Features
|
| 12 |
+
- **Smart Payment Ops**: Automate refund workflows, settlement tracking, and transaction status checks.
|
| 13 |
+
- **Context-Aware AI Assistants**: Trigger queries via natural language (e.g. \"Create a ₹500 payment link\").
|
| 14 |
+
- **Agentic AI Payments**: Provide enhanced automated and dynamic shopping experiences.
|
| 15 |
+
|
| 16 |
+
### MCP Tools Available
|
| 17 |
+
The MCP exposes a set of tools mapped to Paytm's underlying REST APIs. An LLM agent can invoke these exact tools:
|
| 18 |
+
|
| 19 |
+
| Tool Name | Description | Underlying API Context |
|
| 20 |
+
|--------------------------------------|---------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
|
| 21 |
+
| `create_link` | Creates a new payment link. | Create Link API |
|
| 22 |
+
| `fetch_link` | Fetches details of a created payment link. | Fetch Link API |
|
| 23 |
+
| `fetch_transaction` | Fetches transaction details specific to a payment link. | Fetch Transaction Link API |
|
| 24 |
+
| `fetch_order_list` | Fetches list of orders within a 30-day date range. | Order List API |
|
| 25 |
+
| `initiate_refund` | Initiates a refund for a specific transaction. | Initiate Refund API |
|
| 26 |
+
| `check_refund_status` | Checks the status of a previously initiated refund. | Check Refund Status API |
|
| 27 |
+
| `fetch_refund_list` | Fetches a list of refunds within a 30-day date range. | Fetch Refund List API |
|
| 28 |
+
| `get_settlement_summary` | Retrieves overall summary details of payouts for a date range or payout ID. | Get Settlement Summary API |
|
| 29 |
+
| `get_settlement_detail` | Retrieves a granular/transactional view of all settled transactions at the payout level. | Get Settlement Details API |
|
| 30 |
+
| `get_settlement_order_details` | Retrieves settlement details at an order level (needs payout date and order ID). | Get Settlement Order Details API |
|
| 31 |
+
| `get_settlement_transaction_details` | Retrieves settlement details of a specific order based on transaction ID. | Get Settlement Transaction Details API |
|
| 32 |
+
|
| 33 |
+
### Deployment Modes
|
| 34 |
+
1. **Remote MCP (Recommended)**: Hosted by Paytm. Requires requesting a `Client ID` and `Secret Key` from `devsupport@paytmpayments.com`. You will use your `PAYTM_MID`. Cursor setup via `generate_mcp_token_cursor.sh` to generate a JWT token.
|
| 35 |
+
2. **Local MCP (Self-Hosted)**: Run locally via `Claude Desktop`.
|
| 36 |
+
- Requires Python 3.12+, `uv`, and Claude Desktop.
|
| 37 |
+
- Env variables required: `PAYTM_MID`, `PAYTM_KEY_SECRET`.
|
| 38 |
+
- `claude_desktop_config.json` uses `uv path --directory <dir> run paytm_mcp.py`.
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Part 2: AI Router Payment Flow
|
| 43 |
+
|
| 44 |
+
The AI Router serves to centralize the checkout process and smartly route payments across configured payment aggregators (PAs)/payment gateways (PGs).
|
| 45 |
+
|
| 46 |
+
### Standard Transaction Journey
|
| 47 |
+
1. **Initiation**: Merchant initiates the transaction by calling the **Create Order API**.
|
| 48 |
+
2. **Token Generation**: AI Router creates an order and returns a **Transaction Token** along with an Order ID.
|
| 49 |
+
3. **Fetch Options**: Merchant calls **Fetch Payment Options API** to retrieve available payment methods.
|
| 50 |
+
4. **Checkout Rendering**: AI Router provides the configured options, which populate the cashier page.
|
| 51 |
+
5. **Execution**: User selects an option and clicks Pay. The **Pay API** is then called.
|
| 52 |
+
6. **Routing**: AI Router intelligently evaluates and routes the transaction to the most appropriate PA/PG.
|
| 53 |
+
7. **Processing**: Gateway processes the transaction and returns status to AI Router.
|
| 54 |
+
8. **Verification**:
|
| 55 |
+
- Polling: Merchant polls the **Order Status API** (using the Order ID).
|
| 56 |
+
- Server-to-Server: Alternatively, AI Router triggers a real-time **Payment Webhook** with the updated payment status.
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Part 3: AI Router Routing Rule Configuration
|
| 61 |
+
|
| 62 |
+
If no rules are created, the AI Router defaults to selecting the gateway with the highest historical/real-time success probability. Merchants can explicitly define routing constraints via the dashboard or APIs.
|
| 63 |
+
|
| 64 |
+
### 1. Gateway Routing Configuration
|
| 65 |
+
Routing can be applied to One-Time payments and Subscription payments.
|
| 66 |
+
- **UPI Routing**: Filtered by UPI Intent vs. UPI Collect, and optionally limited by transaction amount ranges.
|
| 67 |
+
- **Bank Mandates Routing**: Filtered by Mandate Type (Netbanking vs Debit Card) and applied against specific Issuing Banks.
|
| 68 |
+
- **Cards Routing**: Rules set by card attributes (Credit, Debit, Prepaid, Visa, Mastercard, RuPay, Amex, Diners, Issuing Bank).
|
| 69 |
+
- **Cost-Based Routing**: Optimizes for gateway costs.
|
| 70 |
+
- *Relative Cost*: Ranked priority preference according to cheapest.
|
| 71 |
+
- *Absolute Cost*: Flat value or percentage based. Minimum Success Rate baselines can also be configured so cheap traffic ensures minimum reliability.
|
| 72 |
+
|
| 73 |
+
### 2. API Based / Enforced Routing
|
| 74 |
+
Transaction-level control that overrides dashboard configurations, primarily used for targeted business campaigns/promotions.
|
| 75 |
+
- **How to Use**: Pass special parameter variables via code during runtime.
|
| 76 |
+
- **Payload**: Provide `enforcedRoutingParam` inside the `txnRoutingParams` object.
|
| 77 |
+
- **Endpoint Injection**: This object must be pushed in either the **Create Order API** or **Pay API**. (If a conflict exists between the two, parameters sent via the Pay API take precedence).
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Part 4: General Developer Implementation Notes & FAQs
|
| 82 |
+
- **Gateways requirement**: Merchants must independently onboard with third-party payment aggregators. Once keys are received, submit them statically inside the Paytm AI Router Dashboard.
|
| 83 |
+
- **Fallback Logic**: If the lowest cost gateway drops below the success rate baseline, AI Router will auto-skip to the next cheapest that meets strict success margins.
|
| 84 |
+
- **Integration**: The unified AI Router architecture prevents merchants from having to build split/custom integrations for every gateway backend. Call the AI Router suite uniformly, and routing configs isolate the complexities.
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Part 5: Core Transaction APIs
|
| 89 |
+
1. **Initiate Transaction API**: Used to create an order and retrieve a transaction token.
|
| 90 |
+
- Endpoint: `POST /theia/api/v1/initiateTransaction?mid={MID}&orderId={ORDERID}`
|
| 91 |
+
- Body contains: `requestType` (Payment), `txnAmount` (value, currency), `userInfo` (custId), `callbackUrl`
|
| 92 |
+
2. **Process Transaction API**: Used to process the payment using the generated token.
|
| 93 |
+
- Body contains: `paymentMode` (e.g., `CC`, `DC`, `NET_BANKING`, `UPI`, `BALANCE`), `cardInfo`, `channelCode`.
|
| 94 |
+
3. **Transaction Status API**: Query the status of a specific order.
|
| 95 |
+
- Body contains: `ORDERID`, `MID`. Returns `STATUS` (e.g., `TXN_SUCCESS`, `TXN_FAILURE`).
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Part 6: JS Checkout Integration
|
| 100 |
+
A customized frontend layer that natively displays payment options on the merchant's site.
|
| 101 |
+
1. Inject the script: `<script src="https://securegw.paytm.in/merchantpgpui/checkoutjs/merchants/{MID}.js"></script>`
|
| 102 |
+
2. Initialize and Invoke:
|
| 103 |
+
```javascript
|
| 104 |
+
var config = { "root": "", "flow": "DEFAULT", "data": { "orderId": "{ORDERID}", "token": "{TXN_TOKEN}", "tokenType": "TXN_TOKEN", "amount": "{AMOUNT}" }, "handler": { "notifyMerchant": function(eventName,data){ ... } } };
|
| 105 |
+
window.Paytm.CheckoutJS.init(config).then(function() {
|
| 106 |
+
window.Paytm.CheckoutJS.invoke();
|
| 107 |
+
});
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Part 7: Subscriptions
|
| 113 |
+
- **Initiate Subscription API**: Used to create a subscription mandate.
|
| 114 |
+
- **List Subscriptions API**: Used to view active/inactive mandates.
|
| 115 |
+
- **JS Checkout for Subscriptions**: Extends JS Checkout by passing subscription-specific flags (e.g., `requestType: "SUBSCRIBE"`) during checkout initialization.
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## Part 8: Post-Transaction & Financials
|
| 120 |
+
1. **Refund API**: Initiate a partial or full refund against a `TXNID`.
|
| 121 |
+
- Requires: `ORDERID`, `TXNID`, `refId` (merchant-generated unique refund ID), `refundAmount`.
|
| 122 |
+
2. **Settlement APIs**:
|
| 123 |
+
- **Settlement Summary API**: Fetches the gross/net settlement summary against the Payout Date.
|
| 124 |
+
- **Settlement Detail API**: Provides transactional-level granularity of an executed settlement.
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Part 9: Webhooks & Callbacks
|
| 129 |
+
1. **Payment Status Webhook**: Paytm pushes real-time server-to-server updates when an order transitions to a terminal state (Success/Failure).
|
| 130 |
+
- Payload includes: `ORDERID`, `TXNID`, `TXNAMOUNT`, `STATUS`, `RESPCODE`, `RESPMSG`, `CHECKSUMHASH`.
|
| 131 |
+
- Merchants must validate the `CHECKSUMHASH` against their `PAYTM_KEY_SECRET` to prevent spoofing.
|
| 132 |
+
- Webhook URL is setup via the Merchant Dashboard.
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## Part 10: AI Router Analytics & Reports
|
| 137 |
+
Paytm provides a rich dashboard for visualizing and analyzing transaction data. This is particularly useful when building merchant-facing LLM agents that need to query or interpret performance graphs, failures, and reconciliations.
|
| 138 |
+
|
| 139 |
+
### 1. Analytics Dashboards
|
| 140 |
+
- **Successful Payments Analysis**: Tracks successful payment volumes. Supports Historical Benchmarking (Yesterday, Current Week vs Last Week, Current Month comparisons) and Historic Trends. Can overlay **Gateways**, **Payment Source**, and **Moving Averages**.
|
| 141 |
+
- **Total Collection Analysis**: Similar to volume tracking, but tracks the Total Collection Amount in INR.
|
| 142 |
+
- **Success Rate Analysis**: Tracks the transaction success percentage. Includes **Failure Reasons Analytics** (identifies if the failure was caused by the user, bank, or the router) and highlights the top failure contributors.
|
| 143 |
+
|
| 144 |
+
### 2. Standard Reports
|
| 145 |
+
- **Payments Report**: Offers a List View (Transaction ID, Date, Order ID, Payment Source, Gateway, Amount) and a Detailed View (Response Codes, Customer details). Allows single-click refund initiation.
|
| 146 |
+
- **Refunds Report**: Offers List View and Detailed View (shows RRN [Refund Reference Number]).
|
| 147 |
+
- **Bulk Refunds**: Merchants can upload a CSV (containing `TXN_ID`, `REFUND_AMOUNT`, and `REFUND_REASON`) to process mass bulk refunds directly from the dashboard panel.
|
| 148 |
+
|
| 149 |
+
### 3. Report Generation and Downloads
|
| 150 |
+
- **Exporting**: Reports can be downloaded offline (CSV/Excel/PDF) or emailed directly.
|
| 151 |
+
- **Constraints**: Maximum querying duration for a single report export is 3 months.
|
| 152 |
+
- **Filters Supported**: Date/Time Range, Specific Payment Gateway, Payment Source (CC/DC/UPI), Status (Success, Pending, Failed), Order ID, etc.
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
requests
|
| 4 |
+
pytest
|
| 5 |
+
httpx
|
scripts/__pycache__/seed_scenarios.cpython-311.pyc
ADDED
|
Binary file (901 Bytes). View file
|
|
|
scripts/seed_scenarios.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.config import load_settings
|
| 2 |
+
from app.db.repo import Repository
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def main() -> None:
|
| 6 |
+
settings = load_settings()
|
| 7 |
+
repo = Repository(settings.db_path)
|
| 8 |
+
repo.seed_demo_data(force=True)
|
| 9 |
+
print("Mock scenarios seeded successfully.")
|
| 10 |
+
print(f"Database path: {settings.db_path}")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
if __name__ == "__main__":
|
| 14 |
+
main()
|
tests/__pycache__/test_context_memory.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (20.3 kB). View file
|
|
|
tests/__pycache__/test_mock_tools.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (4.59 kB). View file
|
|
|
tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (4.77 kB). View file
|
|
|
tests/__pycache__/test_smoke.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (4.46 kB). View file
|
|
|
tests/__pycache__/test_telegram_handler.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (4.67 kB). View file
|
|
|
tests/test_context_memory.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the new conversational-memory / rolling-context feature."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from unittest.mock import patch, MagicMock
|
| 4 |
+
|
| 5 |
+
from app.agent.orchestrator import Orchestrator
|
| 6 |
+
from app.db.repo import Repository
|
| 7 |
+
from app.llm.client import LlmClient
|
| 8 |
+
from app.tools.mock_paytm import MockPaytmTools
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# Helpers
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
def _make_repo(tmp_path: Path) -> Repository:
|
| 16 |
+
repo = Repository(str(tmp_path / "ctx.sqlite3"))
|
| 17 |
+
repo.seed_demo_data()
|
| 18 |
+
return repo
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _make_orchestrator(tmp_path: Path) -> tuple[Orchestrator, Repository, LlmClient]:
|
| 22 |
+
repo = _make_repo(tmp_path)
|
| 23 |
+
tools = MockPaytmTools(repo)
|
| 24 |
+
llm = LlmClient("", "", "test-model")
|
| 25 |
+
orch = Orchestrator(repo, tools, llm)
|
| 26 |
+
return orch, repo, llm
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# repo.get_recent_context
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
def test_get_recent_context_returns_history(tmp_path: Path) -> None:
|
| 34 |
+
repo = _make_repo(tmp_path)
|
| 35 |
+
uid = repo.ensure_user("u1")
|
| 36 |
+
|
| 37 |
+
repo.save_message(uid, "text", "hello", "hi there")
|
| 38 |
+
repo.save_message(uid, "text", "show orders", "found 4 orders")
|
| 39 |
+
repo.save_message(uid, "voice", "refund status", "no pending refunds")
|
| 40 |
+
|
| 41 |
+
ctx = repo.get_recent_context(uid)
|
| 42 |
+
# 3 exchanges -> 6 messages (user + assistant each)
|
| 43 |
+
assert len(ctx) == 6
|
| 44 |
+
assert ctx[0] == {"role": "user", "content": "hello"}
|
| 45 |
+
assert ctx[1] == {"role": "assistant", "content": "hi there"}
|
| 46 |
+
assert ctx[-2] == {"role": "user", "content": "refund status"}
|
| 47 |
+
assert ctx[-1] == {"role": "assistant", "content": "no pending refunds"}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_get_recent_context_empty_for_new_user(tmp_path: Path) -> None:
|
| 51 |
+
repo = _make_repo(tmp_path)
|
| 52 |
+
uid = repo.ensure_user("brand-new-user")
|
| 53 |
+
ctx = repo.get_recent_context(uid)
|
| 54 |
+
assert ctx == []
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_get_recent_context_respects_limit(tmp_path: Path) -> None:
|
| 58 |
+
repo = _make_repo(tmp_path)
|
| 59 |
+
uid = repo.ensure_user("u2")
|
| 60 |
+
|
| 61 |
+
for i in range(10):
|
| 62 |
+
repo.save_message(uid, "text", f"msg-{i}", f"reply-{i}")
|
| 63 |
+
|
| 64 |
+
ctx = repo.get_recent_context(uid, limit=3)
|
| 65 |
+
# limit=3 means 3 DB rows -> 6 role messages, but only the most recent 3 exchanges
|
| 66 |
+
assert len(ctx) == 6
|
| 67 |
+
assert ctx[0]["content"] == "msg-7" # oldest of the 3 kept
|
| 68 |
+
assert ctx[-1]["content"] == "reply-9" # newest
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# LlmClient.generate – payload structure
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
def test_generate_builds_payload_with_history(tmp_path: Path) -> None:
|
| 76 |
+
llm = LlmClient("https://example.com/v1/chat/completions", "key", "model")
|
| 77 |
+
history = [
|
| 78 |
+
{"role": "user", "content": "hi"},
|
| 79 |
+
{"role": "assistant", "content": "hello"},
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
with patch("app.llm.client.requests.post") as mock_post:
|
| 83 |
+
mock_resp = MagicMock()
|
| 84 |
+
mock_resp.status_code = 200
|
| 85 |
+
mock_resp.json.return_value = {
|
| 86 |
+
"choices": [{"message": {"content": "test reply"}}]
|
| 87 |
+
}
|
| 88 |
+
mock_resp.raise_for_status = MagicMock()
|
| 89 |
+
mock_post.return_value = mock_resp
|
| 90 |
+
|
| 91 |
+
result = llm.generate("what now?", history=history)
|
| 92 |
+
|
| 93 |
+
assert result == "test reply"
|
| 94 |
+
called_payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
| 95 |
+
msgs = called_payload["messages"]
|
| 96 |
+
# system + 2 history + current user = 4
|
| 97 |
+
assert len(msgs) == 4
|
| 98 |
+
assert msgs[0]["role"] == "system"
|
| 99 |
+
assert msgs[1] == {"role": "user", "content": "hi"}
|
| 100 |
+
assert msgs[2] == {"role": "assistant", "content": "hello"}
|
| 101 |
+
assert msgs[3] == {"role": "user", "content": "what now?"}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_generate_works_without_history() -> None:
|
| 105 |
+
"""Backward compat: calling generate without history still works."""
|
| 106 |
+
llm = LlmClient("https://example.com/v1/chat/completions", "key", "model")
|
| 107 |
+
|
| 108 |
+
with patch("app.llm.client.requests.post") as mock_post:
|
| 109 |
+
mock_resp = MagicMock()
|
| 110 |
+
mock_resp.status_code = 200
|
| 111 |
+
mock_resp.json.return_value = {
|
| 112 |
+
"choices": [{"message": {"content": "ok"}}]
|
| 113 |
+
}
|
| 114 |
+
mock_resp.raise_for_status = MagicMock()
|
| 115 |
+
mock_post.return_value = mock_resp
|
| 116 |
+
|
| 117 |
+
result = llm.generate("just a prompt")
|
| 118 |
+
|
| 119 |
+
assert result == "ok"
|
| 120 |
+
called_payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
| 121 |
+
msgs = called_payload["messages"]
|
| 122 |
+
# system + user = 2 (no history)
|
| 123 |
+
assert len(msgs) == 2
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
# Orchestrator integration – context flows through
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
|
| 130 |
+
def test_orchestrator_passes_context_to_llm(tmp_path: Path) -> None:
|
| 131 |
+
"""When the orchestrator falls through to the LLM path, it should
|
| 132 |
+
include prior context in the generate() call."""
|
| 133 |
+
orch, repo, llm = _make_orchestrator(tmp_path)
|
| 134 |
+
uid = repo.ensure_user("t-100")
|
| 135 |
+
|
| 136 |
+
# Seed a prior message so there's context to retrieve
|
| 137 |
+
repo.save_message(uid, "text", "show orders", "found 4 orders")
|
| 138 |
+
|
| 139 |
+
with patch.object(llm, "generate", return_value="mocked reply") as mock_gen:
|
| 140 |
+
reply = orch.handle_user_text("t-100", "tell me more about the first one")
|
| 141 |
+
|
| 142 |
+
assert reply == "mocked reply"
|
| 143 |
+
# Verify generate was called with history kwarg containing prior context
|
| 144 |
+
args, kwargs = mock_gen.call_args
|
| 145 |
+
assert "history" in kwargs
|
| 146 |
+
assert len(kwargs["history"]) == 2 # 1 prior exchange = 2 messages
|
| 147 |
+
assert kwargs["history"][0]["content"] == "show orders"
|
| 148 |
+
assert kwargs["history"][1]["content"] == "found 4 orders"
|
tests/test_mock_tools.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from app.db.repo import Repository
|
| 4 |
+
from app.tools.mock_paytm import MockPaytmTools
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_create_and_fetch_link(tmp_path: Path) -> None:
|
| 8 |
+
repo = Repository(str(tmp_path / "mock.sqlite3"))
|
| 9 |
+
tools = MockPaytmTools(repo)
|
| 10 |
+
|
| 11 |
+
created = tools.create_link(250.0, "Amit")
|
| 12 |
+
fetched = tools.fetch_link(created["link_id"])
|
| 13 |
+
|
| 14 |
+
assert fetched["link_id"] == created["link_id"]
|
| 15 |
+
assert fetched["amount"] == 250.0
|
| 16 |
+
assert fetched["customer_name"] == "Amit"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_refund_workflow(tmp_path: Path) -> None:
|
| 20 |
+
repo = Repository(str(tmp_path / "refund.sqlite3"))
|
| 21 |
+
tools = MockPaytmTools(repo)
|
| 22 |
+
|
| 23 |
+
initiated = tools.initiate_refund("TXN-2001", 99.0)
|
| 24 |
+
status = tools.check_refund_status(initiated["refund_id"])
|
| 25 |
+
|
| 26 |
+
assert status["refund_id"] == initiated["refund_id"]
|
| 27 |
+
assert status["txn_id"] == "TXN-2001"
|
| 28 |
+
assert status["status"] == "PENDING"
|
tests/test_orchestrator.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from app.agent.orchestrator import Orchestrator
|
| 4 |
+
from app.db.repo import Repository
|
| 5 |
+
from app.llm.client import LlmClient
|
| 6 |
+
from app.tools.mock_paytm import MockPaytmTools
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def build_orchestrator(tmp_path: Path) -> Orchestrator:
|
| 10 |
+
repo = Repository(str(tmp_path / "orch.sqlite3"))
|
| 11 |
+
repo.seed_demo_data()
|
| 12 |
+
tools = MockPaytmTools(repo)
|
| 13 |
+
llm = LlmClient("", "", "llama-3.1-8b-instant")
|
| 14 |
+
return Orchestrator(repo, tools, llm)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_order_query_returns_summary(tmp_path: Path) -> None:
|
| 18 |
+
orchestrator = build_orchestrator(tmp_path)
|
| 19 |
+
response = orchestrator.handle_user_text("123", "show recent orders")
|
| 20 |
+
assert "orders" in response
|
| 21 |
+
assert "<b>" in response
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_refund_requires_confirm_then_executes(tmp_path: Path) -> None:
|
| 25 |
+
orchestrator = build_orchestrator(tmp_path)
|
| 26 |
+
preview = orchestrator.handle_user_text("123", "refund txn-2001 amount 20")
|
| 27 |
+
assert "confirm" in preview.lower()
|
| 28 |
+
|
| 29 |
+
confirmed = orchestrator.handle_user_text("123", "confirm")
|
| 30 |
+
assert "Refund initiated" in confirmed
|
tests/test_smoke.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
|
| 3 |
+
from app.main import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_health() -> None:
|
| 7 |
+
client = TestClient(app)
|
| 8 |
+
response = client.get("/health")
|
| 9 |
+
assert response.status_code == 200
|
| 10 |
+
assert response.json()["status"] == "ok"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_webhook_text_message() -> None:
|
| 14 |
+
client = TestClient(app)
|
| 15 |
+
payload = {
|
| 16 |
+
"message": {"chat": {"id": 101}, "text": "show settlement summary"},
|
| 17 |
+
}
|
| 18 |
+
response = client.post(
|
| 19 |
+
"/telegram/webhook",
|
| 20 |
+
json=payload,
|
| 21 |
+
headers={"X-Telegram-Bot-Api-Secret-Token": "local-secret"},
|
| 22 |
+
)
|
| 23 |
+
assert response.status_code == 200
|
| 24 |
+
data = response.json()
|
| 25 |
+
assert data["ok"] is True
|
| 26 |
+
assert data["input_type"] == "text"
|
tests/test_telegram_handler.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from app.agent.orchestrator import Orchestrator
|
| 4 |
+
from app.db.repo import Repository
|
| 5 |
+
from app.llm.client import LlmClient
|
| 6 |
+
from app.telegram.handler import TelegramHandler
|
| 7 |
+
from app.tools.mock_paytm import MockPaytmTools
|
| 8 |
+
from app.voice.stt import SttClient
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def build_handler(tmp_path: Path) -> TelegramHandler:
|
| 12 |
+
repo = Repository(str(tmp_path / "telegram.sqlite3"))
|
| 13 |
+
repo.seed_demo_data(force=True)
|
| 14 |
+
orchestrator = Orchestrator(repo, MockPaytmTools(repo), LlmClient("", "", "llama"))
|
| 15 |
+
stt = SttClient("", "", "whisper-large-v3-turbo")
|
| 16 |
+
return TelegramHandler(orchestrator, stt, bot_token="")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_voice_update_without_bot_token_falls_back(tmp_path: Path) -> None:
|
| 20 |
+
handler = build_handler(tmp_path)
|
| 21 |
+
result = handler.process_update(
|
| 22 |
+
{"message": {"chat": {"id": 999}, "voice": {"file_id": "abc123"}}}
|
| 23 |
+
)
|
| 24 |
+
assert result["ok"] is True
|
| 25 |
+
assert result["input_type"] == "voice"
|
| 26 |
+
assert "transcription unavailable" in result["transcript"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_audio_update_is_supported(tmp_path: Path) -> None:
|
| 30 |
+
handler = build_handler(tmp_path)
|
| 31 |
+
result = handler.process_update(
|
| 32 |
+
{"message": {"chat": {"id": 999}, "audio": {"file_id": "xyz123"}}}
|
| 33 |
+
)
|
| 34 |
+
assert result["ok"] is True
|
| 35 |
+
assert result["input_type"] == "voice"
|
| 36 |
+
assert "transcription unavailable" in result["transcript"]
|
via.sqlite3
ADDED
|
Binary file (98.3 kB). View file
|
|
|