converse/main.py

235 lines
6.7 KiB
Python
Raw Normal View History

#!/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)