Initial commit: Multi-service AI agent system
- Frontend: Vite + React + TypeScript chat interface - Backend: FastAPI gateway with LangGraph routing - Knowledge Service: ChromaDB RAG with Gitea scraper - LangGraph Service: Multi-agent orchestration - Airflow: Scheduled Gitea ingestion DAG - Documentation: Complete plan and implementation guides Architecture: - Modular Docker Compose per service - External ai-mesh network for communication - Fast rebuilds with /app/packages pattern - Intelligent agent routing (no hardcoded keywords) Services: - Frontend (5173): React chat UI - Chat Gateway (8000): FastAPI entry point - LangGraph (8090): Agent orchestration - Knowledge (8080): ChromaDB RAG - Airflow (8081): Scheduled ingestion - PostgreSQL (5432): Chat history Excludes: node_modules, .venv, chroma_db, logs, .env files Includes: All source code, configs, docs, docker files
This commit is contained in:
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y libpq-dev gcc
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
58
backend/main.py
Normal file
58
backend/main.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stdout)])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
BRAIN_URL = "http://opencode-brain:5000"
|
||||
KNOWLEDGE_URL = "http://knowledge-service:8080/query"
|
||||
AUTH = httpx.BasicAuth("opencode", "sam4jo")
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(request: MessageRequest):
|
||||
user_msg = request.message.lower()
|
||||
timeout_long = httpx.Timeout(180.0, connect=10.0)
|
||||
timeout_short = httpx.Timeout(5.0, connect=2.0)
|
||||
|
||||
context = ""
|
||||
# Check for keywords to trigger Librarian (DB) lookup
|
||||
if any(kw in user_msg for kw in ["sam", "hobby", "music", "guitar", "skiing", "experience"]):
|
||||
logger.info("Gateway: Consulting Librarian (DB)...")
|
||||
async with httpx.AsyncClient(timeout=timeout_short) as client:
|
||||
try:
|
||||
k_res = await client.post(KNOWLEDGE_URL, json={"question": request.message})
|
||||
if k_res.status_code == 200:
|
||||
context = k_res.json().get("context", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"Gateway: Librarian offline/slow: {str(e)}")
|
||||
|
||||
# Forward to Brain (LLM)
|
||||
async with httpx.AsyncClient(auth=AUTH, timeout=timeout_long) as brain_client:
|
||||
try:
|
||||
session_res = await brain_client.post(f"{BRAIN_URL}/session", json={"title": "Demo"})
|
||||
session_id = session_res.json()["id"]
|
||||
final_prompt = f"CONTEXT:\n{context}\n\nUSER: {request.message}" if context else request.message
|
||||
response = await brain_client.post(f"{BRAIN_URL}/session/{session_id}/message", json={"parts": [{"type": "text", "text": final_prompt}]})
|
||||
|
||||
# FIX: Iterate through parts array to find text response
|
||||
data = response.json()
|
||||
if "parts" in data:
|
||||
for part in data["parts"]:
|
||||
if part.get("type") == "text" and "text" in part:
|
||||
return {"response": part["text"]}
|
||||
|
||||
return {"response": "AI responded but no text found in expected format."}
|
||||
except Exception:
|
||||
logger.error(f"Gateway: Brain failure: {traceback.format_exc()}")
|
||||
return {"response": "Error: The Brain is taking too long or is disconnected."}
|
||||
49
backend/main.py.new
Normal file
49
backend/main.py.new
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
import os
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stdout)])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
LANGGRAPH_URL = os.getenv("LANGGRAPH_URL", "http://langgraph-service:8090")
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(request: MessageRequest):
|
||||
"""Updated chat endpoint that routes through LangGraph Supervisor."""
|
||||
logger.info(f"Gateway: Received message: {request.message}")
|
||||
|
||||
try:
|
||||
# Call LangGraph Supervisor instead of direct brain
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) as client:
|
||||
response = await client.post(
|
||||
f"{LANGGRAPH_URL}/query",
|
||||
json={"query": request.message}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
logger.info(f"Gateway: Response from {result.get('agent_used', 'unknown')} agent")
|
||||
return {"response": result["response"]}
|
||||
else:
|
||||
logger.error(f"Gateway: LangGraph error {response.status_code}")
|
||||
return {"response": "Error: Orchestration service unavailable"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Gateway: Error routing through LangGraph: {traceback.format_exc()}")
|
||||
return {"response": "Error: Unable to process your request at this time."}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "service": "chat-gateway"}
|
||||
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
psycopg2-binary
|
||||
pydantic
|
||||
httpx
|
||||
pytest
|
||||
pytest-asyncio
|
||||
79
backend/tests/test_gateway.py
Normal file
79
backend/tests/test_gateway.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
import httpx
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_general_query():
|
||||
"""Test that a general query (no personal keywords) skips the Librarian."""
|
||||
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
||||
# Mock Brain response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"info": {"id": "msg_123"},
|
||||
"parts": [{"type": "text", "text": "I am a general AI."}]
|
||||
}
|
||||
|
||||
# First call is for session creation, second for message
|
||||
mock_post.side_effect = [AsyncMock(status_code=200, json=lambda: {"id": "ses_123"}), mock_response]
|
||||
|
||||
response = client.post("/chat", json={"message": "What is 2+2?"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["response"] == "I am a general AI."
|
||||
# Verify Librarian (knowledge-service) was NOT called
|
||||
# The knowledge service URL is http://knowledge-service:8080/query
|
||||
calls = [call.args[0] for call in mock_post.call_args_list]
|
||||
assert not any("knowledge-service" in url for url in calls)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_personal_query_success():
|
||||
"""Test that a personal query calls the Librarian and injects context."""
|
||||
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
||||
# 1. Mock Librarian Response
|
||||
mock_k_res = AsyncMock()
|
||||
mock_k_res.status_code = 200
|
||||
mock_k_res.json.return_value = {"context": "Sam likes red guitars."}
|
||||
|
||||
# 2. Mock Brain Session Response
|
||||
mock_s_res = AsyncMock()
|
||||
mock_s_res.status_code = 200
|
||||
mock_s_res.json.return_value = {"id": "ses_123"}
|
||||
|
||||
# 3. Mock Brain Message Response
|
||||
mock_b_res = AsyncMock()
|
||||
mock_b_res.status_code = 200
|
||||
mock_b_res.json.return_value = {
|
||||
"parts": [{"type": "text", "text": "I see Sam likes red guitars."}]
|
||||
}
|
||||
|
||||
mock_post.side_effect = [mock_k_res, mock_s_res, mock_b_res]
|
||||
|
||||
response = client.post("/chat", json={"message": "Tell me about Sam's music"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "red guitars" in response.json()["response"]
|
||||
|
||||
# Verify Librarian was called
|
||||
calls = [call.args[0] for call in mock_post.call_args_list]
|
||||
assert any("knowledge-service" in url for url in calls)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_librarian_timeout_failover():
|
||||
"""Test that the gateway fails over instantly (5s) if Librarian is slow."""
|
||||
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
||||
# Mock Librarian Timeout
|
||||
mock_post.side_effect = [
|
||||
httpx.TimeoutException("Timeout"), # Librarian call
|
||||
AsyncMock(status_code=200, json=lambda: {"id": "ses_123"}), # Brain Session
|
||||
AsyncMock(status_code=200, json=lambda: {"parts": [{"type": "text", "text": "Direct Brain Response"}]}) # Brain Msg
|
||||
]
|
||||
|
||||
response = client.post("/chat", json={"message": "Sam's hobbies?"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["response"] == "Direct Brain Response"
|
||||
Reference in New Issue
Block a user