diff --git a/Dockerfile b/Dockerfile index 6b8f6e7..af7d8f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/agent.py b/agent.py index dd30c32..a90332f 100644 --- a/agent.py +++ b/agent.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5f20dbe..a17ade0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/fast_tools.py b/fast_tools.py index fb951d1..ef3991c 100644 --- a/fast_tools.py +++ b/fast_tools.py @@ -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. diff --git a/routecheck/Dockerfile b/routecheck/Dockerfile new file mode 100644 index 0000000..1f66f49 --- /dev/null +++ b/routecheck/Dockerfile @@ -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"] diff --git a/routecheck/app.py b/routecheck/app.py new file mode 100644 index 0000000..c088e10 --- /dev/null +++ b/routecheck/app.py @@ -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 = """ + + + + +RouteCheck + + + +
+

RouteCheck

+

Real-time driving time with Yandex traffic data

+ + +
+ +
+ captcha + +
+ ↻ New challenge +
Wrong answer, try again.
+ +
+ + +
+ + + + + +
+
+
+
with current traffic
+
+
+
without traffic
+
+
distance
+
+
+
+
+ + + + +"""