nothingworry commited on
Commit
aa63765
·
1 Parent(s): ef83e66

feat: add knowledge base management and analytics dashboard

Browse files
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("/analytics/overview")
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("/analytics/tool-usage")
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("/analytics/redflags")
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("/analytics/activity")
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
- @router.post("/rag/search")
 
 
 
 
 
 
 
 
9
  async def rag_search(
10
- query: str,
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
- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
 
3
- ## Getting Started
 
4
 
5
- First, run the development server:
 
 
 
 
 
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
- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
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
- To learn more about Next.js, take a look at the following resources:
26
 
27
- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
 
30
- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
-
32
- ## Deploy on Vercel
33
 
34
- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
 
36
- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
 
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: #ffffff;
5
- --foreground: #171717;
 
 
 
 
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
- background: var(--background);
 
 
 
 
 
 
 
 
 
 
 
 
24
  color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "Create Next App",
17
- description: "Generated by create next app",
 
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 Image from "next/image";
 
 
 
 
 
2
 
3
  export default function Home() {
4
  return (
5
- <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6
- <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7
- <Image
8
- className="dark:invert"
9
- src="/next.svg"
10
- alt="Next.js logo"
11
- width={100}
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-col gap-4 text-base font-medium sm:flex-row">
38
- <a
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
- </main>
63
- </div>
 
 
 
 
 
 
 
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