gcharanteja commited on
Commit Β·
54f0a75
1
Parent(s): a608a37
Refactor project structure and enhance functionality
Browse files- Update .gitignore to exclude model files and sensitive data.
- Modify Dockerfile to remove .env from the image for security.
- Revise README.md to clarify project purpose and deployment instructions.
- Implement main.py as the orchestrator for service management with detailed status endpoints.
- Create shared_state.py for managing shared variables between components.
- Refactor telegram_agent.py to improve message handling and initialization of components.
- .gitignore +2 -0
- Dockerfile +1 -0
- README.md +65 -4
- main.py +287 -22
- shared_state.py +8 -0
- telegram_agent.py +198 -336
.gitignore
CHANGED
|
@@ -3,5 +3,7 @@
|
|
| 3 |
.venv/
|
| 4 |
__pycache__/
|
| 5 |
*.pyc
|
|
|
|
|
|
|
| 6 |
.qwen
|
| 7 |
|
|
|
|
| 3 |
.venv/
|
| 4 |
__pycache__/
|
| 5 |
*.pyc
|
| 6 |
+
models/
|
| 7 |
+
*.gguf
|
| 8 |
.qwen
|
| 9 |
|
Dockerfile
CHANGED
|
@@ -20,6 +20,7 @@ RUN uv sync --frozen --no-dev
|
|
| 20 |
|
| 21 |
# Copy application files
|
| 22 |
COPY --chown=user . .
|
|
|
|
| 23 |
|
| 24 |
# Run main.py
|
| 25 |
CMD ["uv", "run", "main.py"]
|
|
|
|
| 20 |
|
| 21 |
# Copy application files
|
| 22 |
COPY --chown=user . .
|
| 23 |
+
RUN rm -f .env # never include .env in Docker image (use HF Space secrets)
|
| 24 |
|
| 25 |
# Run main.py
|
| 26 |
CMD ["uv", "run", "main.py"]
|
README.md
CHANGED
|
@@ -7,10 +7,71 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# Mann
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
##
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Mann β Jira Sprint Manager
|
| 11 |
|
| 12 |
+
AI-powered Jira sprint management via Telegram. Uses a local LLM (Phi-4-mini) with MCP tools to interact with Jira.
|
| 13 |
|
| 14 |
+
## Architecture
|
| 15 |
|
| 16 |
+
```
|
| 17 |
+
main.py (FastAPI orchestrator, port 7860)
|
| 18 |
+
βββ server.py β Jira REST API (subprocess, port 8001)
|
| 19 |
+
βββ jira_mcp_server.py β MCP tool server (stdio)
|
| 20 |
+
βββ telegram_agent.py β Telegram bot + LLM (background thread)
|
| 21 |
+
βββ Phi-4-mini (llama-cpp) β loaded from HF cache
|
| 22 |
+
βββ sentence-transformers β local embeddings
|
| 23 |
+
βββ Pinecone β conversation memory
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Endpoints
|
| 27 |
+
|
| 28 |
+
| Method | Path | Description |
|
| 29 |
+
|--------|------|-------------|
|
| 30 |
+
| `POST` | `/start` | Trigger service startup |
|
| 31 |
+
| `GET` | `/status` | Overall health of all services |
|
| 32 |
+
| `GET` | `/status/server` | Jira REST API status |
|
| 33 |
+
| `GET` | `/status/mcp` | MCP server + tool list |
|
| 34 |
+
| `GET` | `/status/telegram` | Telegram bot + LLM status |
|
| 35 |
+
| `*` | `/jira/*` | Proxy to Jira REST API |
|
| 36 |
+
|
| 37 |
+
## Environment Variables
|
| 38 |
+
|
| 39 |
+
Set these in HF Spaces β Settings β Repository secrets:
|
| 40 |
+
|
| 41 |
+
| Variable | Description |
|
| 42 |
+
|----------|-------------|
|
| 43 |
+
| `JIRA_BASE_URL` | Your Jira instance URL (e.g. `https://your-domain.atlassian.net`) |
|
| 44 |
+
| `JIRA_EMAIL` | Jira account email |
|
| 45 |
+
| `JIRA_API_TOKEN` | Jira API token |
|
| 46 |
+
| `JIRA_PROJECT_KEY` | Project key (default: `SCRUM`) |
|
| 47 |
+
| `JIRA_BOARD_ID` | Board ID (default: `1`) |
|
| 48 |
+
| `TELEGRAM_TOKEN` | Telegram bot token from @BotFather |
|
| 49 |
+
| `PINECONE_API_KEY` | Pinecone API key for conversation memory |
|
| 50 |
+
| `PINECONE_INDEX` | Pinecone index name (default: `jira-agent-memory`) |
|
| 51 |
+
|
| 52 |
+
## Local Development
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
# Install dependencies
|
| 56 |
+
uv sync
|
| 57 |
+
|
| 58 |
+
# Set environment variables
|
| 59 |
+
cp .env.example .env # edit with your credentials
|
| 60 |
+
|
| 61 |
+
# Run the unified orchestrator
|
| 62 |
+
uv run main.py
|
| 63 |
+
|
| 64 |
+
# Or run Telegram agent standalone (requires server.py already running)
|
| 65 |
+
uv run telegram_agent.py
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## HF Spaces Deployment
|
| 69 |
+
|
| 70 |
+
1. Push code to this repo
|
| 71 |
+
2. Set all environment variables in Space settings
|
| 72 |
+
3. Space auto-starts via `Dockerfile` β `main.py`
|
| 73 |
+
4. Check `/status` endpoint to verify all services are live
|
| 74 |
+
|
| 75 |
+
## Model
|
| 76 |
+
|
| 77 |
+
Phi-4-mini (Q4_K_M, ~2.2GB) is downloaded from HuggingFace to `~/.cache/huggingface` β this does **not** count against the 1GB app storage limit on HF Spaces.
|
main.py
CHANGED
|
@@ -1,40 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import time
|
|
|
|
|
|
|
| 2 |
import threading
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
from
|
|
|
|
|
|
|
|
|
|
| 6 |
import uvicorn
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
try:
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
@app.get("/healthz")
|
| 24 |
-
def healthz():
|
| 25 |
-
return {"status": "ok"}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
# Start loading animation in background thread
|
| 33 |
-
loading_thread = threading.Thread(target=loading_animation, daemon=True)
|
| 34 |
-
loading_thread.start()
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
uvicorn.run(app, host="0.0.0.0", port=port)
|
| 38 |
|
|
|
|
| 39 |
if __name__ == "__main__":
|
| 40 |
main()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unified Jira Sprint Manager β Orchestrator
|
| 3 |
+
============================================
|
| 4 |
+
Single entry point for HF Spaces. Starts all services and exposes status endpoints.
|
| 5 |
+
|
| 6 |
+
Endpoints:
|
| 7 |
+
POST /start β Start all services (server, MCP, Telegram bot)
|
| 8 |
+
GET /status β Overall health of all services
|
| 9 |
+
GET /status/server β Check if server.py (Jira REST API) is live
|
| 10 |
+
GET /status/mcp β Check if MCP server is live
|
| 11 |
+
GET /status/telegram β Check if Telegram bot + LLM are live
|
| 12 |
+
|
| 13 |
+
Plus all server.py routes mounted under /jira/*
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
import time
|
| 19 |
+
import json
|
| 20 |
+
import asyncio
|
| 21 |
import threading
|
| 22 |
+
import subprocess
|
| 23 |
+
import requests
|
| 24 |
+
from contextlib import asynccontextmanager
|
| 25 |
+
|
| 26 |
+
from fastapi import FastAPI, HTTPException
|
| 27 |
+
from pydantic import BaseModel
|
| 28 |
import uvicorn
|
| 29 |
|
| 30 |
+
# ββ Service state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
class ServiceState:
|
| 32 |
+
def __init__(self):
|
| 33 |
+
self.server_started = False
|
| 34 |
+
self.server_process = None
|
| 35 |
+
self.telegram_started = False
|
| 36 |
+
self.all_started = False
|
| 37 |
+
self.error = None
|
| 38 |
+
|
| 39 |
+
state = ServiceState()
|
| 40 |
+
|
| 41 |
+
# ββ Refs for status checking (set by telegram_agent) βββββββββββββββββββββββββ
|
| 42 |
+
mcp_session_ref = None
|
| 43 |
+
openai_tools_ref = None
|
| 44 |
+
telegram_bot_ref = None
|
| 45 |
+
llm_ref = None
|
| 46 |
+
|
| 47 |
+
# ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
SERVER_PORT = int(os.environ.get("SERVER_PORT", "8001"))
|
| 49 |
+
MAIN_PORT = int(os.environ.get("PORT", "7860"))
|
| 50 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ββ Start server.py as subprocess βββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
def start_jira_server():
|
| 55 |
+
"""Launch server.py (FastAPI Jira REST API) as a background subprocess."""
|
| 56 |
+
server_script = os.path.join(BASE_DIR, "server.py")
|
| 57 |
+
if not os.path.exists(server_script):
|
| 58 |
+
state.error = f"server.py not found at {server_script}"
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
print(f"\n[orchestrator] Starting Jira REST API server on port {SERVER_PORT}...")
|
| 62 |
+
env = {**os.environ, "PORT": str(SERVER_PORT)}
|
| 63 |
+
proc = subprocess.Popen(
|
| 64 |
+
[sys.executable, server_script],
|
| 65 |
+
env=env,
|
| 66 |
+
stdout=subprocess.PIPE,
|
| 67 |
+
stderr=subprocess.STDOUT,
|
| 68 |
+
text=True,
|
| 69 |
+
)
|
| 70 |
+
state.server_process = proc
|
| 71 |
+
|
| 72 |
+
# Wait for server to be ready
|
| 73 |
+
for attempt in range(30):
|
| 74 |
+
time.sleep(1)
|
| 75 |
+
poll = proc.poll()
|
| 76 |
+
if poll is not None:
|
| 77 |
+
state.error = f"server.py exited early (code={poll})"
|
| 78 |
+
if proc.stdout:
|
| 79 |
+
print(proc.stdout.read())
|
| 80 |
+
return False
|
| 81 |
+
try:
|
| 82 |
+
r = requests.get(f"http://localhost:{SERVER_PORT}/", timeout=2)
|
| 83 |
+
if r.status_code == 200:
|
| 84 |
+
state.server_started = True
|
| 85 |
+
print(f"[orchestrator] Jira REST API server is live on port {SERVER_PORT}")
|
| 86 |
+
return True
|
| 87 |
+
except requests.RequestException:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
state.error = "server.py failed to start within 30 seconds"
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
|
| 94 |
+
# ββ Start Telegram + MCP in background thread βββββββββββββββββββββββββββββββββ
|
| 95 |
+
def start_telegram_and_mcp():
|
| 96 |
+
"""Run the Telegram agent + MCP server in a dedicated event loop."""
|
| 97 |
+
import shared_state
|
| 98 |
+
from telegram_agent import (
|
| 99 |
+
start_telegram_with_mcp, _init_telegram_bot, _init_llm,
|
| 100 |
+
_init_embedder, _init_pinecone, bot, llm,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Initialize components to detect LLM
|
| 104 |
try:
|
| 105 |
+
_init_embedder()
|
| 106 |
+
_init_llm()
|
| 107 |
+
_init_pinecone()
|
| 108 |
+
_init_telegram_bot()
|
| 109 |
+
except Exception as e:
|
| 110 |
+
state.error = f"Failed to init components: {str(e)}"
|
| 111 |
+
print(f"[orchestrator] ERROR: {state.error}")
|
| 112 |
+
return
|
| 113 |
|
| 114 |
+
# Set refs
|
| 115 |
+
shared_state.llm_instance = llm
|
| 116 |
+
shared_state.telegram_bot = bot
|
| 117 |
|
| 118 |
+
# Run the MCP + Telegram loop
|
| 119 |
+
try:
|
| 120 |
+
asyncio.run(start_telegram_with_mcp())
|
| 121 |
+
except Exception as e:
|
| 122 |
+
state.error = f"Telegram/MCP failed: {str(e)}"
|
| 123 |
+
print(f"[orchestrator] ERROR: {state.error}")
|
| 124 |
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
+
# ββ Lifespan for async startup βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
+
@asynccontextmanager
|
| 128 |
+
async def lifespan(app: FastAPI):
|
| 129 |
+
"""Start all services on app startup."""
|
| 130 |
+
print("\n" + "=" * 60)
|
| 131 |
+
print(" Jira Sprint Manager β Unified Orchestrator")
|
| 132 |
+
print("=" * 60)
|
| 133 |
|
| 134 |
+
# 1. Start server.py
|
| 135 |
+
if not start_jira_server():
|
| 136 |
+
print(f"[orchestrator] ERROR: Failed to start server.py: {state.error}")
|
| 137 |
+
else:
|
| 138 |
+
# 2. Start Telegram + MCP in background thread
|
| 139 |
+
print("[orchestrator] Starting Telegram bot + MCP server...")
|
| 140 |
+
tg_thread = threading.Thread(target=start_telegram_and_mcp, daemon=True)
|
| 141 |
+
tg_thread.start()
|
| 142 |
+
state.telegram_started = True
|
| 143 |
+
state.all_started = True
|
| 144 |
|
| 145 |
+
yield
|
| 146 |
+
|
| 147 |
+
# Shutdown
|
| 148 |
+
print("\n[orchestrator] Shutting down...")
|
| 149 |
+
if state.server_process:
|
| 150 |
+
state.server_process.terminate()
|
| 151 |
+
state.server_process.wait(timeout=5)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ββ FastAPI App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
+
app = FastAPI(
|
| 156 |
+
title="Jira Sprint Manager",
|
| 157 |
+
description="Unified orchestrator for Jira Sprint Management (REST API + MCP + Telegram Bot)",
|
| 158 |
+
version="2.0.0",
|
| 159 |
+
lifespan=lifespan,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ββ Status Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
@app.get("/status/server")
|
| 166 |
+
def status_server():
|
| 167 |
+
"""Check if the Jira REST API server (server.py) is live."""
|
| 168 |
+
if not state.server_started:
|
| 169 |
+
return {"service": "jira_rest_api", "status": "down", "port": SERVER_PORT,
|
| 170 |
+
"error": state.error or "Not started yet"}
|
| 171 |
+
try:
|
| 172 |
+
r = requests.get(f"http://localhost:{SERVER_PORT}/", timeout=3)
|
| 173 |
+
return {"service": "jira_rest_api", "status": "live" if r.status_code == 200 else "degraded",
|
| 174 |
+
"port": SERVER_PORT, "response": r.json() if r.status_code == 200 else None}
|
| 175 |
+
except requests.RequestException as e:
|
| 176 |
+
return {"service": "jira_rest_api", "status": "unreachable", "port": SERVER_PORT, "error": str(e)}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@app.get("/status/mcp")
|
| 180 |
+
def status_mcp():
|
| 181 |
+
"""Check if the MCP server is live and how many tools are available."""
|
| 182 |
+
import shared_state
|
| 183 |
+
tools = shared_state.openai_tools
|
| 184 |
+
session = shared_state.mcp_session
|
| 185 |
+
|
| 186 |
+
if session is None or tools is None:
|
| 187 |
+
return {"service": "mcp_server", "status": "down", "tools_count": 0,
|
| 188 |
+
"tools": [], "error": "MCP server not initialized"}
|
| 189 |
+
return {"service": "mcp_server", "status": "live", "tools_count": len(tools),
|
| 190 |
+
"tools": [t["function"]["name"] for t in tools]}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
@app.get("/status/telegram")
|
| 194 |
+
def status_telegram():
|
| 195 |
+
"""Check if the Telegram bot is running and LLM is loaded."""
|
| 196 |
+
import shared_state
|
| 197 |
+
b = shared_state.telegram_bot
|
| 198 |
+
l = shared_state.llm_instance
|
| 199 |
+
|
| 200 |
+
bot_status = "live" if b is not None else "down"
|
| 201 |
+
llm_status = "loaded" if l is not None else "not_loaded"
|
| 202 |
+
|
| 203 |
+
model_path = os.environ.get("MODEL_PATH", os.path.join(BASE_DIR, "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf"))
|
| 204 |
+
model_exists = os.path.exists(model_path)
|
| 205 |
+
|
| 206 |
+
return {"service": "telegram_bot", "bot_status": bot_status, "llm_status": llm_status,
|
| 207 |
+
"model_path": model_path, "model_downloaded": model_exists}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@app.get("/status")
|
| 211 |
+
def status_all():
|
| 212 |
+
"""Overall health check of all services."""
|
| 213 |
+
server = status_server()
|
| 214 |
+
mcp = status_mcp()
|
| 215 |
+
telegram = status_telegram()
|
| 216 |
+
|
| 217 |
+
all_up = (server.get("status") == "live" and mcp.get("status") == "live" and
|
| 218 |
+
telegram.get("bot_status") == "live" and telegram.get("llm_status") == "loaded")
|
| 219 |
+
|
| 220 |
+
return {
|
| 221 |
+
"overall_status": "healthy" if all_up else "partial" if state.all_started else "starting",
|
| 222 |
+
"services": {"jira_rest_api": server, "mcp_server": mcp, "telegram_bot": telegram},
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class StartRequest(BaseModel):
|
| 227 |
+
download_model: bool = True
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@app.post("/start")
|
| 231 |
+
def start_services(req: StartRequest = None):
|
| 232 |
+
"""
|
| 233 |
+
Manually trigger service startup (useful if lifespan didn't auto-start).
|
| 234 |
+
Returns immediately β services run in background.
|
| 235 |
+
"""
|
| 236 |
+
if state.all_started:
|
| 237 |
+
return {"status": "already_started", "message": "All services are already running"}
|
| 238 |
+
|
| 239 |
+
# Start server.py
|
| 240 |
+
if not state.server_started:
|
| 241 |
+
if not start_jira_server():
|
| 242 |
+
raise HTTPException(status_code=500, detail=f"Failed to start server.py: {state.error}")
|
| 243 |
+
|
| 244 |
+
# Start Telegram + MCP
|
| 245 |
+
if not state.telegram_started:
|
| 246 |
+
tg_thread = threading.Thread(target=start_telegram_and_mcp, daemon=True)
|
| 247 |
+
tg_thread.start()
|
| 248 |
+
state.telegram_started = True
|
| 249 |
+
state.all_started = True
|
| 250 |
+
|
| 251 |
+
return {"status": "starting", "message": "Services starting. Poll /status to check progress."}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ββ Proxy: Mount server.py routes under /jira/* ββββββββββββββββββββββββββββββ
|
| 255 |
+
from fastapi import Request as FastAPIRequest
|
| 256 |
+
from fastapi.responses import JSONResponse
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@app.api_route("/jira/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
| 260 |
+
async def proxy_jira(path: str, request: FastAPIRequest):
|
| 261 |
+
"""Proxy requests to the Jira REST API server."""
|
| 262 |
+
if not state.server_started:
|
| 263 |
+
raise HTTPException(status_code=503, detail="Jira REST API server not started")
|
| 264 |
+
|
| 265 |
+
body = await request.body() if request.method in ("POST", "PUT", "PATCH") else None
|
| 266 |
+
|
| 267 |
+
try:
|
| 268 |
+
r = requests.request(
|
| 269 |
+
method=request.method, url=f"http://localhost:{SERVER_PORT}/{path}",
|
| 270 |
+
headers={k: v for k, v in request.headers.items() if k.lower() != "host"},
|
| 271 |
+
params=dict(request.query_params), data=body, timeout=30,
|
| 272 |
+
)
|
| 273 |
+
return JSONResponse(content=r.json(), status_code=r.status_code)
|
| 274 |
+
except requests.RequestException as e:
|
| 275 |
+
raise HTTPException(status_code=502, detail=f"Failed to reach Jira server: {str(e)}")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ββ Root ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 279 |
+
@app.get("/")
|
| 280 |
+
def root():
|
| 281 |
+
return {
|
| 282 |
+
"service": "Jira Sprint Manager",
|
| 283 |
+
"version": "2.0.0",
|
| 284 |
+
"status": "healthy" if state.all_started else "starting",
|
| 285 |
+
"endpoints": {
|
| 286 |
+
"POST /start": "Start all services",
|
| 287 |
+
"GET /status": "Overall health check",
|
| 288 |
+
"GET /status/server": "Jira REST API status",
|
| 289 |
+
"GET /status/mcp": "MCP server status",
|
| 290 |
+
"GET /status/telegram": "Telegram bot + LLM status",
|
| 291 |
+
"GET/POST /jira/*": "Proxy to Jira REST API",
|
| 292 |
+
},
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 297 |
+
def main():
|
| 298 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 299 |
+
print(f"\n[orchestrator] Starting unified FastAPI server on port {port}")
|
| 300 |
+
print(f"[orchestrator] Jira REST API will run on port {SERVER_PORT}\n")
|
| 301 |
uvicorn.run(app, host="0.0.0.0", port=port)
|
| 302 |
|
| 303 |
+
|
| 304 |
if __name__ == "__main__":
|
| 305 |
main()
|
shared_state.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared state between main.py and telegram_agent.py
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
mcp_session = None
|
| 6 |
+
openai_tools = None
|
| 7 |
+
telegram_bot = None
|
| 8 |
+
llm_instance = None
|
telegram_agent.py
CHANGED
|
@@ -8,19 +8,22 @@ Stack:
|
|
| 8 |
Pinecone β stores every turn + tool call + tool result
|
| 9 |
Telegram (pyTelegramBotAPI) β chat interface
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
import os
|
| 17 |
import sys
|
| 18 |
import json
|
| 19 |
import uuid
|
| 20 |
-
import time
|
| 21 |
import asyncio
|
| 22 |
import datetime
|
| 23 |
import threading
|
|
|
|
| 24 |
from llama_cpp import Llama
|
| 25 |
from sentence_transformers import SentenceTransformer
|
| 26 |
from pinecone import Pinecone, ServerlessSpec
|
|
@@ -35,106 +38,96 @@ load_dotenv()
|
|
| 35 |
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN")
|
| 36 |
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY")
|
| 37 |
PINECONE_INDEX = os.environ.get("PINECONE_INDEX", "jira-agent-memory")
|
| 38 |
-
|
| 39 |
MCP_SERVER_SCRIPT = os.path.join(os.path.dirname(__file__), "jira_mcp_server.py")
|
| 40 |
-
|
| 41 |
EMBED_DIM = 768
|
| 42 |
-
|
| 43 |
MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf")
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
# ββ
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
# Per-user conversation state: {chat_id: {"session": ClientSession, "openai_tools": [...], "messages": [...]}}
|
| 54 |
-
user_sessions = {}
|
| 55 |
-
loop_ref = None # will hold the asyncio event loop
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
_embedder
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
def embed(text: str) -> list[float]:
|
| 63 |
vec = _embedder.encode(text[:8000], normalize_embeddings=True)
|
| 64 |
return vec.tolist()
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
from huggingface_hub import hf_hub_download
|
| 74 |
-
|
|
|
|
| 75 |
repo_id="bartowski/Phi-4-mini-instruct-GGUF",
|
| 76 |
filename="Phi-4-mini-instruct-Q4_K_M.gguf",
|
| 77 |
-
local_dir=models_dir,
|
| 78 |
-
local_dir_use_symlinks=False,
|
| 79 |
-
)
|
| 80 |
-
# Move to expected path if needed
|
| 81 |
-
if downloaded_path != MODEL_PATH and not os.path.exists(MODEL_PATH):
|
| 82 |
-
import shutil
|
| 83 |
-
shutil.move(downloaded_path, MODEL_PATH)
|
| 84 |
-
print("\n Download complete.\n")
|
| 85 |
-
|
| 86 |
-
print(" Loading local model...")
|
| 87 |
-
print(f" Model: {MODEL_PATH}")
|
| 88 |
-
print(" This may take 10-30 seconds on first run...\n")
|
| 89 |
-
|
| 90 |
-
llm = Llama(
|
| 91 |
-
model_path=MODEL_PATH,
|
| 92 |
-
n_ctx=8192,
|
| 93 |
-
n_threads=4,
|
| 94 |
-
n_batch=512,
|
| 95 |
-
n_gpu_layers=0,
|
| 96 |
-
verbose=False,
|
| 97 |
-
temperature=0.2,
|
| 98 |
-
)
|
| 99 |
-
print(" Model loaded successfully.\n")
|
| 100 |
-
|
| 101 |
-
# ββ pinecone ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
-
pc = Pinecone(api_key=PINECONE_API_KEY)
|
| 103 |
-
existing = [i.name for i in pc.list_indexes()]
|
| 104 |
-
if PINECONE_INDEX not in existing:
|
| 105 |
-
print(f" Creating Pinecone index '{PINECONE_INDEX}' (dim={EMBED_DIM})...")
|
| 106 |
-
pc.create_index(
|
| 107 |
-
name=PINECONE_INDEX,
|
| 108 |
-
dimension=EMBED_DIM,
|
| 109 |
-
metric="cosine",
|
| 110 |
-
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
|
| 111 |
)
|
| 112 |
-
print("
|
| 113 |
-
|
| 114 |
-
pine_index = pc.Index(PINECONE_INDEX)
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
doc = (
|
| 120 |
-
f"User: {user_input}\n"
|
| 121 |
-
f"Agent: {agent_reply}\n"
|
| 122 |
-
f"Tool called: {tool_name}\n"
|
| 123 |
-
f"Tool args: {json.dumps(tool_args)}\n"
|
| 124 |
-
f"Tool result: {json.dumps(tool_result)}"
|
| 125 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
pine_index.upsert(vectors=[{
|
| 127 |
-
"id": str(uuid.uuid4()),
|
| 128 |
-
"
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
"agent_reply": agent_reply,
|
| 133 |
-
"tool_name": tool_name,
|
| 134 |
-
"tool_args": json.dumps(tool_args),
|
| 135 |
-
"tool_result": json.dumps(tool_result),
|
| 136 |
-
"timestamp": datetime.datetime.utcnow().isoformat(),
|
| 137 |
-
},
|
| 138 |
}])
|
| 139 |
|
| 140 |
|
|
@@ -143,213 +136,116 @@ def recall(user_input: str, top_k: int = 2) -> str:
|
|
| 143 |
total = stats.get("total_vector_count", 0)
|
| 144 |
if total == 0:
|
| 145 |
return ""
|
| 146 |
-
results = pine_index.query(
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
)
|
| 151 |
-
matches = results.get("matches", [])
|
| 152 |
-
if not matches:
|
| 153 |
-
return ""
|
| 154 |
-
chunks = [
|
| 155 |
-
f"[past interaction]\n{m['metadata'].get('user_input', '')} β {m['metadata'].get('tool_name', 'no tool')}"
|
| 156 |
-
for m in matches if m.get('score', 0) > 0.5
|
| 157 |
-
]
|
| 158 |
-
if not chunks:
|
| 159 |
-
return ""
|
| 160 |
-
return "\nRECENT SIMILAR REQUESTS (for context only, do NOT reuse old data):\n" + "\n---\n".join(chunks) + "\n"
|
| 161 |
|
| 162 |
|
| 163 |
-
def mcp_tools_to_openai_schema(mcp_tools)
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
tools.append({
|
| 167 |
-
"type": "function",
|
| 168 |
-
"function": {
|
| 169 |
-
"name": t.name,
|
| 170 |
-
"description": t.description or "",
|
| 171 |
-
"parameters": t.inputSchema or {"type": "object", "properties": {}},
|
| 172 |
-
},
|
| 173 |
-
})
|
| 174 |
-
return tools
|
| 175 |
|
| 176 |
|
| 177 |
-
# ββ
|
| 178 |
async def agent_turn_async(session, openai_tools, user_input: str, chat_id: int) -> str:
|
| 179 |
-
"""Run one full agent turn. Returns the final reply for the user."""
|
| 180 |
-
|
| 181 |
memory_block = recall(user_input)
|
| 182 |
-
|
| 183 |
tool_descriptions = ""
|
| 184 |
for t in openai_tools:
|
| 185 |
-
|
| 186 |
-
req =
|
| 187 |
-
props =
|
| 188 |
-
|
| 189 |
-
tool_descriptions += f"- {t['function']['name']}: {t['function']['description']} |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
""
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
{{"tool": "<tool_name>", "arguments": {{"<param>": "<value>"}}}}
|
| 200 |
-
|
| 201 |
-
Available tools:
|
| 202 |
-
{tool_descriptions}
|
| 203 |
-
|
| 204 |
-
RULES:
|
| 205 |
-
1. For ANY query about Jira data, you MUST call one of the tools above by responding with JSON.
|
| 206 |
-
2. Do NOT make up Jira data. Only report what the tool returns.
|
| 207 |
-
3. If the user asks for something that doesn't need a tool (e.g. greeting), just respond normally.
|
| 208 |
-
4. After the tool result comes back, summarize it concisely for the user.
|
| 209 |
-
5. Be concise.
|
| 210 |
|
| 211 |
-
{
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
final_reply = ""
|
| 214 |
-
tool_calls_parsed = []
|
| 215 |
all_results = []
|
| 216 |
-
|
| 217 |
-
# Build messages for this turn (do NOT pollute with old internal tool messages)
|
| 218 |
-
messages = [
|
| 219 |
-
{"role": "system", "content": system_prompt},
|
| 220 |
-
{"role": "user", "content": user_input},
|
| 221 |
-
]
|
| 222 |
-
# Add clean conversation history (only user/assistant exchanges, max 6 turns)
|
| 223 |
-
user_sessions.setdefault(chat_id, {"messages": []})
|
| 224 |
-
clean_history = []
|
| 225 |
-
for m in user_sessions[chat_id]["messages"]:
|
| 226 |
-
if m["role"] in ("user", "assistant"):
|
| 227 |
-
clean_history.append(m)
|
| 228 |
-
messages.extend(clean_history[-6:])
|
| 229 |
-
|
| 230 |
-
tool_name = "none"
|
| 231 |
-
tool_args = {}
|
| 232 |
-
tool_result = {}
|
| 233 |
-
final_reply = ""
|
| 234 |
tool_calls_parsed = []
|
| 235 |
-
all_results = []
|
| 236 |
|
| 237 |
-
|
| 238 |
-
for _ in range(max_tool_rounds):
|
| 239 |
try:
|
| 240 |
-
response = llm.create_chat_completion(
|
| 241 |
-
|
| 242 |
-
max_tokens=1024,
|
| 243 |
-
temperature=0.2,
|
| 244 |
-
top_p=0.9,
|
| 245 |
-
)
|
| 246 |
-
|
| 247 |
-
choice = response['choices'][0]
|
| 248 |
-
message = choice['message']
|
| 249 |
-
response_text = message.get("content", "")
|
| 250 |
-
|
| 251 |
-
# Strip <think> blocks
|
| 252 |
if "<think>" in response_text and "</think>" in response_text:
|
| 253 |
-
response_text = response_text[response_text.rfind("</think>") +
|
| 254 |
|
| 255 |
-
# Parse
|
| 256 |
tool_calls_parsed = []
|
| 257 |
remaining = response_text
|
| 258 |
while True:
|
| 259 |
-
|
| 260 |
-
if
|
| 261 |
break
|
| 262 |
-
remaining = remaining[
|
| 263 |
-
depth = 0
|
| 264 |
-
json_end = -1
|
| 265 |
for i, ch in enumerate(remaining):
|
| 266 |
-
if ch == "{":
|
| 267 |
-
depth += 1
|
| 268 |
elif ch == "}":
|
| 269 |
depth -= 1
|
| 270 |
if depth == 0:
|
| 271 |
-
|
| 272 |
break
|
| 273 |
-
if
|
| 274 |
break
|
| 275 |
try:
|
| 276 |
-
parsed = json.loads(remaining[:
|
| 277 |
if "tool" in parsed:
|
| 278 |
tool_calls_parsed.append(parsed)
|
| 279 |
except json.JSONDecodeError:
|
| 280 |
pass
|
| 281 |
-
remaining = remaining[
|
| 282 |
|
| 283 |
-
# Model wants to call tools
|
| 284 |
if tool_calls_parsed:
|
| 285 |
all_results = []
|
| 286 |
for tc in tool_calls_parsed:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
print(f" result: {json.dumps(tool_result, indent=2)}\n")
|
| 304 |
-
all_results.append((tool_name, tool_result))
|
| 305 |
-
|
| 306 |
-
messages.append({
|
| 307 |
-
"role": "assistant",
|
| 308 |
-
"content": response_text,
|
| 309 |
-
})
|
| 310 |
-
|
| 311 |
-
results_text_parts = []
|
| 312 |
-
for tn, tr in all_results:
|
| 313 |
-
results_text_parts.append(f"Tool {tn} returned: {json.dumps(tr)}")
|
| 314 |
-
|
| 315 |
-
has_sprint_id_error = any(
|
| 316 |
-
"sprint_id" in json.dumps(tr).lower() and ("integer" in json.dumps(tr).lower() or "int_parsing" in json.dumps(tr).lower())
|
| 317 |
-
for _, tr in all_results
|
| 318 |
-
)
|
| 319 |
-
if has_sprint_id_error:
|
| 320 |
-
messages.append({
|
| 321 |
-
"role": "user",
|
| 322 |
-
"content": f"{' | '.join(results_text_parts)}\n\nThe sprint_id must be a number. First call get_active_sprint to find the current sprint ID, then use that number.",
|
| 323 |
-
})
|
| 324 |
else:
|
| 325 |
-
messages.append({
|
| 326 |
-
"role": "user",
|
| 327 |
-
"content": f"{' | '.join(results_text_parts)}\n\nNow summarize the results for the user concisely.",
|
| 328 |
-
})
|
| 329 |
-
|
| 330 |
-
# Model gave final text answer
|
| 331 |
else:
|
| 332 |
final_reply = response_text
|
| 333 |
print(f"\n Agent: {final_reply}\n")
|
| 334 |
break
|
| 335 |
-
|
| 336 |
except Exception as e:
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
return f"Sorry, I ran into an error: {str(e)[:200]}"
|
| 343 |
-
|
| 344 |
-
# Store in Pinecone
|
| 345 |
if all_results:
|
| 346 |
-
|
| 347 |
-
last_tool_args_dict = tool_calls_parsed[-1].get("arguments", {}) if tool_calls_parsed else {}
|
| 348 |
-
store(user_input, final_reply, last_tool_name, last_tool_args_dict, last_tool_res)
|
| 349 |
else:
|
| 350 |
store(user_input, final_reply, "none", {}, {})
|
| 351 |
|
| 352 |
-
# Save
|
| 353 |
user_sessions[chat_id]["messages"].append({"role": "user", "content": user_input})
|
| 354 |
user_sessions[chat_id]["messages"].append({"role": "assistant", "content": final_reply})
|
| 355 |
if len(user_sessions[chat_id]["messages"]) > 20:
|
|
@@ -359,121 +255,87 @@ RULES:
|
|
| 359 |
|
| 360 |
|
| 361 |
# ββ Telegram handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
f"Hey {name}! π\n\
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
bot.
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
@bot.message_handler(func=lambda m: True)
|
| 396 |
-
def handle_message(message):
|
| 397 |
-
"""Handle all other messages β run the agent."""
|
| 398 |
-
user_input = message.text.strip()
|
| 399 |
-
chat_id = message.chat.id
|
| 400 |
-
username = message.from_user.first_name or message.from_user.username or "User"
|
| 401 |
-
|
| 402 |
-
if not user_input:
|
| 403 |
-
return
|
| 404 |
-
|
| 405 |
-
# Log user message to terminal for debugging
|
| 406 |
-
print(f"\n{'='*50}")
|
| 407 |
-
print(f" Telegram [{username}]: {user_input}")
|
| 408 |
-
print(f"{'='*50}\n")
|
| 409 |
-
|
| 410 |
-
# Show "typing" indicator
|
| 411 |
-
bot.send_chat_action(chat_id, "typing")
|
| 412 |
-
|
| 413 |
-
# Schedule the async agent turn on the event loop
|
| 414 |
-
future = asyncio.run_coroutine_threadsafe(
|
| 415 |
-
agent_turn_async(mcp_session_ref, openai_tools_ref, user_input, chat_id),
|
| 416 |
-
loop_ref,
|
| 417 |
-
)
|
| 418 |
-
reply = future.result(timeout=120) # wait up to 2 minutes
|
| 419 |
-
|
| 420 |
-
# Send reply (split if too long for Telegram's 4096 char limit)
|
| 421 |
-
chunks = [reply[i:i+4000] for i in range(0, len(reply), 4000)] if reply else ["..."]
|
| 422 |
-
for chunk in chunks:
|
| 423 |
-
bot.send_message(chat_id, chunk)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
# ββ main entry: start MCP + Telegram βββββββββββββββββββββββββββββββββββββββββ
|
| 427 |
-
mcp_session_ref = None
|
| 428 |
-
openai_tools_ref = None
|
| 429 |
-
|
| 430 |
-
async def main_async():
|
| 431 |
global mcp_session_ref, openai_tools_ref, loop_ref
|
| 432 |
loop_ref = asyncio.get_event_loop()
|
| 433 |
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
)
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
async with stdio_client(server_params) as (read, write):
|
| 441 |
async with ClientSession(read, write) as session:
|
| 442 |
await session.initialize()
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
mcp_tools = tools_response.tools
|
| 446 |
openai_tools = mcp_tools_to_openai_schema(mcp_tools)
|
| 447 |
|
| 448 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
mcp_session_ref = session
|
| 450 |
openai_tools_ref = openai_tools
|
| 451 |
|
| 452 |
stats = pine_index.describe_index_stats()
|
| 453 |
-
total = stats.get("total_vector_count", 0)
|
| 454 |
-
|
| 455 |
print("=" * 60)
|
| 456 |
-
print("
|
| 457 |
-
print(f"
|
| 458 |
-
print(f" Memory | {total} interactions in Pinecone")
|
| 459 |
print("=" * 60 + "\n")
|
| 460 |
|
| 461 |
-
# Start Telegram bot in a background thread
|
| 462 |
def run_telegram():
|
| 463 |
-
print(" Telegram bot
|
| 464 |
bot.infinity_polling()
|
| 465 |
|
| 466 |
-
|
| 467 |
-
|
| 468 |
|
| 469 |
-
# Keep the asyncio loop alive
|
| 470 |
try:
|
| 471 |
while True:
|
| 472 |
await asyncio.sleep(1)
|
| 473 |
except (KeyboardInterrupt, SystemExit):
|
| 474 |
-
print("\nShutting down...")
|
| 475 |
bot.stop_polling()
|
| 476 |
|
| 477 |
|
|
|
|
| 478 |
if __name__ == "__main__":
|
| 479 |
-
asyncio.run(
|
|
|
|
| 8 |
Pinecone β stores every turn + tool call + tool result
|
| 9 |
Telegram (pyTelegramBotAPI) β chat interface
|
| 10 |
|
| 11 |
+
Can be run standalone:
|
| 12 |
+
python telegram_agent.py
|
| 13 |
+
|
| 14 |
+
Or imported by main.py orchestrator:
|
| 15 |
+
from telegram_agent import start_telegram_with_mcp
|
| 16 |
+
asyncio.run(start_telegram_with_mcp())
|
| 17 |
"""
|
| 18 |
|
| 19 |
import os
|
| 20 |
import sys
|
| 21 |
import json
|
| 22 |
import uuid
|
|
|
|
| 23 |
import asyncio
|
| 24 |
import datetime
|
| 25 |
import threading
|
| 26 |
+
from typing import Optional
|
| 27 |
from llama_cpp import Llama
|
| 28 |
from sentence_transformers import SentenceTransformer
|
| 29 |
from pinecone import Pinecone, ServerlessSpec
|
|
|
|
| 38 |
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN")
|
| 39 |
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY")
|
| 40 |
PINECONE_INDEX = os.environ.get("PINECONE_INDEX", "jira-agent-memory")
|
|
|
|
| 41 |
MCP_SERVER_SCRIPT = os.path.join(os.path.dirname(__file__), "jira_mcp_server.py")
|
|
|
|
| 42 |
EMBED_DIM = 768
|
|
|
|
| 43 |
MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf")
|
| 44 |
|
| 45 |
+
# ββ Globals (lazy-initialized) ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
bot: Optional[telebot.TeleBot] = None
|
| 47 |
+
llm: Optional[Llama] = None
|
| 48 |
+
pine_index = None
|
| 49 |
+
_embedder = None
|
| 50 |
+
user_sessions = {}
|
| 51 |
+
loop_ref = None
|
| 52 |
+
mcp_session_ref = None
|
| 53 |
+
openai_tools_ref = None
|
| 54 |
+
|
| 55 |
|
| 56 |
+
# ββ Lazy initializers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
def _init_telegram_bot():
|
| 58 |
+
global bot
|
| 59 |
+
if bot is not None:
|
| 60 |
+
return bot
|
| 61 |
+
if not TELEGRAM_TOKEN:
|
| 62 |
+
raise ValueError("Missing TELEGRAM_TOKEN")
|
| 63 |
+
bot = telebot.TeleBot(TELEGRAM_TOKEN, parse_mode=None)
|
| 64 |
+
return bot
|
| 65 |
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
+
def _init_embedder():
|
| 68 |
+
global _embedder
|
| 69 |
+
if _embedder is not None:
|
| 70 |
+
return _embedder
|
| 71 |
+
print(" Loading embedding model...")
|
| 72 |
+
_embedder = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cpu")
|
| 73 |
+
print(" Embedding model ready.\n")
|
| 74 |
+
return _embedder
|
| 75 |
+
|
| 76 |
|
| 77 |
def embed(text: str) -> list[float]:
|
| 78 |
vec = _embedder.encode(text[:8000], normalize_embeddings=True)
|
| 79 |
return vec.tolist()
|
| 80 |
|
| 81 |
+
|
| 82 |
+
def _init_llm() -> Llama:
|
| 83 |
+
global llm
|
| 84 |
+
if llm is not None:
|
| 85 |
+
return llm
|
| 86 |
+
|
| 87 |
+
# Download to HF cache (does NOT count against HF Spaces 1GB app storage)
|
| 88 |
+
# The cache lives at ~/.cache/huggingface which is on a separate volume
|
| 89 |
from huggingface_hub import hf_hub_download
|
| 90 |
+
print(" Resolving model from HuggingFace cache...\n")
|
| 91 |
+
cached_path = hf_hub_download(
|
| 92 |
repo_id="bartowski/Phi-4-mini-instruct-GGUF",
|
| 93 |
filename="Phi-4-mini-instruct-Q4_K_M.gguf",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
)
|
| 95 |
+
print(f" Model cached at: {cached_path}")
|
| 96 |
+
print(f" Loading model...\n")
|
|
|
|
| 97 |
|
| 98 |
+
llm = Llama(
|
| 99 |
+
model_path=cached_path, n_ctx=8192, n_threads=4, n_batch=512,
|
| 100 |
+
n_gpu_layers=0, verbose=False, temperature=0.2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
)
|
| 102 |
+
print(" Model loaded.\n")
|
| 103 |
+
return llm
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _init_pinecone():
|
| 107 |
+
global pine_index
|
| 108 |
+
if pine_index is not None:
|
| 109 |
+
return pine_index
|
| 110 |
+
if not PINECONE_API_KEY:
|
| 111 |
+
raise ValueError("Missing PINECONE_API_KEY")
|
| 112 |
+
pc = Pinecone(api_key=PINECONE_API_KEY)
|
| 113 |
+
existing = [i.name for i in pc.list_indexes()]
|
| 114 |
+
if PINECONE_INDEX not in existing:
|
| 115 |
+
print(f" Creating Pinecone index '{PINECONE_INDEX}'...")
|
| 116 |
+
pc.create_index(name=PINECONE_INDEX, dimension=EMBED_DIM, metric="cosine",
|
| 117 |
+
spec=ServerlessSpec(cloud="aws", region="us-east-1"))
|
| 118 |
+
print(" Index created.\n")
|
| 119 |
+
pine_index = pc.Index(PINECONE_INDEX)
|
| 120 |
+
return pine_index
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def store(user_input, agent_reply, tool_name, tool_args, tool_result):
|
| 124 |
+
doc = f"User: {user_input}\nAgent: {agent_reply}\nTool: {tool_name}\nArgs: {json.dumps(tool_args)}\nResult: {json.dumps(tool_result)}"
|
| 125 |
pine_index.upsert(vectors=[{
|
| 126 |
+
"id": str(uuid.uuid4()), "values": embed(doc),
|
| 127 |
+
"metadata": {"doc": doc, "user_input": user_input, "agent_reply": agent_reply,
|
| 128 |
+
"tool_name": tool_name, "tool_args": json.dumps(tool_args),
|
| 129 |
+
"tool_result": json.dumps(tool_result),
|
| 130 |
+
"timestamp": datetime.datetime.utcnow().isoformat()},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
}])
|
| 132 |
|
| 133 |
|
|
|
|
| 136 |
total = stats.get("total_vector_count", 0)
|
| 137 |
if total == 0:
|
| 138 |
return ""
|
| 139 |
+
results = pine_index.query(vector=embed(user_input), top_k=min(top_k, total), include_metadata=True)
|
| 140 |
+
chunks = [f"[past]\n{m['metadata'].get('user_input','')} β {m['metadata'].get('tool_name','')}"
|
| 141 |
+
for m in results.get("matches", []) if m.get('score', 0) > 0.5]
|
| 142 |
+
return "\nRECENT SIMILAR (context only):\n" + "\n---\n".join(chunks) + "\n" if chunks else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
+
def mcp_tools_to_openai_schema(mcp_tools):
|
| 146 |
+
return [{"type": "function", "function": {"name": t.name, "description": t.description or "",
|
| 147 |
+
"parameters": t.inputSchema or {"type": "object", "properties": {}}}} for t in mcp_tools]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
+
# ββ Agent turn ββββββββββββββββββββββββββββββββββββοΏ½οΏ½βββββββββββββββββββββββββββ
|
| 151 |
async def agent_turn_async(session, openai_tools, user_input: str, chat_id: int) -> str:
|
|
|
|
|
|
|
| 152 |
memory_block = recall(user_input)
|
|
|
|
| 153 |
tool_descriptions = ""
|
| 154 |
for t in openai_tools:
|
| 155 |
+
p = t["function"].get("parameters", {})
|
| 156 |
+
req = p.get("required", [])
|
| 157 |
+
props = p.get("properties", {})
|
| 158 |
+
ps = ", ".join(f"{k}" + (" (required)" if k in req else "") for k in props) if props else "none"
|
| 159 |
+
tool_descriptions += f"- {t['function']['name']}: {t['function']['description']} | {ps}\n"
|
| 160 |
+
tool_descriptions += "\nIMPORTANT: For add_issues_to_sprint, sprint_id must be a NUMBER, NOT 'current'.\n"
|
| 161 |
+
|
| 162 |
+
system_prompt = (
|
| 163 |
+
f"You are an intelligent Jira Sprint Management AI Agent.\n\n"
|
| 164 |
+
f"Available tools:\n{tool_descriptions}\n"
|
| 165 |
+
f"To call a tool, respond with ONLY JSON: {{\"tool\": \"<name>\", \"arguments\": {{...}}}}\n\n"
|
| 166 |
+
f"RULES:\n1. Call tools for Jira data queries.\n2. Don't make up data.\n"
|
| 167 |
+
f"3. Normal replies for greetings.\n4. Summarize tool results concisely.\n\n{memory_block}"
|
| 168 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
+
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}]
|
| 171 |
+
user_sessions.setdefault(chat_id, {"messages": []})
|
| 172 |
+
clean = [m for m in user_sessions[chat_id]["messages"] if m["role"] in ("user", "assistant")]
|
| 173 |
+
messages.extend(clean[-6:])
|
| 174 |
|
| 175 |
final_reply = ""
|
|
|
|
| 176 |
all_results = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
tool_calls_parsed = []
|
|
|
|
| 178 |
|
| 179 |
+
for _ in range(5):
|
|
|
|
| 180 |
try:
|
| 181 |
+
response = llm.create_chat_completion(messages=messages, max_tokens=1024, temperature=0.2, top_p=0.9)
|
| 182 |
+
response_text = response['choices'][0]['message'].get("content", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if "<think>" in response_text and "</think>" in response_text:
|
| 184 |
+
response_text = response_text[response_text.rfind("</think>") + 8:].strip()
|
| 185 |
|
| 186 |
+
# Parse JSON tool calls
|
| 187 |
tool_calls_parsed = []
|
| 188 |
remaining = response_text
|
| 189 |
while True:
|
| 190 |
+
j = remaining.find("{")
|
| 191 |
+
if j < 0:
|
| 192 |
break
|
| 193 |
+
remaining = remaining[j:]
|
| 194 |
+
depth, end = 0, -1
|
|
|
|
| 195 |
for i, ch in enumerate(remaining):
|
| 196 |
+
if ch == "{": depth += 1
|
|
|
|
| 197 |
elif ch == "}":
|
| 198 |
depth -= 1
|
| 199 |
if depth == 0:
|
| 200 |
+
end = i + 1
|
| 201 |
break
|
| 202 |
+
if end < 0:
|
| 203 |
break
|
| 204 |
try:
|
| 205 |
+
parsed = json.loads(remaining[:end])
|
| 206 |
if "tool" in parsed:
|
| 207 |
tool_calls_parsed.append(parsed)
|
| 208 |
except json.JSONDecodeError:
|
| 209 |
pass
|
| 210 |
+
remaining = remaining[end:]
|
| 211 |
|
|
|
|
| 212 |
if tool_calls_parsed:
|
| 213 |
all_results = []
|
| 214 |
for tc in tool_calls_parsed:
|
| 215 |
+
tname, targs = tc.get("tool", "unknown"), tc.get("arguments", {})
|
| 216 |
+
print(f"\n β MCP: {tname}({json.dumps(targs)})")
|
| 217 |
+
result = await session.call_tool(tname, arguments=targs)
|
| 218 |
+
raw = result.content[0].text if result.content and hasattr(result.content[0], "text") else str(result.content[0]) if result.content else "{}"
|
| 219 |
+
try:
|
| 220 |
+
tresult = json.loads(raw)
|
| 221 |
+
except json.JSONDecodeError:
|
| 222 |
+
tresult = {"raw": raw}
|
| 223 |
+
print(f" β {json.dumps(tresult, indent=2)}\n")
|
| 224 |
+
all_results.append((tname, tresult))
|
| 225 |
+
|
| 226 |
+
messages.append({"role": "assistant", "content": response_text})
|
| 227 |
+
parts = [f"Tool {tn} returned: {json.dumps(tr)}" for tn, tr in all_results]
|
| 228 |
+
sprint_err = any("sprint_id" in json.dumps(tr).lower() and ("integer" in json.dumps(tr).lower() or "int_parsing" in json.dumps(tr).lower()) for _, tr in all_results)
|
| 229 |
+
if sprint_err:
|
| 230 |
+
messages.append({"role": "user", "content": f"{' | '.join(parts)}\n\nsprint_id must be a number. Call get_active_sprint first."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
else:
|
| 232 |
+
messages.append({"role": "user", "content": f"{' | '.join(parts)}\n\nSummarize concisely."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
else:
|
| 234 |
final_reply = response_text
|
| 235 |
print(f"\n Agent: {final_reply}\n")
|
| 236 |
break
|
|
|
|
| 237 |
except Exception as e:
|
| 238 |
+
if "out of memory" in str(e).lower():
|
| 239 |
+
return "Sorry, memory error. Try again."
|
| 240 |
+
return f"Error: {str(e)[:200]}"
|
| 241 |
+
|
| 242 |
+
# Pinecone store
|
|
|
|
|
|
|
|
|
|
| 243 |
if all_results:
|
| 244 |
+
store(user_input, final_reply, all_results[-1][0], tool_calls_parsed[-1].get("arguments", {}), all_results[-1][1])
|
|
|
|
|
|
|
| 245 |
else:
|
| 246 |
store(user_input, final_reply, "none", {}, {})
|
| 247 |
|
| 248 |
+
# Save history
|
| 249 |
user_sessions[chat_id]["messages"].append({"role": "user", "content": user_input})
|
| 250 |
user_sessions[chat_id]["messages"].append({"role": "assistant", "content": final_reply})
|
| 251 |
if len(user_sessions[chat_id]["messages"]) > 20:
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
# ββ Telegram handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 258 |
+
def _register_handlers():
|
| 259 |
+
@bot.message_handler(commands=['start'])
|
| 260 |
+
def cmd_start(message):
|
| 261 |
+
name = message.from_user.first_name or "there"
|
| 262 |
+
bot.reply_to(message, f"Hey {name}! π\n\nI'm your Jira Sprint Manager. Ask me anything about sprints, backlog, or issues!")
|
| 263 |
+
|
| 264 |
+
@bot.message_handler(commands=['memory'])
|
| 265 |
+
def cmd_memory(message):
|
| 266 |
+
parts = message.text.split(None, 1)
|
| 267 |
+
if len(parts) < 2:
|
| 268 |
+
bot.reply_to(message, "Usage: /memory <query>")
|
| 269 |
+
return
|
| 270 |
+
result = recall(parts[1], top_k=5)
|
| 271 |
+
bot.reply_to(message, result[:4000] if result else "Nothing found.")
|
| 272 |
+
|
| 273 |
+
@bot.message_handler(func=lambda m: True)
|
| 274 |
+
def handle_message(message):
|
| 275 |
+
user_input = message.text.strip()
|
| 276 |
+
if not user_input:
|
| 277 |
+
return
|
| 278 |
+
print(f"\n{'='*50}\n Telegram [{message.from_user.first_name}]: {user_input}\n{'='*50}\n")
|
| 279 |
+
bot.send_chat_action(message.chat.id, "typing")
|
| 280 |
+
future = asyncio.run_coroutine_threadsafe(
|
| 281 |
+
agent_turn_async(mcp_session_ref, openai_tools_ref, user_input, message.chat.id), loop_ref)
|
| 282 |
+
reply = future.result(timeout=120)
|
| 283 |
+
for chunk in [reply[i:i+4000] for i in range(0, len(reply), 4000)] if reply else ["..."]:
|
| 284 |
+
bot.send_message(message.chat.id, chunk)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ββ Main entry: start MCP + Telegram (callable by orchestrator) βββββββββββββββ
|
| 288 |
+
async def start_telegram_with_mcp():
|
| 289 |
+
"""Initialize all components and start the Telegram bot with MCP."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
global mcp_session_ref, openai_tools_ref, loop_ref
|
| 291 |
loop_ref = asyncio.get_event_loop()
|
| 292 |
|
| 293 |
+
# Lazy init all components
|
| 294 |
+
_init_telegram_bot()
|
| 295 |
+
_init_embedder()
|
| 296 |
+
_init_llm()
|
| 297 |
+
_init_pinecone()
|
| 298 |
+
_register_handlers()
|
| 299 |
+
|
| 300 |
+
server_params = StdioServerParameters(command=sys.executable, args=[MCP_SERVER_SCRIPT], env={**os.environ})
|
| 301 |
|
| 302 |
async with stdio_client(server_params) as (read, write):
|
| 303 |
async with ClientSession(read, write) as session:
|
| 304 |
await session.initialize()
|
| 305 |
+
tools_resp = await session.list_tools()
|
| 306 |
+
mcp_tools = tools_resp.tools
|
|
|
|
| 307 |
openai_tools = mcp_tools_to_openai_schema(mcp_tools)
|
| 308 |
|
| 309 |
+
# Update refs for status checking
|
| 310 |
+
import shared_state
|
| 311 |
+
shared_state.mcp_session = session
|
| 312 |
+
shared_state.openai_tools = openai_tools
|
| 313 |
+
shared_state.telegram_bot = bot
|
| 314 |
+
shared_state.llm_instance = llm
|
| 315 |
mcp_session_ref = session
|
| 316 |
openai_tools_ref = openai_tools
|
| 317 |
|
| 318 |
stats = pine_index.describe_index_stats()
|
|
|
|
|
|
|
| 319 |
print("=" * 60)
|
| 320 |
+
print(f" Telegram + MCP | Tools: {[t.name for t in mcp_tools]}")
|
| 321 |
+
print(f" Memory: {stats.get('total_vector_count', 0)} vectors")
|
|
|
|
| 322 |
print("=" * 60 + "\n")
|
| 323 |
|
|
|
|
| 324 |
def run_telegram():
|
| 325 |
+
print(" Telegram bot running.\n")
|
| 326 |
bot.infinity_polling()
|
| 327 |
|
| 328 |
+
t = threading.Thread(target=run_telegram, daemon=True)
|
| 329 |
+
t.start()
|
| 330 |
|
|
|
|
| 331 |
try:
|
| 332 |
while True:
|
| 333 |
await asyncio.sleep(1)
|
| 334 |
except (KeyboardInterrupt, SystemExit):
|
| 335 |
+
print("\nShutting down Telegram...")
|
| 336 |
bot.stop_polling()
|
| 337 |
|
| 338 |
|
| 339 |
+
# ββ Standalone run ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 340 |
if __name__ == "__main__":
|
| 341 |
+
asyncio.run(start_telegram_with_mcp())
|