Add routecheck service and CommuteTool fast tool

routecheck/ — FastAPI service (port 8090):
  - Image captcha (PIL: arithmetic problem, noise, wave distortion)
  - POST /api/captcha/new + /api/captcha/solve → short-lived token
  - GET /api/route?from=lat,lon&to=lat,lon&token=... → Yandex Routing API
  - Internal bypass via INTERNAL_TOKEN env var (for CommuteTool)
  - HTTPS proxy forwarded to reach Yandex API from container

CommuteTool (fast_tools.py):
  - Matches commute/traffic/arrival time queries
  - Calls routecheck /api/route with ROUTECHECK_TOKEN
  - Hardcoded route: Balashikha home → Moscow center
  - Returns traffic-adjusted travel time + delay annotation

Needs: YANDEX_ROUTING_KEY + ROUTECHECK_TOKEN in .env

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvis
2026-03-13 07:08:48 +00:00
parent d2ca1926f8
commit 32089ed596
6 changed files with 466 additions and 2 deletions

View File

@@ -5,6 +5,6 @@ WORKDIR /app
RUN pip install --no-cache-dir deepagents langchain-openai langgraph \
fastapi uvicorn langchain-mcp-adapters langchain-community httpx
COPY agent.py channels.py vram_manager.py router.py agent_factory.py fast_tools.py hello_world.py .
COPY agent.py channels.py vram_manager.py router.py agent_factory.py fast_tools.py hello_world.py ./
CMD ["uvicorn", "agent:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -24,7 +24,7 @@ from langchain_core.tools import Tool
from vram_manager import VRAMManager
from router import Router
from agent_factory import build_medium_agent, build_complex_agent
from fast_tools import FastToolRunner, WeatherTool
from fast_tools import FastToolRunner, WeatherTool, CommuteTool
import channels
# Bifrost gateway — all LLM inference goes through here
@@ -38,6 +38,8 @@ COMPLEX_MODEL = os.getenv("DEEPAGENTS_COMPLEX_MODEL", "qwen3:8b")
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://host.docker.internal:11437")
OPENMEMORY_URL = os.getenv("OPENMEMORY_URL", "http://openmemory:8765")
CRAWL4AI_URL = os.getenv("CRAWL4AI_URL", "http://crawl4ai:11235")
ROUTECHECK_URL = os.getenv("ROUTECHECK_URL", "http://routecheck:8090")
ROUTECHECK_TOKEN = os.getenv("ROUTECHECK_TOKEN", "")
MAX_HISTORY_TURNS = 5
_conversation_buffers: dict[str, list] = {}
@@ -122,6 +124,7 @@ _memory_search_tool = None
# Fast tools run before the LLM — classifier + context enricher
_fast_tool_runner = FastToolRunner([
WeatherTool(searxng_url=SEARXNG_URL),
CommuteTool(routecheck_url=ROUTECHECK_URL, internal_token=ROUTECHECK_TOKEN),
])
# GPU mutex: one LLM inference at a time

View File

@@ -31,6 +31,8 @@ services:
- SEARXNG_URL=http://host.docker.internal:11437
- GRAMMY_URL=http://grammy:3001
- CRAWL4AI_URL=http://crawl4ai:11235
- ROUTECHECK_URL=http://routecheck:8090
- ROUTECHECK_TOKEN=${ROUTECHECK_TOKEN}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
@@ -38,6 +40,7 @@ services:
- grammy
- crawl4ai
- bifrost
- routecheck
restart: unless-stopped
openmemory:
@@ -79,6 +82,19 @@ services:
profiles:
- tools
routecheck:
build: ./routecheck
container_name: routecheck
ports:
- "8090:8090"
environment:
- YANDEX_ROUTING_KEY=${YANDEX_ROUTING_KEY}
- INTERNAL_TOKEN=${ROUTECHECK_TOKEN}
- HTTPS_PROXY=http://host.docker.internal:56928
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
crawl4ai:
image: unclecode/crawl4ai:latest
container_name: crawl4ai

View File

@@ -92,6 +92,68 @@ class WeatherTool(FastTool):
return "\n".join(lines) if len(lines) > 1 else ""
class CommuteTool(FastTool):
"""
Returns real-time driving time from home (Balashikha) to a destination
using Yandex traffic data via the local routecheck service.
Triggered by queries about commute time, arrival, or road traffic.
The routecheck service handles Yandex API auth and the HTTPS proxy.
"""
_PATTERN = re.compile(
r"\b(commute|arrival time|how long.{0,20}(drive|get|travel|reach)"
r"|сколько.{0,20}(ехать|добираться|минут)"
r"|пробки|traffic|road.{0,10}now|drive to (work|office|center|москва|moscow)"
r"|when (will i|do i) (arrive|get there|reach))\b",
re.IGNORECASE,
)
# Home: Balashikha. Default destination: Moscow city center.
_HOME = "55.7963,37.9382"
_DEST = "55.7558,37.6173"
def __init__(self, routecheck_url: str, internal_token: str):
self._url = routecheck_url.rstrip("/")
self._token = internal_token
@property
def name(self) -> str:
return "commute"
def matches(self, message: str) -> bool:
return bool(self._PATTERN.search(message))
async def run(self, message: str) -> str:
if not self._token:
return "[commute: ROUTECHECK_TOKEN not configured]"
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(
f"{self._url}/api/route",
params={"from": self._HOME, "to": self._DEST, "token": self._token},
)
r.raise_for_status()
d = r.json()
except Exception as e:
return f"[commute error: {e}]"
traffic = d["duration_traffic_min"]
normal = d["duration_min"]
dist = d["distance_km"]
delay = traffic - normal
lines = [
f"Current drive time from Balashikha to Moscow center:",
f" With traffic: {traffic} min",
f" Without traffic: {normal} min",
f" Distance: {dist} km",
]
if delay > 5:
lines.append(f" Traffic delay: +{delay} min")
return "\n".join(lines)
class FastToolRunner:
"""
Classifier + executor for fast tools.

6
routecheck/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir fastapi uvicorn pillow httpx
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]

377
routecheck/app.py Normal file
View File

@@ -0,0 +1,377 @@
"""
RouteCheck — local routing web service with image captcha.
Endpoints:
GET / — web UI
GET /captcha/image/{id} — PNG captcha image
POST /api/captcha/new — create captcha, return {id}
POST /api/captcha/solve — {id, answer} → {token} or 400
GET /api/route — ?from=lat,lon&to=lat,lon&token=...
token = solved captcha token OR INTERNAL_TOKEN env var
"""
import io
import math
import os
import random
import string
import time
import uuid
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from pydantic import BaseModel
app = FastAPI(title="RouteCheck")
# ── Config ─────────────────────────────────────────────────────────────────────
YANDEX_KEY = os.getenv("YANDEX_ROUTING_KEY", "")
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
HTTPS_PROXY = os.getenv("HTTPS_PROXY", "")
CAPTCHA_TTL = 300 # seconds a captcha is valid
TOKEN_TTL = 3600 # seconds a solved token is valid
# ── In-memory captcha store ────────────────────────────────────────────────────
_captchas: dict[str, dict] = {} # id → {answer, token, expires}
_tokens: dict[str, float] = {} # token → expires
def _purge():
now = time.time()
for k in list(_captchas.keys()):
if _captchas[k]["expires"] < now:
del _captchas[k]
for k in list(_tokens.keys()):
if _tokens[k] < now:
del _tokens[k]
# ── Captcha image generation ───────────────────────────────────────────────────
def _rand_color(dark=False):
if dark:
return tuple(random.randint(0, 100) for _ in range(3))
return tuple(random.randint(140, 255) for _ in range(3))
def _make_captcha_image(text: str) -> bytes:
W, H = 220, 80
img = Image.new("RGB", (W, H), color=_rand_color())
draw = ImageDraw.Draw(img)
# Background noise: random lines
for _ in range(8):
x1, y1 = random.randint(0, W), random.randint(0, H)
x2, y2 = random.randint(0, W), random.randint(0, H)
draw.line([(x1, y1), (x2, y2)], fill=_rand_color(dark=True), width=2)
# Background noise: random dots
for _ in range(300):
x, y = random.randint(0, W), random.randint(0, H)
draw.point((x, y), fill=_rand_color(dark=True))
# Draw each character with slight random offset and rotation
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
except Exception:
font = ImageFont.load_default()
char_w = W // (len(text) + 2)
for i, ch in enumerate(text):
x = char_w + i * char_w + random.randint(-4, 4)
y = (H - 40) // 2 + random.randint(-6, 6)
# Draw shadow
draw.text((x + 2, y + 2), ch, font=font, fill=_rand_color(dark=True))
draw.text((x, y), ch, font=font, fill=_rand_color(dark=True))
# Wavy distortion via pixel manipulation
pixels = img.load()
for x in range(W):
shift = int(4 * math.sin(x / 15.0))
col = [pixels[x, y] for y in range(H)]
for y in range(H):
pixels[x, y] = col[(y - shift) % H]
img = img.filter(ImageFilter.SMOOTH)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _generate_problem() -> tuple[str, int]:
"""Return (display_text, answer)."""
ops = [
lambda a, b: (f"{a} + {b} = ?", a + b),
lambda a, b: (f"{a} × {b} = ?", a * b),
lambda a, b: (f"{max(a,b)} {min(a,b)} = ?", max(a, b) - min(a, b)),
]
op = random.choice(ops)
a, b = random.randint(2, 9), random.randint(2, 9)
text, answer = op(a, b)
return text, answer
# ── Routes ─────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index():
return HTML_PAGE
@app.get("/captcha/image/{captcha_id}")
async def captcha_image(captcha_id: str):
_purge()
entry = _captchas.get(captcha_id)
if not entry:
raise HTTPException(404, "Captcha not found or expired")
png = _make_captcha_image(entry["problem"])
return StreamingResponse(io.BytesIO(png), media_type="image/png",
headers={"Cache-Control": "no-store"})
class CaptchaNewResponse(BaseModel):
id: str
@app.post("/api/captcha/new")
async def captcha_new():
_purge()
problem_text, answer = _generate_problem()
cid = str(uuid.uuid4())
_captchas[cid] = {
"problem": problem_text,
"answer": answer,
"expires": time.time() + CAPTCHA_TTL,
}
return {"id": cid}
class SolveRequest(BaseModel):
id: str
answer: int
@app.post("/api/captcha/solve")
async def captcha_solve(req: SolveRequest):
_purge()
entry = _captchas.get(req.id)
if not entry:
raise HTTPException(400, "Captcha expired or not found")
if entry["answer"] != req.answer:
raise HTTPException(400, "Wrong answer")
token = str(uuid.uuid4())
_tokens[token] = time.time() + TOKEN_TTL
del _captchas[req.id]
return {"token": token}
@app.get("/api/route")
async def route(
from_coords: str = Query(..., alias="from", description="lat,lon"),
to_coords: str = Query(..., alias="to", description="lat,lon"),
token: str = Query(...),
):
_purge()
# Auth: internal service token or valid captcha token
if token != INTERNAL_TOKEN:
if token not in _tokens:
raise HTTPException(401, "Invalid or expired token — solve captcha first")
if not YANDEX_KEY:
raise HTTPException(503, "YANDEX_ROUTING_KEY not configured")
# Parse coords
try:
from_lat, from_lon = map(float, from_coords.split(","))
to_lat, to_lon = map(float, to_coords.split(","))
except ValueError:
raise HTTPException(400, "coords must be lat,lon")
# Yandex Routing API expects lon,lat order
waypoints = f"{from_lon},{from_lat}|{to_lon},{to_lat}"
transport = httpx.AsyncHTTPTransport(proxy=HTTPS_PROXY) if HTTPS_PROXY else None
async with httpx.AsyncClient(timeout=15, transport=transport) as client:
try:
r = await client.get(
"https://api.routing.yandex.net/v2/route",
params={"apikey": YANDEX_KEY, "waypoints": waypoints, "mode": "driving"},
)
except Exception as e:
raise HTTPException(502, f"Yandex API unreachable: {e}")
if r.status_code != 200:
raise HTTPException(502, f"Yandex API error {r.status_code}: {r.text[:200]}")
data = r.json()
try:
leg = data["route"]["legs"][0]
duration_s = leg["duration"]
duration_traffic_s = leg.get("duration_in_traffic", duration_s)
distance_m = leg["distance"]
except (KeyError, IndexError) as e:
raise HTTPException(502, f"Unexpected Yandex response: {e}{str(data)[:200]}")
return {
"duration_min": round(duration_s / 60),
"duration_traffic_min": round(duration_traffic_s / 60),
"distance_km": round(distance_m / 1000, 1),
}
# ── HTML ───────────────────────────────────────────────────────────────────────
HTML_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RouteCheck</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh;
display: flex; align-items: center; justify-content: center; }
.card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,.5); }
h1 { font-size: 1.4rem; font-weight: 700; color: #38bdf8; margin-bottom: .3rem; }
.sub { color: #94a3b8; font-size: .85rem; margin-bottom: 1.5rem; }
label { display: block; font-size: .8rem; color: #94a3b8; margin-bottom: .3rem; margin-top: 1rem; }
input { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 6px;
color: #e2e8f0; padding: .55rem .75rem; font-size: .95rem; outline: none; }
input:focus { border-color: #38bdf8; }
button { width: 100%; margin-top: 1.2rem; padding: .7rem; background: #0ea5e9;
border: none; border-radius: 6px; color: #fff; font-size: 1rem;
font-weight: 600; cursor: pointer; transition: background .2s; }
button:hover { background: #0284c7; }
button:disabled { background: #334155; cursor: default; }
.captcha-row { display: flex; gap: .75rem; align-items: center; margin-top: 1rem; }
.captcha-row img { border-radius: 6px; border: 1px solid #334155; cursor: pointer; }
.captcha-row input { flex: 1; }
.result { margin-top: 1.2rem; background: #0f172a; border-radius: 8px; padding: 1rem;
border-left: 3px solid #38bdf8; display: none; }
.result .big { font-size: 1.6rem; font-weight: 700; color: #38bdf8; }
.result .label { font-size: .8rem; color: #64748b; margin-top: .2rem; }
.result .row { display: flex; gap: 1.5rem; margin-top: .8rem; }
.result .metric { flex: 1; }
.result .metric .val { font-size: 1.1rem; font-weight: 600; }
.error { color: #f87171; margin-top: .8rem; font-size: .85rem; display: none; }
.step { display: none; }
.step.active { display: block; }
a.refresh { font-size: .75rem; color: #38bdf8; text-decoration: none; display: block;
margin-top: .4rem; }
a.refresh:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<h1>RouteCheck</h1>
<p class="sub">Real-time driving time with Yandex traffic data</p>
<!-- Step 1: captcha -->
<div class="step active" id="step-captcha">
<label>Prove you are human</label>
<div class="captcha-row">
<img id="captcha-img" src="" alt="captcha" width="160" height="60"
title="Click to refresh" onclick="loadCaptcha()">
<input id="captcha-ans" type="number" placeholder="Answer" min="0" max="999">
</div>
<a class="refresh" href="#" onclick="loadCaptcha();return false;">↻ New challenge</a>
<div class="error" id="captcha-err">Wrong answer, try again.</div>
<button id="captcha-btn" onclick="solveCaptcha()">Verify →</button>
</div>
<!-- Step 2: route query -->
<div class="step" id="step-route">
<label>From (lat, lon)</label>
<input id="from" type="text" placeholder="55.7963, 37.9382" value="55.7963, 37.9382">
<label>To (lat, lon)</label>
<input id="to" type="text" placeholder="55.7558, 37.6173" value="55.7558, 37.6173">
<button id="route-btn" onclick="queryRoute()">Get travel time</button>
<div class="error" id="route-err"></div>
<div class="result" id="result">
<div class="big" id="res-traffic"></div>
<div class="label">with current traffic</div>
<div class="row">
<div class="metric"><div class="val" id="res-normal"></div>
<div class="label">without traffic</div></div>
<div class="metric"><div class="val" id="res-dist"></div>
<div class="label">distance</div></div>
</div>
</div>
</div>
</div>
<script>
let captchaId = null;
let routeToken = null;
async function loadCaptcha() {
const r = await fetch('/api/captcha/new', {method: 'POST'});
const d = await r.json();
captchaId = d.id;
document.getElementById('captcha-img').src = '/captcha/image/' + captchaId + '?t=' + Date.now();
document.getElementById('captcha-ans').value = '';
document.getElementById('captcha-err').style.display = 'none';
}
async function solveCaptcha() {
const ans = parseInt(document.getElementById('captcha-ans').value);
if (isNaN(ans)) return;
const btn = document.getElementById('captcha-btn');
btn.disabled = true;
const r = await fetch('/api/captcha/solve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: captchaId, answer: ans})
});
if (r.ok) {
const d = await r.json();
routeToken = d.token;
document.getElementById('step-captcha').classList.remove('active');
document.getElementById('step-route').classList.add('active');
} else {
document.getElementById('captcha-err').style.display = 'block';
loadCaptcha();
}
btn.disabled = false;
}
async function queryRoute() {
const from = document.getElementById('from').value.trim();
const to = document.getElementById('to').value.trim();
const btn = document.getElementById('route-btn');
const err = document.getElementById('route-err');
err.style.display = 'none';
document.getElementById('result').style.display = 'none';
btn.disabled = true;
btn.textContent = 'Fetching…';
const r = await fetch(`/api/route?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&token=${routeToken}`);
btn.disabled = false;
btn.textContent = 'Get travel time';
if (!r.ok) {
const d = await r.json();
err.textContent = d.detail || 'Error';
err.style.display = 'block';
return;
}
const d = await r.json();
document.getElementById('res-traffic').textContent = d.duration_traffic_min + ' min';
document.getElementById('res-normal').textContent = d.duration_min + ' min';
document.getElementById('res-dist').textContent = d.distance_km + ' km';
document.getElementById('result').style.display = 'block';
}
loadCaptcha();
document.getElementById('captcha-ans').addEventListener('keydown', e => {
if (e.key === 'Enter') solveCaptcha();
});
</script>
</body>
</html>
"""