#!/usr/bin/env python3 """ Egregore Web Service - HTTP frontend Serves the chat UI and proxies requests to reason and recall services. Runs on port 8080. """ import os import secrets import uuid import tempfile from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Query from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBasic, HTTPBasicCredentials from pydantic import BaseModel from dotenv import load_dotenv import httpx import openai import aiofiles # Load environment load_dotenv("/home/admin/.env") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") CHAT_USERNAME = os.getenv("CHAT_USERNAME", "admin") CHAT_PASSWORD = os.getenv("CHAT_PASSWORD", "changeme") # Service URLs REASON_URL = os.getenv("REASON_URL", "http://127.0.0.1:8081") RECALL_URL = os.getenv("RECALL_URL", "http://127.0.0.1:8082") app = FastAPI(title="Egregore", docs_url=None, redoc_url=None) security = HTTPBasic() # Static files path STATIC_PATH = "/home/admin/services/web/static" # HTTP client for internal services http_client = httpx.AsyncClient(timeout=120.0) # Auth dependency def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)): correct_username = secrets.compare_digest(credentials.username, CHAT_USERNAME) correct_password = secrets.compare_digest(credentials.password, CHAT_PASSWORD) if not (correct_username and correct_password): raise HTTPException( status_code=401, detail="Invalid credentials", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username # Models class ChatMessage(BaseModel): message: str model: str = "claude-sonnet-4-20250514" # Routes @app.get("/", response_class=HTMLResponse) async def root(username: str = Depends(verify_credentials)): """Serve the chat interface""" async with aiofiles.open(f"{STATIC_PATH}/index.html") as f: return await f.read() @app.post("/api/chat") async def chat(msg: ChatMessage, username: str = Depends(verify_credentials)): """Send a message and get a response""" group_id = str(uuid.uuid4()) # Save user message to db service await http_client.post(f"{RECALL_URL}/messages", json={ "role": "user", "content": msg.message, "msg_type": "text", "group_id": group_id }) # Get conversation history from db service history_resp = await http_client.get(f"{RECALL_URL}/messages/history") history_data = history_resp.json() history = history_data.get("history", []) # Process conversation with brain service try: reason_resp = await http_client.post(f"{REASON_URL}/process", json={ "model": msg.model, "history": history, "max_iterations": 10 }) if reason_resp.status_code != 200: error_detail = reason_resp.json().get("detail", "Reason service error") raise HTTPException(status_code=500, detail=error_detail) reason_data = reason_resp.json() response_blocks = reason_data.get("blocks", []) except httpx.RequestError as e: raise HTTPException(status_code=503, detail=f"Reason service unavailable: {e}") # Save response blocks to db service save_resp = await http_client.post(f"{RECALL_URL}/messages/blocks", json={ "blocks": response_blocks, "group_id": group_id }) save_data = save_resp.json() saved_messages = save_data.get("messages", []) # Calculate max priority for notification decision max_priority = max((m["priority"] for m in saved_messages), default=0) # Extract final text for notifications/TTS final_text = " ".join( m["content"] for m in saved_messages if m["type"] == "text" and m["content"] ) return { "messages": saved_messages, "max_priority": max_priority, "text": final_text, "group_id": group_id } @app.get("/api/history") async def history( before: int = None, limit: int = 50, msg_type: str = Query(None, alias="type"), username: str = Depends(verify_credentials) ): """Get chat history with pagination""" params = {"limit": min(limit, 100)} if before: params["before"] = before if msg_type: params["type"] = msg_type resp = await http_client.get(f"{RECALL_URL}/messages", params=params) return resp.json() @app.post("/api/transcribe") async def transcribe_audio( audio: UploadFile = File(...), username: str = Depends(verify_credentials) ): """Transcribe audio using OpenAI Whisper API""" if not OPENAI_API_KEY: raise HTTPException(status_code=500, detail="OpenAI API key not configured") try: audio_data = await audio.read() openai_client = openai.OpenAI(api_key=OPENAI_API_KEY) with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp: tmp.write(audio_data) tmp_path = tmp.name try: with open(tmp_path, "rb") as audio_file: transcript = openai_client.audio.transcriptions.create( model="whisper-1", file=audio_file, language="en", response_format="text" ) return {"text": transcript.strip(), "success": True} finally: os.unlink(tmp_path) except openai.APIError as e: raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}") @app.get("/api/search") async def search( q: str, limit: int = 20, msg_type: str = Query(None, alias="type"), username: str = Depends(verify_credentials) ): """Search messages by content""" params = {"q": q, "limit": limit} if msg_type: params["type"] = msg_type resp = await http_client.get(f"{RECALL_URL}/messages/search", params=params) return resp.json() @app.get("/health") async def health(): """Health check with dependency status""" reason_ok = False recall_ok = False try: resp = await http_client.get(f"{REASON_URL}/health", timeout=2.0) reason_ok = resp.status_code == 200 except: pass try: resp = await http_client.get(f"{RECALL_URL}/health", timeout=2.0) recall_ok = resp.status_code == 200 except: pass return { "status": "ok" if (reason_ok and recall_ok) else "degraded", "service": "web", "dependencies": { "reason": "ok" if reason_ok else "unavailable", "recall": "ok" if recall_ok else "unavailable" } } # Mount static files app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8080)