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:
commit
ff03fc7f43
9 changed files with 2244 additions and 0 deletions
234
main.py
Normal file
234
main.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue