Update internal service URLs and variable names to match the renamed services throughout the codebase. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
234 lines
6.7 KiB
Python
234 lines
6.7 KiB
Python
#!/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)
|