Initial commit: Egregore web service

FastAPI HTTP server with auth, static files, and reverse proxy to brain/db services.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egregore 2026-02-02 11:37:41 +00:00
commit ff03fc7f43
9 changed files with 2244 additions and 0 deletions

234
main.py Normal file
View file

@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Egregore Web Service - HTTP frontend
Serves the chat UI and proxies requests to brain and db 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
BRAIN_URL = os.getenv("BRAIN_URL", "http://127.0.0.1:8081")
DB_URL = os.getenv("DB_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"{DB_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"{DB_URL}/messages/history")
history_data = history_resp.json()
history = history_data.get("history", [])
# Process conversation with brain service
try:
brain_resp = await http_client.post(f"{BRAIN_URL}/process", json={
"model": msg.model,
"history": history,
"max_iterations": 10
})
if brain_resp.status_code != 200:
error_detail = brain_resp.json().get("detail", "Brain service error")
raise HTTPException(status_code=500, detail=error_detail)
brain_data = brain_resp.json()
response_blocks = brain_data.get("blocks", [])
except httpx.RequestError as e:
raise HTTPException(status_code=503, detail=f"Brain service unavailable: {e}")
# Save response blocks to db service
save_resp = await http_client.post(f"{DB_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"{DB_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"{DB_URL}/messages/search", params=params)
return resp.json()
@app.get("/health")
async def health():
"""Health check with dependency status"""
brain_ok = False
db_ok = False
try:
resp = await http_client.get(f"{BRAIN_URL}/health", timeout=2.0)
brain_ok = resp.status_code == 200
except:
pass
try:
resp = await http_client.get(f"{DB_URL}/health", timeout=2.0)
db_ok = resp.status_code == 200
except:
pass
return {
"status": "ok" if (brain_ok and db_ok) else "degraded",
"service": "web",
"dependencies": {
"brain": "ok" if brain_ok else "unavailable",
"db": "ok" if db_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)