Spaces:
Sleeping
Sleeping
Commit
·
aa63765
1
Parent(s):
ef83e66
feat: add knowledge base management and analytics dashboard
Browse files- backend/api/ingestion/pdf.py +0 -0
- backend/api/mcp_clients/rag_client.py +52 -0
- backend/api/routes/analytics.py +4 -4
- backend/api/routes/rag.py +56 -4
- backend/mcp_servers/database.py +55 -0
- backend/mcp_servers/main.py +17 -1
- backend/workers/analytics_worker.py +142 -0
- backend/workers/celeryconfig.py +84 -0
- backend/workers/ingestion_worker.py +283 -0
- backend/workers/scheduler.py +37 -0
- frontend/README.md +20 -24
- frontend/app/globals.css +101 -11
- frontend/app/knowledge-base/page.tsx +280 -0
- frontend/app/layout.tsx +3 -2
- frontend/app/page.tsx +24 -58
- frontend/components/analytics-panel.tsx +146 -0
- frontend/components/chat-panel.tsx +179 -0
- frontend/components/feature-grid.tsx +56 -0
- frontend/components/footer.tsx +15 -0
- frontend/components/hero.tsx +100 -0
- frontend/components/ingestion-card.tsx +56 -0
- frontend/components/knowledge-base-panel.tsx +216 -0
- frontend/package-lock.json +0 -0
backend/api/ingestion/pdf.py
ADDED
|
File without changes
|
backend/api/mcp_clients/rag_client.py
CHANGED
|
@@ -12,6 +12,7 @@ class RAGClient:
|
|
| 12 |
def __init__(self):
|
| 13 |
self.base_url = os.getenv("RAG_MCP_URL")
|
| 14 |
self.search_endpoint = f"{self.base_url}/search"
|
|
|
|
| 15 |
|
| 16 |
async def search(self, query: str, tenant_id: str):
|
| 17 |
"""
|
|
@@ -37,3 +38,54 @@ class RAGClient:
|
|
| 37 |
except Exception as e:
|
| 38 |
print("RAG Client Error:", e)
|
| 39 |
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
def __init__(self):
|
| 13 |
self.base_url = os.getenv("RAG_MCP_URL")
|
| 14 |
self.search_endpoint = f"{self.base_url}/search"
|
| 15 |
+
self.ingest_endpoint = f"{self.base_url}/ingest"
|
| 16 |
|
| 17 |
async def search(self, query: str, tenant_id: str):
|
| 18 |
"""
|
|
|
|
| 38 |
except Exception as e:
|
| 39 |
print("RAG Client Error:", e)
|
| 40 |
return []
|
| 41 |
+
|
| 42 |
+
async def ingest(self, content: str, tenant_id: str):
|
| 43 |
+
"""
|
| 44 |
+
Sends content to the RAG server for ingestion.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
async with httpx.AsyncClient() as client:
|
| 49 |
+
response = await client.post(
|
| 50 |
+
self.ingest_endpoint,
|
| 51 |
+
json={
|
| 52 |
+
"tenant_id": tenant_id,
|
| 53 |
+
"content": content
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if response.status_code != 200:
|
| 58 |
+
return {"error": f"HTTP {response.status_code}"}
|
| 59 |
+
|
| 60 |
+
data = response.json()
|
| 61 |
+
return data
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print("RAG Ingest Error:", e)
|
| 65 |
+
return {"error": str(e)}
|
| 66 |
+
|
| 67 |
+
async def list_documents(self, tenant_id: str, limit: int = 1000, offset: int = 0):
|
| 68 |
+
"""
|
| 69 |
+
List all documents for a tenant.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
async with httpx.AsyncClient() as client:
|
| 74 |
+
response = await client.get(
|
| 75 |
+
f"{self.base_url}/list",
|
| 76 |
+
params={
|
| 77 |
+
"tenant_id": tenant_id,
|
| 78 |
+
"limit": limit,
|
| 79 |
+
"offset": offset
|
| 80 |
+
}
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
if response.status_code != 200:
|
| 84 |
+
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
| 85 |
+
|
| 86 |
+
data = response.json()
|
| 87 |
+
return data
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print("RAG List Error:", e)
|
| 91 |
+
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
backend/api/routes/analytics.py
CHANGED
|
@@ -25,7 +25,7 @@ ANALYTICS_DATA = {
|
|
| 25 |
}
|
| 26 |
|
| 27 |
|
| 28 |
-
@router.get("/
|
| 29 |
async def analytics_overview(
|
| 30 |
x_tenant_id: str = Header(None)
|
| 31 |
):
|
|
@@ -47,7 +47,7 @@ async def analytics_overview(
|
|
| 47 |
}
|
| 48 |
|
| 49 |
|
| 50 |
-
@router.get("/
|
| 51 |
async def analytics_tool_usage(
|
| 52 |
x_tenant_id: str = Header(None)
|
| 53 |
):
|
|
@@ -64,7 +64,7 @@ async def analytics_tool_usage(
|
|
| 64 |
}
|
| 65 |
|
| 66 |
|
| 67 |
-
@router.get("/
|
| 68 |
async def analytics_redflags(
|
| 69 |
x_tenant_id: str = Header(None)
|
| 70 |
):
|
|
@@ -86,7 +86,7 @@ async def analytics_redflags(
|
|
| 86 |
}
|
| 87 |
|
| 88 |
|
| 89 |
-
@router.get("/
|
| 90 |
async def analytics_activity(
|
| 91 |
x_tenant_id: str = Header(None)
|
| 92 |
):
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
|
| 28 |
+
@router.get("/overview")
|
| 29 |
async def analytics_overview(
|
| 30 |
x_tenant_id: str = Header(None)
|
| 31 |
):
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
|
| 50 |
+
@router.get("/tool-usage")
|
| 51 |
async def analytics_tool_usage(
|
| 52 |
x_tenant_id: str = Header(None)
|
| 53 |
):
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
|
| 67 |
+
@router.get("/redflags")
|
| 68 |
async def analytics_redflags(
|
| 69 |
x_tenant_id: str = Header(None)
|
| 70 |
):
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
|
| 89 |
+
@router.get("/activity")
|
| 90 |
async def analytics_activity(
|
| 91 |
x_tenant_id: str = Header(None)
|
| 92 |
):
|
backend/api/routes/rag.py
CHANGED
|
@@ -1,13 +1,22 @@
|
|
| 1 |
from fastapi import APIRouter, Header, HTTPException
|
|
|
|
| 2 |
from api.mcp_clients.rag_client import RAGClient
|
| 3 |
|
| 4 |
router = APIRouter()
|
| 5 |
rag_client = RAGClient()
|
| 6 |
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
async def rag_search(
|
| 10 |
-
|
| 11 |
x_tenant_id: str = Header(None)
|
| 12 |
):
|
| 13 |
"""
|
|
@@ -18,11 +27,54 @@ async def rag_search(
|
|
| 18 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 19 |
|
| 20 |
try:
|
| 21 |
-
results = await rag_client.search(query, x_tenant_id)
|
| 22 |
return {
|
| 23 |
"tenant_id": x_tenant_id,
|
| 24 |
-
"query": query,
|
| 25 |
"results": results
|
| 26 |
}
|
| 27 |
except Exception as e:
|
| 28 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi import APIRouter, Header, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
from api.mcp_clients.rag_client import RAGClient
|
| 4 |
|
| 5 |
router = APIRouter()
|
| 6 |
rag_client = RAGClient()
|
| 7 |
|
| 8 |
|
| 9 |
+
class IngestRequest(BaseModel):
|
| 10 |
+
content: str
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SearchRequest(BaseModel):
|
| 14 |
+
query: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/search")
|
| 18 |
async def rag_search(
|
| 19 |
+
req: SearchRequest,
|
| 20 |
x_tenant_id: str = Header(None)
|
| 21 |
):
|
| 22 |
"""
|
|
|
|
| 27 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 28 |
|
| 29 |
try:
|
| 30 |
+
results = await rag_client.search(req.query, x_tenant_id)
|
| 31 |
return {
|
| 32 |
"tenant_id": x_tenant_id,
|
| 33 |
+
"query": req.query,
|
| 34 |
"results": results
|
| 35 |
}
|
| 36 |
except Exception as e:
|
| 37 |
raise HTTPException(status_code=500, detail=str(e))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("/ingest")
|
| 41 |
+
async def rag_ingest(
|
| 42 |
+
req: IngestRequest,
|
| 43 |
+
x_tenant_id: str = Header(None)
|
| 44 |
+
):
|
| 45 |
+
"""
|
| 46 |
+
Ingest content into tenant knowledge base using the RAG MCP server.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
if not x_tenant_id:
|
| 50 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
result = await rag_client.ingest(req.content, x_tenant_id)
|
| 54 |
+
return {
|
| 55 |
+
"tenant_id": x_tenant_id,
|
| 56 |
+
"status": "ok",
|
| 57 |
+
**result
|
| 58 |
+
}
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.get("/list")
|
| 64 |
+
async def rag_list(
|
| 65 |
+
limit: int = 1000,
|
| 66 |
+
offset: int = 0,
|
| 67 |
+
x_tenant_id: str = Header(None)
|
| 68 |
+
):
|
| 69 |
+
"""
|
| 70 |
+
List all documents in tenant knowledge base.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
if not x_tenant_id:
|
| 74 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
result = await rag_client.list_documents(x_tenant_id, limit=limit, offset=offset)
|
| 78 |
+
return result
|
| 79 |
+
except Exception as e:
|
| 80 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/mcp_servers/database.py
CHANGED
|
@@ -173,6 +173,61 @@ def search_vectors(tenant_id: str, vector: list, limit: int = 5) -> List[Dict[st
|
|
| 173 |
return []
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
# -----------------------------------
|
| 177 |
# Supabase Client (for REST operations)
|
| 178 |
# -----------------------------------
|
|
|
|
| 173 |
return []
|
| 174 |
|
| 175 |
|
| 176 |
+
def list_all_documents(tenant_id: str, limit: int = 1000, offset: int = 0) -> Dict[str, Any]:
|
| 177 |
+
"""
|
| 178 |
+
List all documents for a tenant with pagination.
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
conn = get_connection()
|
| 182 |
+
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
| 183 |
+
|
| 184 |
+
cur.execute(
|
| 185 |
+
"""
|
| 186 |
+
SELECT
|
| 187 |
+
id,
|
| 188 |
+
chunk_text,
|
| 189 |
+
created_at
|
| 190 |
+
FROM documents
|
| 191 |
+
WHERE tenant_id = %s
|
| 192 |
+
ORDER BY created_at DESC
|
| 193 |
+
LIMIT %s OFFSET %s;
|
| 194 |
+
""",
|
| 195 |
+
(tenant_id, limit, offset)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
rows = cur.fetchall()
|
| 199 |
+
|
| 200 |
+
# Get total count
|
| 201 |
+
cur.execute(
|
| 202 |
+
"""
|
| 203 |
+
SELECT COUNT(*) as total
|
| 204 |
+
FROM documents
|
| 205 |
+
WHERE tenant_id = %s;
|
| 206 |
+
""",
|
| 207 |
+
(tenant_id,)
|
| 208 |
+
)
|
| 209 |
+
total_row = cur.fetchone()
|
| 210 |
+
total = total_row["total"] if total_row else 0
|
| 211 |
+
|
| 212 |
+
cur.close()
|
| 213 |
+
conn.close()
|
| 214 |
+
|
| 215 |
+
results: List[Dict[str, Any]] = []
|
| 216 |
+
for row in rows:
|
| 217 |
+
results.append(
|
| 218 |
+
{
|
| 219 |
+
"id": row["id"],
|
| 220 |
+
"text": row["chunk_text"],
|
| 221 |
+
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
| 222 |
+
}
|
| 223 |
+
)
|
| 224 |
+
return {"documents": results, "total": total, "limit": limit, "offset": offset}
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
print("DB LIST ERROR:", e)
|
| 228 |
+
return {"documents": [], "total": 0, "limit": limit, "offset": offset}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
# -----------------------------------
|
| 232 |
# Supabase Client (for REST operations)
|
| 233 |
# -----------------------------------
|
backend/mcp_servers/main.py
CHANGED
|
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.join(parent_dir, "api")) # For utils
|
|
| 21 |
# --------------------------------------------------------
|
| 22 |
|
| 23 |
from embeddings import embed_text
|
| 24 |
-
from database import insert_document_chunks, search_vectors, initialize_database
|
| 25 |
from utils.text_extractor import extract_text
|
| 26 |
|
| 27 |
|
|
@@ -152,6 +152,22 @@ def search(payload: SearchPayload):
|
|
| 152 |
raise HTTPException(status_code=500, detail=str(e))
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
# --------------------------------------------------------
|
| 156 |
# Allow "python main.py" to start server
|
| 157 |
# --------------------------------------------------------
|
|
|
|
| 21 |
# --------------------------------------------------------
|
| 22 |
|
| 23 |
from embeddings import embed_text
|
| 24 |
+
from database import insert_document_chunks, search_vectors, list_all_documents, initialize_database
|
| 25 |
from utils.text_extractor import extract_text
|
| 26 |
|
| 27 |
|
|
|
|
| 152 |
raise HTTPException(status_code=500, detail=str(e))
|
| 153 |
|
| 154 |
|
| 155 |
+
# --------------------------------------------------------
|
| 156 |
+
# List All Documents Route
|
| 157 |
+
# --------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
@app.get("/list")
|
| 160 |
+
def list_documents(tenant_id: str, limit: int = 1000, offset: int = 0):
|
| 161 |
+
"""
|
| 162 |
+
List all documents for a tenant with pagination.
|
| 163 |
+
"""
|
| 164 |
+
try:
|
| 165 |
+
result = list_all_documents(tenant_id, limit=limit, offset=offset)
|
| 166 |
+
return result
|
| 167 |
+
except Exception as e:
|
| 168 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 169 |
+
|
| 170 |
+
|
| 171 |
# --------------------------------------------------------
|
| 172 |
# Allow "python main.py" to start server
|
| 173 |
# --------------------------------------------------------
|
backend/workers/analytics_worker.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/workers/analytics_worker.py
|
| 3 |
+
|
| 4 |
+
Analytics & evaluation background tasks:
|
| 5 |
+
- compute_daily_metrics: aggregates logs into per-tenant daily metrics
|
| 6 |
+
- compute_rag_quality: synthetic RAG evaluation (precision@k style)
|
| 7 |
+
- export_metrics_to_supabase: writes aggregated metrics to a supabase analytics table
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
from typing import Dict, Any, List
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from backend.workers.celeryconfig import celery_app
|
| 17 |
+
except Exception:
|
| 18 |
+
celery_app = None
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("analytics_worker")
|
| 21 |
+
logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))
|
| 22 |
+
|
| 23 |
+
# Placeholder: hook to your logging/event store. Replace with concrete DB queries
|
| 24 |
+
def fetch_raw_logs(since_ts: int = 0) -> List[Dict[str, Any]]:
|
| 25 |
+
"""
|
| 26 |
+
Fetch raw logs from the event store (Postgres / Supabase table).
|
| 27 |
+
Here we return dummy data if no DB configured.
|
| 28 |
+
"""
|
| 29 |
+
# In prod: query your events table for entries after since_ts
|
| 30 |
+
return [
|
| 31 |
+
{"tenant_id": "demo", "tool": "rag", "latency_ms": 120, "tokens": 12, "ts": since_ts + 10},
|
| 32 |
+
{"tenant_id": "demo", "tool": "web", "latency_ms": 70, "tokens": 0, "ts": since_ts + 20},
|
| 33 |
+
{"tenant_id": "demo", "tool": "llm", "latency_ms": 400, "tokens": 150, "ts": since_ts + 30},
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def aggregate_logs(logs: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
| 38 |
+
"""
|
| 39 |
+
Simple aggregator by tenant: counts requests, tokens, latency per tool.
|
| 40 |
+
Returns a dict keyed by tenant_id.
|
| 41 |
+
"""
|
| 42 |
+
out = {}
|
| 43 |
+
for e in logs:
|
| 44 |
+
tid = e.get("tenant_id", "unknown")
|
| 45 |
+
out.setdefault(tid, {"requests": 0, "tokens": 0, "tools": {}, "total_latency_ms": 0})
|
| 46 |
+
out[tid]["requests"] += 1
|
| 47 |
+
out[tid]["tokens"] += e.get("tokens", 0)
|
| 48 |
+
out[tid]["total_latency_ms"] += e.get("latency_ms", 0)
|
| 49 |
+
tool = e.get("tool", "unknown")
|
| 50 |
+
out[tid]["tools"].setdefault(tool, {"count": 0, "latency_ms": 0})
|
| 51 |
+
out[tid]["tools"][tool]["count"] += 1
|
| 52 |
+
out[tid]["tools"][tool]["latency_ms"] += e.get("latency_ms", 0)
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def write_metrics_supabase(metrics: Dict[str, Dict[str, Any]]):
|
| 57 |
+
"""
|
| 58 |
+
Writes aggregated metrics to supabase analytics table. Falls back to logging if supabase not configured.
|
| 59 |
+
"""
|
| 60 |
+
from backend.workers.ingestion_worker import SUPABASE
|
| 61 |
+
if SUPABASE is None:
|
| 62 |
+
logger.info("Supabase not configured; metrics:\n%s", metrics)
|
| 63 |
+
return {"status": "logged", "metrics_count": len(metrics)}
|
| 64 |
+
|
| 65 |
+
table = os.getenv("SUPABASE_ANALYTICS_TABLE", "analytics_daily")
|
| 66 |
+
rows = []
|
| 67 |
+
ts = int(time.time())
|
| 68 |
+
for tenant_id, data in metrics.items():
|
| 69 |
+
rows.append({
|
| 70 |
+
"tenant_id": tenant_id,
|
| 71 |
+
"date": time.strftime("%Y-%m-%d", time.gmtime(ts)),
|
| 72 |
+
"requests": data["requests"],
|
| 73 |
+
"tokens": data["tokens"],
|
| 74 |
+
"total_latency_ms": data["total_latency_ms"],
|
| 75 |
+
"tools": data["tools"],
|
| 76 |
+
})
|
| 77 |
+
try:
|
| 78 |
+
res = SUPABASE.table(table).upsert(rows).execute()
|
| 79 |
+
logger.info("Wrote %d metrics rows to supabase table %s", len(rows), table)
|
| 80 |
+
return {"status": "ok", "count": len(rows), "result": res}
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.exception("Failed to write metrics to supabase: %s", e)
|
| 83 |
+
return {"status": "error", "error": str(e)}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Celery decorator
|
| 87 |
+
def task_decorator(func):
|
| 88 |
+
if celery_app is not None:
|
| 89 |
+
return celery_app.task(func)
|
| 90 |
+
else:
|
| 91 |
+
def wrapper(*args, **kwargs):
|
| 92 |
+
return func(*args, **kwargs)
|
| 93 |
+
return wrapper
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@task_decorator
|
| 97 |
+
def compute_daily_metrics(window_seconds: int = 86400):
|
| 98 |
+
"""
|
| 99 |
+
Compute daily metrics by fetching raw logs and aggregating.
|
| 100 |
+
"""
|
| 101 |
+
logger.info("compute_daily_metrics started; window=%s", window_seconds)
|
| 102 |
+
since_ts = int(time.time()) - window_seconds
|
| 103 |
+
logs = fetch_raw_logs(since_ts=since_ts)
|
| 104 |
+
metrics = aggregate_logs(logs)
|
| 105 |
+
res = write_metrics_supabase(metrics)
|
| 106 |
+
logger.info("compute_daily_metrics finished; res=%s", res)
|
| 107 |
+
return res
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@task_decorator
|
| 111 |
+
def compute_rag_quality(sample_size: int = 20, top_k: int = 3):
|
| 112 |
+
"""
|
| 113 |
+
Synthetic RAG evaluation:
|
| 114 |
+
- sample random chunks (or seed queries)
|
| 115 |
+
- ask the RAG server to search
|
| 116 |
+
- compute simple precision@k using exact string matching heuristic
|
| 117 |
+
"""
|
| 118 |
+
logger.info("compute_rag_quality started; sample_size=%d top_k=%d", sample_size, top_k)
|
| 119 |
+
|
| 120 |
+
# In production: sample from DB. Here we simulate a simple check using mock data or minimal queries.
|
| 121 |
+
try:
|
| 122 |
+
# fake sample
|
| 123 |
+
sample_queries = ["What is our HR policy?", "What is refund policy?"] * (sample_size // 2)
|
| 124 |
+
results = []
|
| 125 |
+
# Try to call rag MCP if available
|
| 126 |
+
from backend.api.mcp_clients.rag_client import RagClient
|
| 127 |
+
rag = RagClient()
|
| 128 |
+
for q in sample_queries:
|
| 129 |
+
try:
|
| 130 |
+
r = rag.search({"tenant_id": "demo", "query": q})
|
| 131 |
+
# r should contain scored results; we use heuristics: check if any item contains a keyword
|
| 132 |
+
top_texts = [it.get("text", "") for it in r.get("results", [])[:top_k]]
|
| 133 |
+
success = any("policy" in t.lower() for t in top_texts)
|
| 134 |
+
results.append({"query": q, "success": success})
|
| 135 |
+
except Exception:
|
| 136 |
+
results.append({"query": q, "success": False})
|
| 137 |
+
precision = sum(1 for r in results if r["success"]) / max(1, len(results))
|
| 138 |
+
logger.info("compute_rag_quality precision=%.3f", precision)
|
| 139 |
+
return {"precision_at_k": precision, "sample": len(results)}
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.exception("compute_rag_quality failed: %s", e)
|
| 142 |
+
return {"error": str(e)}
|
backend/workers/celeryconfig.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/workers/celeryconfig.py
|
| 3 |
+
|
| 4 |
+
Creates and configures the Celery app for the project.
|
| 5 |
+
|
| 6 |
+
Usage (development):
|
| 7 |
+
# start a worker:
|
| 8 |
+
celery -A backend.workers.celeryconfig worker --loglevel=info
|
| 9 |
+
|
| 10 |
+
# start beat:
|
| 11 |
+
celery -A backend.workers.celeryconfig beat --loglevel=info
|
| 12 |
+
|
| 13 |
+
Notes:
|
| 14 |
+
- Requires CELERY_BROKER_URL (no default broker is assumed)
|
| 15 |
+
- Tasks autodiscover from 'backend.workers' package
|
| 16 |
+
- This file also loads schedule from scheduler.py (SCHEDULE mapping)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
from celery import Celery
|
| 21 |
+
from celery.schedules import crontab
|
| 22 |
+
import logging
|
| 23 |
+
|
| 24 |
+
# Basic logging
|
| 25 |
+
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
| 26 |
+
logger = logging.getLogger("celeryconfig")
|
| 27 |
+
|
| 28 |
+
BROKER_URL = os.getenv("CELERY_BROKER_URL")
|
| 29 |
+
if not BROKER_URL:
|
| 30 |
+
raise RuntimeError(
|
| 31 |
+
"CELERY_BROKER_URL is not set. Configure a broker such as amqp://, redis://, or sqs:// before starting workers."
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", BROKER_URL)
|
| 35 |
+
if not RESULT_BACKEND:
|
| 36 |
+
raise RuntimeError(
|
| 37 |
+
"CELERY_RESULT_BACKEND is not set. Provide a backend URL or reuse CELERY_BROKER_URL."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
celery_app = Celery(
|
| 41 |
+
"integrachat_workers",
|
| 42 |
+
broker=BROKER_URL,
|
| 43 |
+
backend=RESULT_BACKEND,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Recommended worker options
|
| 47 |
+
celery_app.conf.update(
|
| 48 |
+
task_serializer="json",
|
| 49 |
+
result_serializer="json",
|
| 50 |
+
accept_content=["json"],
|
| 51 |
+
task_acks_late=True,
|
| 52 |
+
worker_prefetch_multiplier=1,
|
| 53 |
+
worker_max_tasks_per_child=100,
|
| 54 |
+
broker_pool_limit=10,
|
| 55 |
+
timezone="UTC",
|
| 56 |
+
enable_utc=True,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Auto-discover tasks in the workers package
|
| 60 |
+
celery_app.autodiscover_tasks(["backend.workers"])
|
| 61 |
+
|
| 62 |
+
# Load schedule from scheduler.SCHEDULE and convert crontab-like entries
|
| 63 |
+
try:
|
| 64 |
+
from backend.workers.scheduler import SCHEDULE as CUSTOM_SCHEDULE
|
| 65 |
+
beat_schedule = {}
|
| 66 |
+
for name, cfg in CUSTOM_SCHEDULE.items():
|
| 67 |
+
task_name = cfg["task"]
|
| 68 |
+
schedule_cfg = cfg["schedule"]
|
| 69 |
+
args = cfg.get("args", ())
|
| 70 |
+
# Determine schedule type
|
| 71 |
+
if isinstance(schedule_cfg, dict) and schedule_cfg.get("type") == "crontab":
|
| 72 |
+
hour = schedule_cfg.get("hour", 0)
|
| 73 |
+
minute = schedule_cfg.get("minute", 0)
|
| 74 |
+
beat_schedule[name] = {"task": task_name, "schedule": crontab(minute=minute, hour=hour), "args": args}
|
| 75 |
+
else:
|
| 76 |
+
# fallback: expect a timedelta or seconds (for quick dev)
|
| 77 |
+
beat_schedule[name] = {"task": task_name, "schedule": schedule_cfg, "args": args}
|
| 78 |
+
celery_app.conf.beat_schedule = beat_schedule
|
| 79 |
+
logger.info("Loaded Celery beat schedule with %d jobs", len(beat_schedule))
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.exception("Failed to load scheduler.SCHEDULE: %s", e)
|
| 82 |
+
|
| 83 |
+
# Export celery_app symbol for import by tasks
|
| 84 |
+
__all__ = ["celery_app"]
|
backend/workers/ingestion_worker.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/workers/ingestion_worker.py
|
| 3 |
+
|
| 4 |
+
Celery task(s) for document ingestion:
|
| 5 |
+
- extract_text_from_file: supports PDF, DOCX, TXT, or raw text
|
| 6 |
+
- chunk_text: simple sentence-based chunker with overlap
|
| 7 |
+
- embed_chunks: uses Sentence-Transformers (all-MiniLM-L6-v2) if available,
|
| 8 |
+
otherwise uses a fallback hash-based vector (deterministic) for dev
|
| 9 |
+
- write_embeddings_to_supabase: stores chunk metadata and embedding into Supabase/pgvector
|
| 10 |
+
- ingest_document task: orchestrates the end-to-end flow
|
| 11 |
+
|
| 12 |
+
Notes:
|
| 13 |
+
- Expects SUPABASE_URL and SUPABASE_SERVICE_KEY in environment for production.
|
| 14 |
+
- Uses CELERY broker settings from celeryconfig.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import math
|
| 19 |
+
import time
|
| 20 |
+
import logging
|
| 21 |
+
from typing import List, Dict, Optional
|
| 22 |
+
|
| 23 |
+
from celery import shared_task
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
# Try to import sentence-transformers; fallback to hashlib if not present
|
| 27 |
+
try:
|
| 28 |
+
from sentence_transformers import SentenceTransformer
|
| 29 |
+
EMBED_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
|
| 30 |
+
except Exception:
|
| 31 |
+
EMBED_MODEL = None
|
| 32 |
+
|
| 33 |
+
# Try to import supabase client. If missing, fallback to using psycopg2/pgconnection if env provided.
|
| 34 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 35 |
+
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
from supabase import create_client, Client as SupabaseClient
|
| 39 |
+
if SUPABASE_URL and SUPABASE_SERVICE_KEY:
|
| 40 |
+
SUPABASE = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
| 41 |
+
else:
|
| 42 |
+
SUPABASE = None
|
| 43 |
+
except Exception:
|
| 44 |
+
SUPABASE = None
|
| 45 |
+
|
| 46 |
+
# Try to import a robust text extractor lib. Use python-docx + plain open as fallback.
|
| 47 |
+
try:
|
| 48 |
+
import textract # optional powerful extractor
|
| 49 |
+
except Exception:
|
| 50 |
+
textract = None
|
| 51 |
+
|
| 52 |
+
import hashlib
|
| 53 |
+
import json
|
| 54 |
+
import re
|
| 55 |
+
|
| 56 |
+
logger = logging.getLogger("ingestion_worker")
|
| 57 |
+
logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# -----------------------
|
| 61 |
+
# Utilities
|
| 62 |
+
# -----------------------
|
| 63 |
+
def extract_text_from_path(path: str) -> str:
|
| 64 |
+
"""
|
| 65 |
+
Extract text from a given file path.
|
| 66 |
+
Supports: .txt, .md, .pdf (via textract if available), .docx (basic fallback)
|
| 67 |
+
"""
|
| 68 |
+
p = Path(path)
|
| 69 |
+
if not p.exists():
|
| 70 |
+
raise FileNotFoundError(f"File not found: {path}")
|
| 71 |
+
|
| 72 |
+
suffix = p.suffix.lower()
|
| 73 |
+
if suffix in [".txt", ".md"]:
|
| 74 |
+
return p.read_text(encoding="utf-8", errors="ignore")
|
| 75 |
+
|
| 76 |
+
if textract is not None:
|
| 77 |
+
try:
|
| 78 |
+
raw = textract.process(str(p))
|
| 79 |
+
return raw.decode("utf-8", errors="ignore")
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.warning("textract failed, falling back to basic read (%s)", e)
|
| 82 |
+
|
| 83 |
+
# basic docx fallback
|
| 84 |
+
if suffix == ".docx":
|
| 85 |
+
try:
|
| 86 |
+
from docx import Document as DocxDocument
|
| 87 |
+
doc = DocxDocument(str(p))
|
| 88 |
+
return "\n".join(par.text for par in doc.paragraphs)
|
| 89 |
+
except Exception:
|
| 90 |
+
logger.exception("docx extraction failed; returning empty text")
|
| 91 |
+
return ""
|
| 92 |
+
|
| 93 |
+
# last fallback: binary read as text
|
| 94 |
+
try:
|
| 95 |
+
return p.read_text(encoding="utf-8", errors="ignore")
|
| 96 |
+
except Exception:
|
| 97 |
+
logger.exception("unknown file type and text read failed")
|
| 98 |
+
return ""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def simple_chunk_text(text: str, chunk_size: int = 800, chunk_overlap: int = 100) -> List[str]:
|
| 102 |
+
"""
|
| 103 |
+
Very simple chunker that splits on sentences up to roughly chunk_size tokens/characters.
|
| 104 |
+
chunk_size is approximate (characters). Overlap is characters overlapped between chunks.
|
| 105 |
+
"""
|
| 106 |
+
clean = re.sub(r"\s+", " ", text).strip()
|
| 107 |
+
if not clean:
|
| 108 |
+
return []
|
| 109 |
+
|
| 110 |
+
chunks = []
|
| 111 |
+
start = 0
|
| 112 |
+
n = len(clean)
|
| 113 |
+
while start < n:
|
| 114 |
+
end = min(n, start + chunk_size)
|
| 115 |
+
# try to expand to sentence boundary
|
| 116 |
+
if end < n:
|
| 117 |
+
m = clean.rfind(".", start, end)
|
| 118 |
+
if m != -1 and m - start > chunk_size // 3:
|
| 119 |
+
end = m + 1
|
| 120 |
+
chunk = clean[start:end].strip()
|
| 121 |
+
if chunk:
|
| 122 |
+
chunks.append(chunk)
|
| 123 |
+
start = max(end - chunk_overlap, end)
|
| 124 |
+
return chunks
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def embed_texts(texts: List[str]) -> List[List[float]]:
|
| 128 |
+
"""
|
| 129 |
+
Use SentenceTransformer model if available; otherwise use a deterministic hash-based fallback vector.
|
| 130 |
+
The fallback returns a small vector (e.g. 64-d) computed from sha256 chunks — only for local dev/testing.
|
| 131 |
+
"""
|
| 132 |
+
if EMBED_MODEL is not None:
|
| 133 |
+
vectors = EMBED_MODEL.encode(texts, show_progress_bar=False).tolist()
|
| 134 |
+
return vectors
|
| 135 |
+
|
| 136 |
+
# fallback
|
| 137 |
+
vecs = []
|
| 138 |
+
for t in texts:
|
| 139 |
+
h = hashlib.sha256(t.encode("utf-8")).digest()
|
| 140 |
+
# convert to floats in range [-1,1]
|
| 141 |
+
vals = []
|
| 142 |
+
for i in range(0, min(len(h), 64)):
|
| 143 |
+
vals.append(((h[i] / 255.0) * 2.0) - 1.0)
|
| 144 |
+
# pad to 64
|
| 145 |
+
while len(vals) < 64:
|
| 146 |
+
vals.append(0.0)
|
| 147 |
+
vecs.append(vals)
|
| 148 |
+
return vecs
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def upsert_embeddings_supabase(tenant_id: str, doc_id: str, chunks: List[Dict]):
|
| 152 |
+
"""
|
| 153 |
+
Upsert chunk records into Supabase table `embeddings` (or 'rag_embeddings').
|
| 154 |
+
|
| 155 |
+
Expected schema (example):
|
| 156 |
+
- id (uuid)
|
| 157 |
+
- tenant_id (text)
|
| 158 |
+
- doc_id (text)
|
| 159 |
+
- chunk_index (int)
|
| 160 |
+
- chunk_text (text)
|
| 161 |
+
- metadata (jsonb)
|
| 162 |
+
- embedding (vector) -> in pgvector column
|
| 163 |
+
- created_at (timestamp)
|
| 164 |
+
|
| 165 |
+
This function attempts to use supabase-py. If not available, logs the JSON for manual insertion.
|
| 166 |
+
"""
|
| 167 |
+
if SUPABASE is None:
|
| 168 |
+
logger.warning("Supabase client not configured. Logging chunks for manual insertion.")
|
| 169 |
+
logger.debug("Chunks sample: %s", json.dumps(chunks[:3], indent=2))
|
| 170 |
+
return {"status": "logged", "count": len(chunks)}
|
| 171 |
+
|
| 172 |
+
table_name = os.getenv("SUPABASE_EMBED_TABLE", "rag_embeddings")
|
| 173 |
+
# Build rows
|
| 174 |
+
rows = []
|
| 175 |
+
ts = int(time.time())
|
| 176 |
+
for c in chunks:
|
| 177 |
+
rows.append({
|
| 178 |
+
"tenant_id": tenant_id,
|
| 179 |
+
"doc_id": doc_id,
|
| 180 |
+
"chunk_index": c.get("index"),
|
| 181 |
+
"chunk_text": c.get("text"),
|
| 182 |
+
"metadata": c.get("metadata", {}),
|
| 183 |
+
"embedding": c.get("embedding"),
|
| 184 |
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts)),
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
# Use upsert
|
| 188 |
+
try:
|
| 189 |
+
res = SUPABASE.table(table_name).upsert(rows).execute()
|
| 190 |
+
logger.info("Upserted %d embeddings to Supabase table %s", len(rows), table_name)
|
| 191 |
+
return {"status": "ok", "count": len(rows), "result": res}
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.exception("Failed to upsert embeddings to Supabase: %s", e)
|
| 194 |
+
return {"status": "error", "error": str(e)}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# -----------------------
|
| 198 |
+
# Celery task(s)
|
| 199 |
+
# -----------------------
|
| 200 |
+
# We import app lazily to avoid circular imports — celery app is in celeryconfig.py
|
| 201 |
+
try:
|
| 202 |
+
from backend.workers.celeryconfig import celery_app
|
| 203 |
+
except Exception:
|
| 204 |
+
# If import fails, create a simple local Celery-like decorator using dummy shared_task
|
| 205 |
+
celery_app = None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def task_decorator(func):
|
| 209 |
+
if celery_app is not None:
|
| 210 |
+
return celery_app.task(func)
|
| 211 |
+
else:
|
| 212 |
+
# no-op: run synchronously for dev/testing
|
| 213 |
+
def wrapper(*args, **kwargs):
|
| 214 |
+
return func(*args, **kwargs)
|
| 215 |
+
return wrapper
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@task_decorator
|
| 219 |
+
def ingest_document(tenant_id: str, doc_id: str,
|
| 220 |
+
file_path: Optional[str] = None,
|
| 221 |
+
raw_text: Optional[str] = None,
|
| 222 |
+
source_url: Optional[str] = None,
|
| 223 |
+
chunk_size: int = 800,
|
| 224 |
+
chunk_overlap: int = 100):
|
| 225 |
+
"""
|
| 226 |
+
End-to-end ingestion task.
|
| 227 |
+
- if raw_text provided, use it
|
| 228 |
+
- otherwise extract from file_path
|
| 229 |
+
- chunk, embed, and upsert into Supabase
|
| 230 |
+
Returns a dict with status and counts.
|
| 231 |
+
"""
|
| 232 |
+
start_ts = time.time()
|
| 233 |
+
logger.info("ingest_document started: tenant=%s doc_id=%s", tenant_id, doc_id)
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
if raw_text:
|
| 237 |
+
text = raw_text
|
| 238 |
+
elif file_path:
|
| 239 |
+
text = extract_text_from_path(file_path)
|
| 240 |
+
elif source_url:
|
| 241 |
+
# basic fetch for simple pages
|
| 242 |
+
import requests
|
| 243 |
+
r = requests.get(source_url, timeout=10)
|
| 244 |
+
r.raise_for_status()
|
| 245 |
+
text = re.sub(r"\s+", " ", r.text)
|
| 246 |
+
else:
|
| 247 |
+
raise ValueError("Either raw_text, file_path, or source_url must be provided.")
|
| 248 |
+
|
| 249 |
+
if not text or text.strip() == "":
|
| 250 |
+
logger.warning("No text extracted for doc_id=%s", doc_id)
|
| 251 |
+
return {"status": "empty", "chunks": 0}
|
| 252 |
+
|
| 253 |
+
chunks_text = simple_chunk_text(text, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
| 254 |
+
logger.info("Extracted %d chunks for doc=%s", len(chunks_text), doc_id)
|
| 255 |
+
|
| 256 |
+
# Prepare chunk metadata
|
| 257 |
+
chunks = []
|
| 258 |
+
for i, t in enumerate(chunks_text):
|
| 259 |
+
chunks.append({"index": i, "text": t, "metadata": {"source_url": source_url}})
|
| 260 |
+
|
| 261 |
+
# embed in batches
|
| 262 |
+
batch_size = 32
|
| 263 |
+
embeddings = []
|
| 264 |
+
for i in range(0, len(chunks), batch_size):
|
| 265 |
+
batch_texts = [c["text"] for c in chunks[i:i+batch_size]]
|
| 266 |
+
batch_emb = embed_texts(batch_texts)
|
| 267 |
+
embeddings.extend(batch_emb)
|
| 268 |
+
|
| 269 |
+
# attach embeddings
|
| 270 |
+
for i, c in enumerate(chunks):
|
| 271 |
+
c["embedding"] = embeddings[i]
|
| 272 |
+
|
| 273 |
+
# upsert into supabase
|
| 274 |
+
res = upsert_embeddings_supabase(tenant_id, doc_id, chunks)
|
| 275 |
+
|
| 276 |
+
elapsed = time.time() - start_ts
|
| 277 |
+
logger.info("ingest_document finished: tenant=%s doc=%s chunks=%d elapsed=%.2fs",
|
| 278 |
+
tenant_id, doc_id, len(chunks), elapsed)
|
| 279 |
+
return {"status": "ok", "chunks": len(chunks), "supabase": res}
|
| 280 |
+
|
| 281 |
+
except Exception as exc:
|
| 282 |
+
logger.exception("ingest_document failed: %s", exc)
|
| 283 |
+
return {"status": "error", "error": str(exc)}
|
backend/workers/scheduler.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/workers/scheduler.py
|
| 3 |
+
|
| 4 |
+
Simple scheduler wiring for Celery beat.
|
| 5 |
+
This file merely defines a schedule mapping that celeryconfig.py/ Celery app can import.
|
| 6 |
+
|
| 7 |
+
You can use Celery Beat (celery -A backend.workers.celeryconfig beat) or
|
| 8 |
+
run APScheduler to call the task functions if not using Celery beat.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from datetime import timedelta
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
# Recommended schedule configuration for production:
|
| 15 |
+
SCHEDULE = {
|
| 16 |
+
# run daily metrics every day at midnight UTC
|
| 17 |
+
"daily-metrics": {
|
| 18 |
+
"task": "backend.workers.analytics_worker.compute_daily_metrics",
|
| 19 |
+
"schedule": {"type": "crontab", "hour": 0, "minute": 5}, # small delay after midnight
|
| 20 |
+
},
|
| 21 |
+
# run RAG quality checks nightly
|
| 22 |
+
"rag-quality-nightly": {
|
| 23 |
+
"task": "backend.workers.analytics_worker.compute_rag_quality",
|
| 24 |
+
"schedule": {"type": "crontab", "hour": 2, "minute": 30},
|
| 25 |
+
"args": (20, 3)
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# If you prefer periodic seconds schedule (for local dev),
|
| 30 |
+
# you can expose an alternate schedule via env:
|
| 31 |
+
if os.getenv("LOCAL_CELERY_QUICK", "0") == "1":
|
| 32 |
+
SCHEDULE = {
|
| 33 |
+
"daily-metrics": {"task": "backend.workers.analytics_worker.compute_daily_metrics",
|
| 34 |
+
"schedule": timedelta(seconds=60)}, # every minute for dev
|
| 35 |
+
"rag-quality": {"task": "backend.workers.analytics_worker.compute_rag_quality",
|
| 36 |
+
"schedule": timedelta(seconds=120)}
|
| 37 |
+
}
|
frontend/README.md
CHANGED
|
@@ -1,36 +1,32 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
|
|
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
```bash
|
|
|
|
|
|
|
| 8 |
npm run dev
|
| 9 |
-
# or
|
| 10 |
-
yarn dev
|
| 11 |
-
# or
|
| 12 |
-
pnpm dev
|
| 13 |
-
# or
|
| 14 |
-
bun dev
|
| 15 |
```
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
-
|
| 21 |
-
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
-
|
| 23 |
-
## Learn More
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
|
|
|
| 1 |
+
## IntegraChat Frontend
|
| 2 |
|
| 3 |
+
Next.js 16 / React 19 app that showcases everything wired up in `backend/`.
|
| 4 |
+
It provides a polished operator console with:
|
| 5 |
|
| 6 |
+
- Hero + feature overview describing the FastAPI + MCP stack
|
| 7 |
+
- Live chat panel that POSTs to `POST /agent/message`
|
| 8 |
+
- Analytics snapshot pulling from `GET /analytics/overview`
|
| 9 |
+
- Knowledge ingestion pipeline summary tied to Celery workers
|
| 10 |
+
|
| 11 |
+
## Running Locally
|
| 12 |
|
| 13 |
```bash
|
| 14 |
+
cd frontend
|
| 15 |
+
npm install
|
| 16 |
npm run dev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
```
|
| 18 |
|
| 19 |
+
Visit `http://localhost:3000`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
### API configuration
|
| 22 |
|
| 23 |
+
The UI calls the FastAPI service through `NEXT_PUBLIC_API_URL` (default `http://localhost:8000`).
|
| 24 |
+
Update `.env.local` if your backend runs elsewhere:
|
| 25 |
|
| 26 |
+
```
|
| 27 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 28 |
+
```
|
| 29 |
|
| 30 |
+
## Deploy
|
| 31 |
|
| 32 |
+
Deploy like any Next.js app (Vercel, Docker, etc.). Ensure the backend endpoints are reachable from the browser and CORS is enabled (already configured in `backend/api/main.py`).
|
frontend/app/globals.css
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
:root {
|
| 4 |
-
--background: #
|
| 5 |
-
--foreground: #
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
@theme inline {
|
|
@@ -12,15 +16,101 @@
|
|
| 12 |
--font-mono: var(--font-geist-mono);
|
| 13 |
}
|
| 14 |
|
| 15 |
-
@media (prefers-color-scheme: dark) {
|
| 16 |
-
:root {
|
| 17 |
-
--background: #0a0a0a;
|
| 18 |
-
--foreground: #ededed;
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
body {
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
color: var(--foreground);
|
| 25 |
-
font-family:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
:root {
|
| 4 |
+
--background: #020617;
|
| 5 |
+
--foreground: #f8fafc;
|
| 6 |
+
--card: rgba(12, 17, 32, 0.88);
|
| 7 |
+
--card-border: rgba(255, 255, 255, 0.08);
|
| 8 |
+
--accent: #38bdf8;
|
| 9 |
+
--accent-strong: #0ea5e9;
|
| 10 |
}
|
| 11 |
|
| 12 |
@theme inline {
|
|
|
|
| 16 |
--font-mono: var(--font-geist-mono);
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
body {
|
| 20 |
+
min-height: 100vh;
|
| 21 |
+
margin: 0;
|
| 22 |
+
background: radial-gradient(
|
| 23 |
+
circle at top,
|
| 24 |
+
rgba(14, 165, 233, 0.15),
|
| 25 |
+
transparent 45%
|
| 26 |
+
),
|
| 27 |
+
radial-gradient(
|
| 28 |
+
circle at 20% 20%,
|
| 29 |
+
rgba(59, 130, 246, 0.18),
|
| 30 |
+
transparent 35%
|
| 31 |
+
),
|
| 32 |
+
var(--background);
|
| 33 |
color: var(--foreground);
|
| 34 |
+
font-family: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
| 35 |
+
"Segoe UI", sans-serif;
|
| 36 |
+
line-height: 1.5;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
::selection {
|
| 40 |
+
background: rgba(14, 165, 233, 0.35);
|
| 41 |
+
color: #f8fafc;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.gradient-border {
|
| 45 |
+
position: relative;
|
| 46 |
+
border-radius: 28px;
|
| 47 |
+
background: radial-gradient(
|
| 48 |
+
circle at 10% 20%,
|
| 49 |
+
rgba(59, 130, 246, 0.35),
|
| 50 |
+
rgba(15, 23, 42, 0.95)
|
| 51 |
+
);
|
| 52 |
+
overflow: hidden;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.gradient-border::before {
|
| 56 |
+
content: "";
|
| 57 |
+
position: absolute;
|
| 58 |
+
inset: 0;
|
| 59 |
+
padding: 1.5px;
|
| 60 |
+
border-radius: 30px;
|
| 61 |
+
background: linear-gradient(120deg, #60a5fa, #22d3ee, #f97316);
|
| 62 |
+
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 63 |
+
mask-composite: exclude;
|
| 64 |
+
-webkit-mask: linear-gradient(#fff 0 0) content-box,
|
| 65 |
+
linear-gradient(#fff 0 0);
|
| 66 |
+
-webkit-mask-composite: xor;
|
| 67 |
+
pointer-events: none;
|
| 68 |
+
opacity: 0.9;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.glass-panel {
|
| 72 |
+
background: var(--card);
|
| 73 |
+
border: 1px solid var(--card-border);
|
| 74 |
+
border-radius: 24px;
|
| 75 |
+
box-shadow: 0 20px 60px rgba(2, 6, 23, 0.65);
|
| 76 |
+
backdrop-filter: blur(18px);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.badge {
|
| 80 |
+
border-radius: 999px;
|
| 81 |
+
background: rgba(56, 189, 248, 0.12);
|
| 82 |
+
color: #bae6fd;
|
| 83 |
+
font-size: 0.85rem;
|
| 84 |
+
padding: 0.25rem 0.9rem;
|
| 85 |
+
display: inline-flex;
|
| 86 |
+
align-items: center;
|
| 87 |
+
gap: 0.4rem;
|
| 88 |
+
border: 1px solid rgba(56, 189, 248, 0.4);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.grid-fade {
|
| 92 |
+
position: absolute;
|
| 93 |
+
inset: 0;
|
| 94 |
+
background-image: linear-gradient(
|
| 95 |
+
rgba(248, 250, 252, 0.04) 1px,
|
| 96 |
+
transparent 1px
|
| 97 |
+
),
|
| 98 |
+
linear-gradient(90deg, rgba(248, 250, 252, 0.04) 1px, transparent 1px);
|
| 99 |
+
background-size: 50px 50px;
|
| 100 |
+
opacity: 0.4;
|
| 101 |
+
pointer-events: none;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.scrollArea {
|
| 105 |
+
scrollbar-width: thin;
|
| 106 |
+
scrollbar-color: rgba(56, 189, 248, 0.6) transparent;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.scrollArea::-webkit-scrollbar {
|
| 110 |
+
width: 6px;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.scrollArea::-webkit-scrollbar-thumb {
|
| 114 |
+
background: rgba(56, 189, 248, 0.45);
|
| 115 |
+
border-radius: 999px;
|
| 116 |
}
|
frontend/app/knowledge-base/page.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
|
| 6 |
+
type Document = {
|
| 7 |
+
id: number;
|
| 8 |
+
text: string;
|
| 9 |
+
created_at: string | null;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
type DocumentListResponse = {
|
| 13 |
+
documents: Document[];
|
| 14 |
+
total: number;
|
| 15 |
+
limit: number;
|
| 16 |
+
offset: number;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const API_BASE =
|
| 20 |
+
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 21 |
+
|
| 22 |
+
export default function KnowledgeBasePage() {
|
| 23 |
+
const [tenantId, setTenantId] = useState("tenant123");
|
| 24 |
+
const [documents, setDocuments] = useState<Document[]>([]);
|
| 25 |
+
const [total, setTotal] = useState(0);
|
| 26 |
+
const [loading, setLoading] = useState(false);
|
| 27 |
+
const [error, setError] = useState<string | null>(null);
|
| 28 |
+
const [searchFilter, setSearchFilter] = useState("");
|
| 29 |
+
const [filterType, setFilterType] = useState<"all" | "pdf" | "text" | "faq" | "link">("all");
|
| 30 |
+
|
| 31 |
+
async function loadDocuments() {
|
| 32 |
+
setLoading(true);
|
| 33 |
+
setError(null);
|
| 34 |
+
|
| 35 |
+
try {
|
| 36 |
+
const response = await fetch(
|
| 37 |
+
`${API_BASE}/rag/list?limit=1000&offset=0`,
|
| 38 |
+
{
|
| 39 |
+
headers: {
|
| 40 |
+
"x-tenant-id": tenantId,
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
if (!response.ok) {
|
| 46 |
+
throw new Error(`Failed to load documents: ${response.status}`);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const data: DocumentListResponse = await response.json();
|
| 50 |
+
setDocuments(data.documents || []);
|
| 51 |
+
setTotal(data.total || 0);
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error(err);
|
| 54 |
+
setError(
|
| 55 |
+
err instanceof Error
|
| 56 |
+
? err.message
|
| 57 |
+
: "Failed to load knowledge base. Is the RAG MCP server running?",
|
| 58 |
+
);
|
| 59 |
+
} finally {
|
| 60 |
+
setLoading(false);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
loadDocuments();
|
| 66 |
+
}, [tenantId]);
|
| 67 |
+
|
| 68 |
+
// Filter documents based on search and type
|
| 69 |
+
const filteredDocuments = documents.filter((doc) => {
|
| 70 |
+
const matchesSearch =
|
| 71 |
+
!searchFilter ||
|
| 72 |
+
doc.text.toLowerCase().includes(searchFilter.toLowerCase());
|
| 73 |
+
|
| 74 |
+
// Simple heuristics for document type detection
|
| 75 |
+
const textLower = doc.text.toLowerCase();
|
| 76 |
+
let docType: "pdf" | "text" | "faq" | "link" = "text";
|
| 77 |
+
|
| 78 |
+
if (textLower.includes("http://") || textLower.includes("https://") || textLower.includes("www.")) {
|
| 79 |
+
docType = "link";
|
| 80 |
+
} else if (
|
| 81 |
+
textLower.includes("q:") ||
|
| 82 |
+
textLower.includes("question:") ||
|
| 83 |
+
textLower.includes("faq") ||
|
| 84 |
+
textLower.includes("frequently asked")
|
| 85 |
+
) {
|
| 86 |
+
docType = "faq";
|
| 87 |
+
} else if (textLower.includes(".pdf") || textLower.includes("pdf document")) {
|
| 88 |
+
docType = "pdf";
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const matchesType = filterType === "all" || docType === filterType;
|
| 92 |
+
return matchesSearch && matchesType;
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const getDocumentType = (text: string): "pdf" | "text" | "faq" | "link" => {
|
| 96 |
+
const textLower = text.toLowerCase();
|
| 97 |
+
if (textLower.includes("http://") || textLower.includes("https://") || textLower.includes("www.")) {
|
| 98 |
+
return "link";
|
| 99 |
+
} else if (
|
| 100 |
+
textLower.includes("q:") ||
|
| 101 |
+
textLower.includes("question:") ||
|
| 102 |
+
textLower.includes("faq") ||
|
| 103 |
+
textLower.includes("frequently asked")
|
| 104 |
+
) {
|
| 105 |
+
return "faq";
|
| 106 |
+
} else if (textLower.includes(".pdf") || textLower.includes("pdf document")) {
|
| 107 |
+
return "pdf";
|
| 108 |
+
}
|
| 109 |
+
return "text";
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const getTypeColor = (type: string) => {
|
| 113 |
+
switch (type) {
|
| 114 |
+
case "pdf":
|
| 115 |
+
return "bg-red-500/20 text-red-300 border-red-500/30";
|
| 116 |
+
case "faq":
|
| 117 |
+
return "bg-purple-500/20 text-purple-300 border-purple-500/30";
|
| 118 |
+
case "link":
|
| 119 |
+
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
| 120 |
+
default:
|
| 121 |
+
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
return (
|
| 126 |
+
<main className="mx-auto flex min-h-screen max-w-7xl flex-col gap-8 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 127 |
+
{/* Header */}
|
| 128 |
+
<header className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-sm text-slate-100 shadow-lg shadow-slate-950/40">
|
| 129 |
+
<div className="flex items-center gap-3">
|
| 130 |
+
<Link
|
| 131 |
+
href="/"
|
| 132 |
+
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950 transition hover:scale-105"
|
| 133 |
+
>
|
| 134 |
+
←
|
| 135 |
+
</Link>
|
| 136 |
+
<div>
|
| 137 |
+
<h1 className="text-xl font-semibold">Knowledge Base Library</h1>
|
| 138 |
+
<p className="text-xs text-slate-400">
|
| 139 |
+
All ingested documents, PDFs, FAQs, links, and text content
|
| 140 |
+
</p>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
<div className="flex items-center gap-3">
|
| 144 |
+
<input
|
| 145 |
+
value={tenantId}
|
| 146 |
+
onChange={(e) => setTenantId(e.target.value)}
|
| 147 |
+
placeholder="Tenant ID"
|
| 148 |
+
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 149 |
+
/>
|
| 150 |
+
<button
|
| 151 |
+
onClick={loadDocuments}
|
| 152 |
+
disabled={loading}
|
| 153 |
+
className="rounded-full bg-gradient-to-r from-sky-400 to-cyan-500 px-5 py-2 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:opacity-60"
|
| 154 |
+
>
|
| 155 |
+
{loading ? "Loading…" : "Refresh"}
|
| 156 |
+
</button>
|
| 157 |
+
</div>
|
| 158 |
+
</header>
|
| 159 |
+
|
| 160 |
+
{/* Stats & Filters */}
|
| 161 |
+
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/40 p-6">
|
| 162 |
+
<div className="flex flex-wrap items-center gap-6">
|
| 163 |
+
<div>
|
| 164 |
+
<p className="text-xs uppercase tracking-widest text-slate-400">
|
| 165 |
+
Total Documents
|
| 166 |
+
</p>
|
| 167 |
+
<p className="mt-1 text-2xl font-semibold text-white">{total}</p>
|
| 168 |
+
</div>
|
| 169 |
+
<div>
|
| 170 |
+
<p className="text-xs uppercase tracking-widest text-slate-400">
|
| 171 |
+
Filtered
|
| 172 |
+
</p>
|
| 173 |
+
<p className="mt-1 text-2xl font-semibold text-cyan-300">
|
| 174 |
+
{filteredDocuments.length}
|
| 175 |
+
</p>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 180 |
+
<input
|
| 181 |
+
type="text"
|
| 182 |
+
placeholder="Search documents..."
|
| 183 |
+
value={searchFilter}
|
| 184 |
+
onChange={(e) => setSearchFilter(e.target.value)}
|
| 185 |
+
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white outline-none focus:border-cyan-300"
|
| 186 |
+
/>
|
| 187 |
+
<div className="flex gap-2">
|
| 188 |
+
{(["all", "text", "pdf", "faq", "link"] as const).map((type) => (
|
| 189 |
+
<button
|
| 190 |
+
key={type}
|
| 191 |
+
onClick={() => setFilterType(type)}
|
| 192 |
+
className={`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-wider transition ${
|
| 193 |
+
filterType === type
|
| 194 |
+
? "bg-cyan-500 text-slate-950"
|
| 195 |
+
: "bg-white/5 text-slate-300 hover:bg-white/10"
|
| 196 |
+
}`}
|
| 197 |
+
>
|
| 198 |
+
{type}
|
| 199 |
+
</button>
|
| 200 |
+
))}
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
{/* Error Message */}
|
| 206 |
+
{error && (
|
| 207 |
+
<div className="rounded-2xl border border-red-500/40 bg-red-500/10 px-6 py-4 text-red-200">
|
| 208 |
+
<p className="font-semibold">Error loading knowledge base</p>
|
| 209 |
+
<p className="mt-1 text-sm">{error}</p>
|
| 210 |
+
</div>
|
| 211 |
+
)}
|
| 212 |
+
|
| 213 |
+
{/* Documents Grid */}
|
| 214 |
+
{loading ? (
|
| 215 |
+
<div className="flex items-center justify-center py-20">
|
| 216 |
+
<p className="text-slate-400">Loading documents...</p>
|
| 217 |
+
</div>
|
| 218 |
+
) : filteredDocuments.length === 0 ? (
|
| 219 |
+
<div className="flex flex-col items-center justify-center rounded-2xl border border-white/10 bg-slate-950/40 py-20">
|
| 220 |
+
<p className="text-lg font-semibold text-slate-300">
|
| 221 |
+
No documents found
|
| 222 |
+
</p>
|
| 223 |
+
<p className="mt-2 text-sm text-slate-400">
|
| 224 |
+
{documents.length === 0
|
| 225 |
+
? "Start by ingesting some content in the Knowledge Base panel."
|
| 226 |
+
: "Try adjusting your search or filter criteria."}
|
| 227 |
+
</p>
|
| 228 |
+
</div>
|
| 229 |
+
) : (
|
| 230 |
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
| 231 |
+
{filteredDocuments.map((doc) => {
|
| 232 |
+
const docType = getDocumentType(doc.text);
|
| 233 |
+
const preview = doc.text.slice(0, 200) + (doc.text.length > 200 ? "..." : "");
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<div
|
| 237 |
+
key={doc.id}
|
| 238 |
+
className="group relative rounded-2xl border border-white/10 bg-slate-950/40 p-5 transition hover:border-cyan-500/50 hover:bg-slate-900/60"
|
| 239 |
+
>
|
| 240 |
+
<div className="mb-3 flex items-start justify-between gap-2">
|
| 241 |
+
<span
|
| 242 |
+
className={`rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-wider ${getTypeColor(
|
| 243 |
+
docType,
|
| 244 |
+
)}`}
|
| 245 |
+
>
|
| 246 |
+
{docType}
|
| 247 |
+
</span>
|
| 248 |
+
{doc.created_at && (
|
| 249 |
+
<span className="text-xs text-slate-500">
|
| 250 |
+
{new Date(doc.created_at).toLocaleDateString()}
|
| 251 |
+
</span>
|
| 252 |
+
)}
|
| 253 |
+
</div>
|
| 254 |
+
<p className="text-sm leading-relaxed text-slate-200 line-clamp-6">
|
| 255 |
+
{preview}
|
| 256 |
+
</p>
|
| 257 |
+
<div className="mt-4 flex items-center gap-2 text-xs text-slate-400">
|
| 258 |
+
<span>ID: {doc.id}</span>
|
| 259 |
+
<span>•</span>
|
| 260 |
+
<span>{doc.text.length} chars</span>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
);
|
| 264 |
+
})}
|
| 265 |
+
</div>
|
| 266 |
+
)}
|
| 267 |
+
|
| 268 |
+
{/* Footer */}
|
| 269 |
+
<div className="mt-8 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-center text-sm text-slate-400">
|
| 270 |
+
<p>
|
| 271 |
+
Knowledge base powered by pgvector + MiniLM embeddings •{" "}
|
| 272 |
+
<Link href="/" className="text-cyan-300 hover:text-cyan-200">
|
| 273 |
+
Back to Console
|
| 274 |
+
</Link>
|
| 275 |
+
</p>
|
| 276 |
+
</div>
|
| 277 |
+
</main>
|
| 278 |
+
);
|
| 279 |
+
}
|
| 280 |
+
|
frontend/app/layout.tsx
CHANGED
|
@@ -13,8 +13,9 @@ const geistMono = Geist_Mono({
|
|
| 13 |
});
|
| 14 |
|
| 15 |
export const metadata: Metadata = {
|
| 16 |
-
title: "
|
| 17 |
-
description:
|
|
|
|
| 18 |
};
|
| 19 |
|
| 20 |
export default function RootLayout({
|
|
|
|
| 13 |
});
|
| 14 |
|
| 15 |
export const metadata: Metadata = {
|
| 16 |
+
title: "IntegraChat | Multi-Agent Governance Console",
|
| 17 |
+
description:
|
| 18 |
+
"Operate the IntegraChat MCP stack with live chat, knowledge ingestion, and compliance analytics.",
|
| 19 |
};
|
| 20 |
|
| 21 |
export default function RootLayout({
|
frontend/app/page.tsx
CHANGED
|
@@ -1,65 +1,31 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export default function Home() {
|
| 4 |
return (
|
| 5 |
-
<
|
| 6 |
-
<
|
| 7 |
-
<
|
| 8 |
-
className="
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
height={20}
|
| 13 |
-
priority
|
| 14 |
-
/>
|
| 15 |
-
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
| 16 |
-
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
| 17 |
-
To get started, edit the page.tsx file.
|
| 18 |
-
</h1>
|
| 19 |
-
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
| 20 |
-
Looking for a starting point or more instructions? Head over to{" "}
|
| 21 |
-
<a
|
| 22 |
-
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
| 23 |
-
className="font-medium text-zinc-950 dark:text-zinc-50"
|
| 24 |
-
>
|
| 25 |
-
Templates
|
| 26 |
-
</a>{" "}
|
| 27 |
-
or the{" "}
|
| 28 |
-
<a
|
| 29 |
-
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
| 30 |
-
className="font-medium text-zinc-950 dark:text-zinc-50"
|
| 31 |
-
>
|
| 32 |
-
Learning
|
| 33 |
-
</a>{" "}
|
| 34 |
-
center.
|
| 35 |
-
</p>
|
| 36 |
</div>
|
| 37 |
-
<div className="flex flex-
|
| 38 |
-
|
| 39 |
-
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
| 40 |
-
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
| 41 |
-
target="_blank"
|
| 42 |
-
rel="noopener noreferrer"
|
| 43 |
-
>
|
| 44 |
-
<Image
|
| 45 |
-
className="dark:invert"
|
| 46 |
-
src="/vercel.svg"
|
| 47 |
-
alt="Vercel logomark"
|
| 48 |
-
width={16}
|
| 49 |
-
height={16}
|
| 50 |
-
/>
|
| 51 |
-
Deploy Now
|
| 52 |
-
</a>
|
| 53 |
-
<a
|
| 54 |
-
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
| 55 |
-
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
| 56 |
-
target="_blank"
|
| 57 |
-
rel="noopener noreferrer"
|
| 58 |
-
>
|
| 59 |
-
Documentation
|
| 60 |
-
</a>
|
| 61 |
</div>
|
| 62 |
-
</
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
);
|
| 65 |
}
|
|
|
|
| 1 |
+
import { AnalyticsPanel } from "@/components/analytics-panel";
|
| 2 |
+
import { ChatPanel } from "@/components/chat-panel";
|
| 3 |
+
import { FeatureGrid } from "@/components/feature-grid";
|
| 4 |
+
import { Footer } from "@/components/footer";
|
| 5 |
+
import { Hero } from "@/components/hero";
|
| 6 |
+
import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
|
| 7 |
|
| 8 |
export default function Home() {
|
| 9 |
return (
|
| 10 |
+
<main className="mx-auto flex min-h-screen max-w-6xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
| 11 |
+
<header className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-sm text-slate-100 shadow-lg shadow-slate-950/40">
|
| 12 |
+
<div className="flex items-center gap-3 text-base font-semibold">
|
| 13 |
+
<span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
|
| 14 |
+
IC
|
| 15 |
+
</span>
|
| 16 |
+
IntegraChat Operator Console
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
</div>
|
| 18 |
+
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.4em] text-slate-400">
|
| 19 |
+
FastAPI · MCP Servers · Celery · Next.js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</div>
|
| 21 |
+
</header>
|
| 22 |
+
|
| 23 |
+
<Hero />
|
| 24 |
+
<FeatureGrid />
|
| 25 |
+
<KnowledgeBasePanel />
|
| 26 |
+
<ChatPanel />
|
| 27 |
+
<AnalyticsPanel />
|
| 28 |
+
<Footer />
|
| 29 |
+
</main>
|
| 30 |
);
|
| 31 |
}
|
frontend/components/analytics-panel.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
|
| 5 |
+
type AnalyticsOverview = {
|
| 6 |
+
overview: {
|
| 7 |
+
total_queries: number;
|
| 8 |
+
tool_usage: Record<string, number>;
|
| 9 |
+
redflag_count: number;
|
| 10 |
+
active_users: number;
|
| 11 |
+
};
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const API_BASE =
|
| 15 |
+
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 16 |
+
|
| 17 |
+
export function AnalyticsPanel() {
|
| 18 |
+
const [tenantId, setTenantId] = useState("tenant123");
|
| 19 |
+
const [loading, setLoading] = useState(false);
|
| 20 |
+
const [error, setError] = useState<string | null>(null);
|
| 21 |
+
const [data, setData] = useState<AnalyticsOverview["overview"] | null>(null);
|
| 22 |
+
|
| 23 |
+
async function fetchAnalytics() {
|
| 24 |
+
setLoading(true);
|
| 25 |
+
setError(null);
|
| 26 |
+
try {
|
| 27 |
+
const res = await fetch(`${API_BASE}/analytics/overview`, {
|
| 28 |
+
headers: {
|
| 29 |
+
"x-tenant-id": tenantId,
|
| 30 |
+
},
|
| 31 |
+
});
|
| 32 |
+
if (!res.ok) {
|
| 33 |
+
throw new Error(`Analytics endpoint returned ${res.status}`);
|
| 34 |
+
}
|
| 35 |
+
const payload: AnalyticsOverview = await res.json();
|
| 36 |
+
setData(payload.overview);
|
| 37 |
+
} catch (err) {
|
| 38 |
+
console.error(err);
|
| 39 |
+
setError(
|
| 40 |
+
err instanceof Error
|
| 41 |
+
? err.message
|
| 42 |
+
: "Unable to reach analytics API. Is the FastAPI service running?",
|
| 43 |
+
);
|
| 44 |
+
} finally {
|
| 45 |
+
setLoading(false);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<section
|
| 51 |
+
id="analytics"
|
| 52 |
+
className="glass-panel border border-white/10 p-6 text-white"
|
| 53 |
+
>
|
| 54 |
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
| 55 |
+
<div>
|
| 56 |
+
<p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
|
| 57 |
+
Compliance Pulse
|
| 58 |
+
</p>
|
| 59 |
+
<h2 className="mt-2 text-3xl font-semibold">Analytics snapshot</h2>
|
| 60 |
+
</div>
|
| 61 |
+
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
| 62 |
+
<label className="text-xs uppercase tracking-widest text-slate-400">
|
| 63 |
+
Tenant ID
|
| 64 |
+
</label>
|
| 65 |
+
<input
|
| 66 |
+
value={tenantId}
|
| 67 |
+
onChange={(e) => setTenantId(e.target.value)}
|
| 68 |
+
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 69 |
+
/>
|
| 70 |
+
</div>
|
| 71 |
+
<button
|
| 72 |
+
onClick={fetchAnalytics}
|
| 73 |
+
disabled={loading}
|
| 74 |
+
className="rounded-full bg-white/90 px-5 py-2.5 text-sm font-semibold text-slate-900 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:opacity-60"
|
| 75 |
+
>
|
| 76 |
+
{loading ? "Loading…" : "Refresh metrics"}
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div className="mt-6 grid gap-4 md:grid-cols-4">
|
| 81 |
+
{["total_queries", "active_users", "redflag_count"].map((key) => (
|
| 82 |
+
<div
|
| 83 |
+
key={key}
|
| 84 |
+
className="rounded-2xl border border-white/5 bg-slate-900/40 p-4 text-center"
|
| 85 |
+
>
|
| 86 |
+
<p className="text-sm uppercase tracking-widest text-slate-400">
|
| 87 |
+
{key.replace("_", " ")}
|
| 88 |
+
</p>
|
| 89 |
+
<p className="mt-2 text-3xl font-semibold">
|
| 90 |
+
{data ? data[key as keyof typeof data] : "—"}
|
| 91 |
+
</p>
|
| 92 |
+
</div>
|
| 93 |
+
))}
|
| 94 |
+
<div className="rounded-2xl border border-white/5 bg-slate-900/40 p-4 text-center">
|
| 95 |
+
<p className="text-sm uppercase tracking-widest text-slate-400">
|
| 96 |
+
Tool usage (top)
|
| 97 |
+
</p>
|
| 98 |
+
<p className="mt-2 text-3xl font-semibold">
|
| 99 |
+
{data
|
| 100 |
+
? Object.entries(data.tool_usage)
|
| 101 |
+
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? "—"
|
| 102 |
+
: "—"}
|
| 103 |
+
</p>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div className="mt-6 rounded-2xl border border-white/5 bg-slate-950/50 p-4">
|
| 108 |
+
<p className="text-sm uppercase tracking-[0.5em] text-slate-400">
|
| 109 |
+
Raw tool usage
|
| 110 |
+
</p>
|
| 111 |
+
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
| 112 |
+
{data
|
| 113 |
+
? Object.entries(data.tool_usage).map(([tool, count]) => (
|
| 114 |
+
<div
|
| 115 |
+
key={tool}
|
| 116 |
+
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3"
|
| 117 |
+
>
|
| 118 |
+
<p className="text-sm uppercase tracking-widest text-slate-400">
|
| 119 |
+
{tool}
|
| 120 |
+
</p>
|
| 121 |
+
<p className="text-2xl font-semibold text-white">{count}</p>
|
| 122 |
+
</div>
|
| 123 |
+
))
|
| 124 |
+
: Array.from({ length: 3 }).map((_, idx) => (
|
| 125 |
+
<div
|
| 126 |
+
key={idx}
|
| 127 |
+
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-slate-500"
|
| 128 |
+
>
|
| 129 |
+
<p className="text-sm uppercase tracking-widest text-slate-500">
|
| 130 |
+
Tool {idx + 1}
|
| 131 |
+
</p>
|
| 132 |
+
<p className="text-2xl font-semibold text-slate-500">—</p>
|
| 133 |
+
</div>
|
| 134 |
+
))}
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{error && (
|
| 139 |
+
<p className="mt-4 rounded-2xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
| 140 |
+
{error}
|
| 141 |
+
</p>
|
| 142 |
+
)}
|
| 143 |
+
</section>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
frontend/components/chat-panel.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useMemo, useState } from "react";
|
| 4 |
+
|
| 5 |
+
type Message = {
|
| 6 |
+
role: "user" | "assistant" | "system";
|
| 7 |
+
content: string;
|
| 8 |
+
meta?: string;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const API_BASE =
|
| 12 |
+
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 13 |
+
|
| 14 |
+
export function ChatPanel() {
|
| 15 |
+
const [tenantId, setTenantId] = useState("tenant123");
|
| 16 |
+
const [message, setMessage] = useState("");
|
| 17 |
+
const [isSending, setIsSending] = useState(false);
|
| 18 |
+
const [history, setHistory] = useState<Message[]>([
|
| 19 |
+
{
|
| 20 |
+
role: "assistant",
|
| 21 |
+
content:
|
| 22 |
+
"Hi there! I’m the IntegraChat orchestrator. Ask anything about your tenant data and I will route the right MCP tools.",
|
| 23 |
+
meta: "Agent ready",
|
| 24 |
+
},
|
| 25 |
+
]);
|
| 26 |
+
const [lastDecision, setLastDecision] = useState<string | null>(null);
|
| 27 |
+
|
| 28 |
+
const conversationPayload = useMemo(
|
| 29 |
+
() =>
|
| 30 |
+
history
|
| 31 |
+
.filter((m) => m.role !== "system")
|
| 32 |
+
.map((m) => ({
|
| 33 |
+
role: m.role,
|
| 34 |
+
content: m.content,
|
| 35 |
+
})),
|
| 36 |
+
[history],
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
async function handleSend() {
|
| 40 |
+
if (!message.trim() || isSending) return;
|
| 41 |
+
const userMessage: Message = { role: "user", content: message.trim() };
|
| 42 |
+
const optimisticHistory = [...history, userMessage];
|
| 43 |
+
setHistory(optimisticHistory);
|
| 44 |
+
setMessage("");
|
| 45 |
+
setIsSending(true);
|
| 46 |
+
|
| 47 |
+
try {
|
| 48 |
+
const response = await fetch(`${API_BASE}/agent/message`, {
|
| 49 |
+
method: "POST",
|
| 50 |
+
headers: {
|
| 51 |
+
"Content-Type": "application/json",
|
| 52 |
+
},
|
| 53 |
+
body: JSON.stringify({
|
| 54 |
+
tenant_id: tenantId,
|
| 55 |
+
message: userMessage.content,
|
| 56 |
+
conversation_history: conversationPayload,
|
| 57 |
+
temperature: 0,
|
| 58 |
+
}),
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
if (!response.ok) {
|
| 62 |
+
throw new Error(
|
| 63 |
+
`API error (${response.status}) – check backend/api/main.py`,
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const data = await response.json();
|
| 68 |
+
const assistantText =
|
| 69 |
+
data?.text ??
|
| 70 |
+
"Agent responded but text field was empty. Inspect FastAPI logs for clues.";
|
| 71 |
+
setHistory((prev) => [
|
| 72 |
+
...prev,
|
| 73 |
+
{
|
| 74 |
+
role: "assistant",
|
| 75 |
+
content: assistantText,
|
| 76 |
+
meta: data?.decision?.reason ?? "response",
|
| 77 |
+
},
|
| 78 |
+
]);
|
| 79 |
+
setLastDecision(
|
| 80 |
+
data?.decision
|
| 81 |
+
? `${data.decision.action} · ${data.decision.tool ?? "llm"}`
|
| 82 |
+
: null,
|
| 83 |
+
);
|
| 84 |
+
} catch (err) {
|
| 85 |
+
console.error(err);
|
| 86 |
+
setHistory((prev) => [
|
| 87 |
+
...prev,
|
| 88 |
+
{
|
| 89 |
+
role: "assistant",
|
| 90 |
+
content:
|
| 91 |
+
err instanceof Error
|
| 92 |
+
? err.message
|
| 93 |
+
: "Failed to reach the FastAPI gateway.",
|
| 94 |
+
meta: "error",
|
| 95 |
+
},
|
| 96 |
+
]);
|
| 97 |
+
setLastDecision("error");
|
| 98 |
+
} finally {
|
| 99 |
+
setIsSending(false);
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
return (
|
| 104 |
+
<section
|
| 105 |
+
id="chat"
|
| 106 |
+
className="gradient-border relative rounded-[28px] p-1 text-white"
|
| 107 |
+
>
|
| 108 |
+
<div className="glass-panel relative rounded-[26px] p-6">
|
| 109 |
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
| 110 |
+
<div>
|
| 111 |
+
<p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
|
| 112 |
+
Orchestrator Console
|
| 113 |
+
</p>
|
| 114 |
+
<h2 className="mt-2 text-3xl font-semibold">
|
| 115 |
+
Talk to your enterprise agent
|
| 116 |
+
</h2>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
| 119 |
+
<label className="text-xs uppercase tracking-widest text-slate-400">
|
| 120 |
+
Tenant ID
|
| 121 |
+
</label>
|
| 122 |
+
<input
|
| 123 |
+
value={tenantId}
|
| 124 |
+
onChange={(e) => setTenantId(e.target.value)}
|
| 125 |
+
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 126 |
+
/>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="mt-6 h-[360px] space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-slate-950/40 p-4 scrollArea">
|
| 130 |
+
{history.map((msg, idx) => (
|
| 131 |
+
<div
|
| 132 |
+
key={`${msg.role}-${idx}`}
|
| 133 |
+
className={`flex gap-3 rounded-2xl px-4 py-3 ${
|
| 134 |
+
msg.role === "user"
|
| 135 |
+
? "bg-slate-900/70 text-slate-100"
|
| 136 |
+
: "bg-cyan-500/10 text-slate-100"
|
| 137 |
+
}`}
|
| 138 |
+
>
|
| 139 |
+
<span className="text-xs font-semibold uppercase tracking-widest text-cyan-200/80">
|
| 140 |
+
{msg.role}
|
| 141 |
+
</span>
|
| 142 |
+
<div className="space-y-1 text-sm">
|
| 143 |
+
<p>{msg.content}</p>
|
| 144 |
+
{msg.meta && (
|
| 145 |
+
<p className="text-xs text-slate-400">{msg.meta}</p>
|
| 146 |
+
)}
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
))}
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div className="mt-5 flex flex-col gap-3 md:flex-row">
|
| 153 |
+
<textarea
|
| 154 |
+
placeholder="Ask about policies, knowledge base hits, or route through RAG/Web/Admin..."
|
| 155 |
+
value={message}
|
| 156 |
+
onChange={(e) => setMessage(e.target.value)}
|
| 157 |
+
className="flex-1 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
|
| 158 |
+
rows={3}
|
| 159 |
+
/>
|
| 160 |
+
<button
|
| 161 |
+
onClick={handleSend}
|
| 162 |
+
disabled={isSending}
|
| 163 |
+
className="min-w-[160px] rounded-2xl bg-gradient-to-r from-sky-400 to-cyan-500 px-6 py-3 font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
|
| 164 |
+
>
|
| 165 |
+
{isSending ? "Routing…" : "Send to MCP"}
|
| 166 |
+
</button>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<div className="mt-4 flex items-center gap-3 text-sm text-slate-300">
|
| 170 |
+
<span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_12px_#34d399]" />
|
| 171 |
+
{lastDecision
|
| 172 |
+
? `Last decision: ${lastDecision}`
|
| 173 |
+
: "No tool invocation yet"}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</section>
|
| 177 |
+
);
|
| 178 |
+
}
|
| 179 |
+
|
frontend/components/feature-grid.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const features = [
|
| 2 |
+
{
|
| 3 |
+
title: "Agent Orchestrator",
|
| 4 |
+
description:
|
| 5 |
+
"Observability for the FastAPI orchestrator: intents, tool routing, red-flag blocking, and reasoning traces.",
|
| 6 |
+
tag: "backend/api/services/agent_orchestrator.py",
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
title: "Knowledge RAG MCP",
|
| 10 |
+
description:
|
| 11 |
+
"Ingest docs, embed with MiniLM, and search tenant-scoped corpora via pgvector—all from the UI.",
|
| 12 |
+
tag: "backend/mcp_servers/main.py",
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
title: "Governance Policies",
|
| 16 |
+
description:
|
| 17 |
+
"Create, test, and ship regex + semantic rule packs that instantly sync to Admin MCP and Celery alerts.",
|
| 18 |
+
tag: "backend/api/services/redflag_detector.py",
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
title: "Analytics + Workers",
|
| 22 |
+
description:
|
| 23 |
+
"Monitor Celery ingestion throughput, tool usage trends, and daily compliance KPIs in one glance.",
|
| 24 |
+
tag: "backend/workers/*",
|
| 25 |
+
},
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
export function FeatureGrid() {
|
| 29 |
+
return (
|
| 30 |
+
<section className="grid gap-6 md:grid-cols-2">
|
| 31 |
+
{features.map((feature) => (
|
| 32 |
+
<article
|
| 33 |
+
key={feature.title}
|
| 34 |
+
className="glass-panel flex flex-col justify-between p-6 transition hover:-translate-y-1 hover:border-cyan-300/50"
|
| 35 |
+
>
|
| 36 |
+
<div>
|
| 37 |
+
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">
|
| 38 |
+
{feature.tag}
|
| 39 |
+
</p>
|
| 40 |
+
<h3 className="mt-4 text-2xl font-semibold text-white">
|
| 41 |
+
{feature.title}
|
| 42 |
+
</h3>
|
| 43 |
+
<p className="mt-4 text-base text-slate-300">
|
| 44 |
+
{feature.description}
|
| 45 |
+
</p>
|
| 46 |
+
</div>
|
| 47 |
+
<div className="mt-6 inline-flex items-center gap-2 text-sm text-cyan-200">
|
| 48 |
+
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400" />
|
| 49 |
+
Ready to run
|
| 50 |
+
</div>
|
| 51 |
+
</article>
|
| 52 |
+
))}
|
| 53 |
+
</section>
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
frontend/components/footer.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function Footer() {
|
| 2 |
+
return (
|
| 3 |
+
<footer className="mt-16 flex flex-col items-center gap-3 border-t border-white/5 py-8 text-center text-sm text-slate-400">
|
| 4 |
+
<p>
|
| 5 |
+
IntegraChat · FastAPI + Next.js reference console for multi-agent MCP
|
| 6 |
+
stacks.
|
| 7 |
+
</p>
|
| 8 |
+
<p className="text-xs">
|
| 9 |
+
Need backend ports? API 8000 · RAG 8001 · Web 8002 · Admin 8003 · update
|
| 10 |
+
env via <code className="rounded bg-white/5 px-1">NEXT_PUBLIC_API_URL</code>
|
| 11 |
+
</p>
|
| 12 |
+
</footer>
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
frontend/components/hero.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
|
| 3 |
+
const stats = [
|
| 4 |
+
{ label: "Multi-tenant agents", value: "3 MCPs" },
|
| 5 |
+
{ label: "Policies enforced", value: "128 rules" },
|
| 6 |
+
{ label: "Avg. response time", value: "1.8s" },
|
| 7 |
+
];
|
| 8 |
+
|
| 9 |
+
export function Hero() {
|
| 10 |
+
return (
|
| 11 |
+
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-gradient-to-br from-slate-900 via-slate-900/70 to-cyan-900/40 p-10 text-white shadow-2xl">
|
| 12 |
+
<div className="grid gap-12 md:grid-cols-[1.2fr,0.8fr]">
|
| 13 |
+
<div className="space-y-8">
|
| 14 |
+
<span className="badge">
|
| 15 |
+
<span className="h-2 w-2 rounded-full bg-cyan-400" />
|
| 16 |
+
realtime oversight
|
| 17 |
+
</span>
|
| 18 |
+
<h1 className="text-4xl font-semibold leading-tight md:text-5xl">
|
| 19 |
+
Run chat agents, red-flag governance, and analytics from a single
|
| 20 |
+
console.
|
| 21 |
+
</h1>
|
| 22 |
+
<p className="text-lg text-slate-200">
|
| 23 |
+
IntegraChat brings together the FastAPI backend, MCP tool servers,
|
| 24 |
+
and compliance automation into a cohesive operator experience.
|
| 25 |
+
Trigger conversations, inspect tool traces, and stream policy
|
| 26 |
+
alerts—without leaving the browser.
|
| 27 |
+
</p>
|
| 28 |
+
<div className="flex flex-wrap gap-4 text-base font-medium">
|
| 29 |
+
<Link
|
| 30 |
+
href="#chat"
|
| 31 |
+
className="rounded-full bg-white/90 px-6 py-3 text-slate-900 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 hover:bg-white"
|
| 32 |
+
>
|
| 33 |
+
Launch chat workspace
|
| 34 |
+
</Link>
|
| 35 |
+
<Link
|
| 36 |
+
href="#analytics"
|
| 37 |
+
className="rounded-full border border-white/30 px-6 py-3 text-white transition hover:border-cyan-300/70 hover:text-cyan-100"
|
| 38 |
+
>
|
| 39 |
+
View governance metrics
|
| 40 |
+
</Link>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<div className="glass-panel p-6">
|
| 44 |
+
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">
|
| 45 |
+
Stack Snapshot
|
| 46 |
+
</p>
|
| 47 |
+
<ul className="mt-6 space-y-4 text-sm text-slate-100">
|
| 48 |
+
<li className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
|
| 49 |
+
<div>
|
| 50 |
+
<p className="text-xs uppercase tracking-wider text-slate-300">
|
| 51 |
+
API Gateway
|
| 52 |
+
</p>
|
| 53 |
+
<p className="font-semibold text-white">FastAPI 0.110 + CORS</p>
|
| 54 |
+
</div>
|
| 55 |
+
<span className="text-xs text-slate-300">backend/api</span>
|
| 56 |
+
</li>
|
| 57 |
+
<li className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
|
| 58 |
+
<div>
|
| 59 |
+
<p className="text-xs uppercase tracking-wider text-slate-300">
|
| 60 |
+
MCP Servers
|
| 61 |
+
</p>
|
| 62 |
+
<p className="font-semibold text-white">
|
| 63 |
+
RAG · Web · Admin policy
|
| 64 |
+
</p>
|
| 65 |
+
</div>
|
| 66 |
+
<span className="text-xs text-slate-300">ports 8001-8003</span>
|
| 67 |
+
</li>
|
| 68 |
+
<li className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
|
| 69 |
+
<div>
|
| 70 |
+
<p className="text-xs uppercase tracking-wider text-slate-300">
|
| 71 |
+
Workers
|
| 72 |
+
</p>
|
| 73 |
+
<p className="font-semibold text-white">
|
| 74 |
+
Celery ingestion + analytics
|
| 75 |
+
</p>
|
| 76 |
+
</div>
|
| 77 |
+
<span className="text-xs text-slate-300">beat + workers</span>
|
| 78 |
+
</li>
|
| 79 |
+
</ul>
|
| 80 |
+
<div className="mt-6 grid gap-4 rounded-2xl border border-white/10 bg-slate-900/30 p-5 text-center sm:grid-cols-3">
|
| 81 |
+
{stats.map((stat) => (
|
| 82 |
+
<div key={stat.label}>
|
| 83 |
+
<p className="text-2xl font-semibold text-white">
|
| 84 |
+
{stat.value}
|
| 85 |
+
</p>
|
| 86 |
+
<p className="text-xs uppercase tracking-wider text-slate-400">
|
| 87 |
+
{stat.label}
|
| 88 |
+
</p>
|
| 89 |
+
</div>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="pointer-events-none absolute inset-0 opacity-40">
|
| 95 |
+
<div className="grid-fade" />
|
| 96 |
+
</div>
|
| 97 |
+
</section>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
frontend/components/ingestion-card.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const steps = [
|
| 2 |
+
{
|
| 3 |
+
title: "Chunk & Embed",
|
| 4 |
+
detail:
|
| 5 |
+
"Uploads land in Celery ingestion workers, chunked to 800 chars with 100 overlap, and embedded via MiniLM or hash fallback.",
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
title: "Supabase / pgvector",
|
| 9 |
+
detail:
|
| 10 |
+
"Chunks upsert into tenant-scoped tables with metadata, ready for RAG MCP retrieval.",
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
title: "Quality Tasks",
|
| 14 |
+
detail:
|
| 15 |
+
"Nightly analytics + RAG precision@k jobs run through Celery beat (`scheduler.py`).",
|
| 16 |
+
},
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
export function IngestionCard() {
|
| 20 |
+
return (
|
| 21 |
+
<section className="glass-panel border border-white/10 p-6 text-white">
|
| 22 |
+
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
| 23 |
+
<div>
|
| 24 |
+
<p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
|
| 25 |
+
Knowledge Ops
|
| 26 |
+
</p>
|
| 27 |
+
<h2 className="mt-2 text-3xl font-semibold">Ingestion pipeline</h2>
|
| 28 |
+
<p className="mt-4 text-base text-slate-300">
|
| 29 |
+
Drop PDFs, DOCX, MD, or direct raw text and let Celery handle the
|
| 30 |
+
rest. Every step mirrors the backend implementation in
|
| 31 |
+
`backend/workers/ingestion_worker.py`, so what you see locally is
|
| 32 |
+
what ships to production.
|
| 33 |
+
</p>
|
| 34 |
+
</div>
|
| 35 |
+
<div className="rounded-full border border-white/10 bg-white/10 px-4 py-2 text-sm text-slate-100">
|
| 36 |
+
Celery broker / beat ready
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
<ol className="mt-6 grid gap-4 md:grid-cols-3">
|
| 40 |
+
{steps.map((step, idx) => (
|
| 41 |
+
<li
|
| 42 |
+
key={step.title}
|
| 43 |
+
className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"
|
| 44 |
+
>
|
| 45 |
+
<p className="text-xs uppercase tracking-[0.4em] text-slate-400">
|
| 46 |
+
Step {idx + 1}
|
| 47 |
+
</p>
|
| 48 |
+
<h3 className="mt-2 text-xl font-semibold">{step.title}</h3>
|
| 49 |
+
<p className="mt-2 text-sm text-slate-300">{step.detail}</p>
|
| 50 |
+
</li>
|
| 51 |
+
))}
|
| 52 |
+
</ol>
|
| 53 |
+
</section>
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
frontend/components/knowledge-base-panel.tsx
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
|
| 6 |
+
type SearchResult = {
|
| 7 |
+
text: string;
|
| 8 |
+
similarity?: number;
|
| 9 |
+
relevance?: number;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const API_BASE =
|
| 13 |
+
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
| 14 |
+
|
| 15 |
+
export function KnowledgeBasePanel() {
|
| 16 |
+
const [tenantId, setTenantId] = useState("tenant123");
|
| 17 |
+
const [searchQuery, setSearchQuery] = useState("");
|
| 18 |
+
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
| 19 |
+
const [isSearching, setIsSearching] = useState(false);
|
| 20 |
+
const [ingestContent, setIngestContent] = useState("");
|
| 21 |
+
const [isIngesting, setIsIngesting] = useState(false);
|
| 22 |
+
const [ingestStatus, setIngestStatus] = useState<string | null>(null);
|
| 23 |
+
const [searchError, setSearchError] = useState<string | null>(null);
|
| 24 |
+
|
| 25 |
+
async function handleSearch() {
|
| 26 |
+
if (!searchQuery.trim() || isSearching) return;
|
| 27 |
+
setIsSearching(true);
|
| 28 |
+
setSearchError(null);
|
| 29 |
+
setSearchResults([]);
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const response = await fetch(`${API_BASE}/rag/search`, {
|
| 33 |
+
method: "POST",
|
| 34 |
+
headers: {
|
| 35 |
+
"Content-Type": "application/json",
|
| 36 |
+
"x-tenant-id": tenantId,
|
| 37 |
+
},
|
| 38 |
+
body: JSON.stringify({ query: searchQuery }),
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
if (!response.ok) {
|
| 42 |
+
throw new Error(`Search failed: ${response.status}`);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const data = await response.json();
|
| 46 |
+
setSearchResults(data.results || []);
|
| 47 |
+
} catch (err) {
|
| 48 |
+
console.error(err);
|
| 49 |
+
setSearchError(
|
| 50 |
+
err instanceof Error
|
| 51 |
+
? err.message
|
| 52 |
+
: "Failed to search knowledge base. Is the RAG MCP server running?",
|
| 53 |
+
);
|
| 54 |
+
} finally {
|
| 55 |
+
setIsSearching(false);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function handleIngest() {
|
| 60 |
+
if (!ingestContent.trim() || isIngesting) return;
|
| 61 |
+
setIsIngesting(true);
|
| 62 |
+
setIngestStatus(null);
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
const response = await fetch(`${API_BASE}/rag/ingest`, {
|
| 66 |
+
method: "POST",
|
| 67 |
+
headers: {
|
| 68 |
+
"Content-Type": "application/json",
|
| 69 |
+
"x-tenant-id": tenantId,
|
| 70 |
+
},
|
| 71 |
+
body: JSON.stringify({ content: ingestContent }),
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (!response.ok) {
|
| 75 |
+
throw new Error(`Ingestion failed: ${response.status}`);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const data = await response.json();
|
| 79 |
+
setIngestStatus(
|
| 80 |
+
`✅ Successfully ingested ${data.chunks_stored || 0} chunk(s)`,
|
| 81 |
+
);
|
| 82 |
+
setIngestContent("");
|
| 83 |
+
} catch (err) {
|
| 84 |
+
console.error(err);
|
| 85 |
+
setIngestStatus(
|
| 86 |
+
err instanceof Error
|
| 87 |
+
? `❌ Error: ${err.message}`
|
| 88 |
+
: "Failed to ingest content. Is the RAG MCP server running?",
|
| 89 |
+
);
|
| 90 |
+
} finally {
|
| 91 |
+
setIsIngesting(false);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return (
|
| 96 |
+
<section
|
| 97 |
+
id="knowledge-base"
|
| 98 |
+
className="gradient-border relative rounded-[28px] p-1 text-white"
|
| 99 |
+
>
|
| 100 |
+
<div className="glass-panel relative rounded-[26px] p-6">
|
| 101 |
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
| 102 |
+
<div>
|
| 103 |
+
<p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
|
| 104 |
+
Knowledge Base
|
| 105 |
+
</p>
|
| 106 |
+
<h2 className="mt-2 text-3xl font-semibold">
|
| 107 |
+
Search & ingest documents
|
| 108 |
+
</h2>
|
| 109 |
+
</div>
|
| 110 |
+
<Link
|
| 111 |
+
href="/knowledge-base"
|
| 112 |
+
className="rounded-full border border-cyan-500/50 bg-cyan-500/10 px-5 py-2.5 text-sm font-semibold text-cyan-300 transition hover:bg-cyan-500/20"
|
| 113 |
+
>
|
| 114 |
+
View All Documents →
|
| 115 |
+
</Link>
|
| 116 |
+
<div className="flex flex-col gap-2 text-sm text-slate-200">
|
| 117 |
+
<label className="text-xs uppercase tracking-widest text-slate-400">
|
| 118 |
+
Tenant ID
|
| 119 |
+
</label>
|
| 120 |
+
<input
|
| 121 |
+
value={tenantId}
|
| 122 |
+
onChange={(e) => setTenantId(e.target.value)}
|
| 123 |
+
className="rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-white outline-none focus:border-cyan-300"
|
| 124 |
+
/>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Search Section */}
|
| 129 |
+
<div className="mt-6">
|
| 130 |
+
<div className="flex flex-col gap-3 md:flex-row">
|
| 131 |
+
<input
|
| 132 |
+
type="text"
|
| 133 |
+
placeholder="Search knowledge base (e.g., 'HR policy', 'refund procedure')..."
|
| 134 |
+
value={searchQuery}
|
| 135 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 136 |
+
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
| 137 |
+
className="flex-1 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
|
| 138 |
+
/>
|
| 139 |
+
<button
|
| 140 |
+
onClick={handleSearch}
|
| 141 |
+
disabled={isSearching}
|
| 142 |
+
className="min-w-[140px] rounded-2xl bg-gradient-to-r from-sky-400 to-cyan-500 px-6 py-3 font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
|
| 143 |
+
>
|
| 144 |
+
{isSearching ? "Searching…" : "Search"}
|
| 145 |
+
</button>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
{searchError && (
|
| 149 |
+
<p className="mt-3 rounded-2xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
| 150 |
+
{searchError}
|
| 151 |
+
</p>
|
| 152 |
+
)}
|
| 153 |
+
|
| 154 |
+
{searchResults.length > 0 && (
|
| 155 |
+
<div className="mt-4 space-y-3">
|
| 156 |
+
<p className="text-sm uppercase tracking-widest text-slate-400">
|
| 157 |
+
Found {searchResults.length} result(s)
|
| 158 |
+
</p>
|
| 159 |
+
{searchResults.map((result, idx) => (
|
| 160 |
+
<div
|
| 161 |
+
key={idx}
|
| 162 |
+
className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"
|
| 163 |
+
>
|
| 164 |
+
<div className="flex items-start justify-between gap-3">
|
| 165 |
+
<p className="flex-1 text-sm text-slate-200">
|
| 166 |
+
{result.text}
|
| 167 |
+
</p>
|
| 168 |
+
{(result.similarity !== undefined ||
|
| 169 |
+
result.relevance !== undefined) && (
|
| 170 |
+
<span className="text-xs text-cyan-300">
|
| 171 |
+
{(
|
| 172 |
+
result.similarity ?? result.relevance ?? 0
|
| 173 |
+
).toFixed(2)}
|
| 174 |
+
</span>
|
| 175 |
+
)}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
))}
|
| 179 |
+
</div>
|
| 180 |
+
)}
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
{/* Ingest Section */}
|
| 184 |
+
<div className="mt-8 rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
| 185 |
+
<p className="text-sm uppercase tracking-[0.5em] text-slate-400">
|
| 186 |
+
Add to Knowledge Base
|
| 187 |
+
</p>
|
| 188 |
+
<p className="mt-2 text-sm text-slate-300">
|
| 189 |
+
Paste text content to ingest. It will be chunked, embedded, and
|
| 190 |
+
stored in your tenant's knowledge base.
|
| 191 |
+
</p>
|
| 192 |
+
<textarea
|
| 193 |
+
placeholder="Paste document content here (e.g., policy text, procedures, documentation)..."
|
| 194 |
+
value={ingestContent}
|
| 195 |
+
onChange={(e) => setIngestContent(e.target.value)}
|
| 196 |
+
className="mt-4 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
|
| 197 |
+
rows={6}
|
| 198 |
+
/>
|
| 199 |
+
<div className="mt-4 flex items-center gap-3">
|
| 200 |
+
<button
|
| 201 |
+
onClick={handleIngest}
|
| 202 |
+
disabled={isIngesting || !ingestContent.trim()}
|
| 203 |
+
className="rounded-2xl bg-gradient-to-r from-emerald-400 to-teal-500 px-6 py-2.5 font-semibold text-slate-950 shadow-lg shadow-emerald-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
|
| 204 |
+
>
|
| 205 |
+
{isIngesting ? "Ingesting…" : "Ingest Content"}
|
| 206 |
+
</button>
|
| 207 |
+
{ingestStatus && (
|
| 208 |
+
<p className="text-sm text-slate-300">{ingestStatus}</p>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</section>
|
| 214 |
+
);
|
| 215 |
+
}
|
| 216 |
+
|
frontend/package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|