ALPHA0008 commited on
Commit
5f7dc7e
·
1 Parent(s): a688aff

feat: dashboard UI overhaul + auth flow + auto-company-load

Browse files

- Glassmorphic design system (dark theme, teal accents)
- Sidebar + TopBar layout replacing legacy nav
- Dashboard auto-loads company from sessionStorage
- Login/Register pages with elevated auth UI
- Onboarding wizard with multi-step flow
- Compile pipeline viewer with progress tracking
- Skills explorer with grid + detail panel
- Query agent split-pane demo view
- Backend: Supabase auth, JWT, chunking pipeline
- Protected backend.env from git tracking

Files changed (42) hide show
  1. .gitignore +2 -0
  2. backend/auth/__init__.py +0 -0
  3. backend/auth/jwt.py +46 -0
  4. backend/chunking/__init__.py +2 -0
  5. backend/chunking/chunkers.py +301 -0
  6. backend/chunking/registry.py +68 -0
  7. backend/db/schema.sql +3 -0
  8. backend/db/supabase.py +104 -0
  9. backend/graph/graph.py +7 -61
  10. backend/graph/nodes/chunk_documents.py +41 -0
  11. backend/graph/nodes/detect_contradictions.py +15 -1
  12. backend/graph/nodes/extract_decisions.py +15 -1
  13. backend/graph/nodes/extract_exceptions.py +15 -1
  14. backend/graph/nodes/extract_workflows.py +15 -1
  15. backend/graph/nodes/ingest_join.py +0 -29
  16. backend/graph/nodes/ingest_notion.py +0 -60
  17. backend/graph/nodes/ingest_slack.py +0 -50
  18. backend/graph/nodes/ingest_tickets.py +0 -59
  19. backend/graph/nodes/load_sources.py +3 -14
  20. backend/graph/nodes/write_brain.py +17 -13
  21. backend/graph/state.py +0 -4
  22. backend/llm.py +1 -1
  23. backend/main.py +259 -8
  24. backend/models/schemas.py +56 -0
  25. frontend/src/app/compile/[jobId]/page.tsx +172 -97
  26. frontend/src/app/demo/[companyId]/page.tsx +75 -227
  27. frontend/src/app/globals.css +504 -11
  28. frontend/src/app/layout.tsx +8 -3
  29. frontend/src/app/login/page.tsx +139 -0
  30. frontend/src/app/onboarding/page.tsx +224 -0
  31. frontend/src/app/page.tsx +311 -59
  32. frontend/src/app/register/page.tsx +155 -0
  33. frontend/src/app/skills/[companyId]/page.tsx +251 -84
  34. frontend/src/components/DashboardLayout.tsx +31 -0
  35. frontend/src/components/NavBar.tsx +50 -0
  36. frontend/src/components/Sidebar.tsx +135 -0
  37. frontend/src/components/TopBar.tsx +92 -0
  38. frontend/src/components/ui/ConfidenceBadge.tsx +50 -0
  39. frontend/src/components/ui/GlassCard.tsx +54 -0
  40. frontend/src/components/ui/StatCard.tsx +59 -0
  41. frontend/src/lib/api.ts +2 -0
  42. frontend/src/lib/auth.tsx +124 -0
.gitignore CHANGED
@@ -56,3 +56,5 @@ backend/nul
56
  backend_log.txt
57
  *.log
58
 
 
 
 
56
  backend_log.txt
57
  *.log
58
 
59
+
60
+ backend.env
backend/auth/__init__.py ADDED
File without changes
backend/auth/jwt.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import jwt
3
+ from fastapi import Header, HTTPException
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
9
+ SUPABASE_JWT_SECRET: str | None = None
10
+
11
+
12
+ # Derive the JWT secret from the anon key (Supabase pattern)
13
+ # The JWT secret is base64-encoded and used to verify tokens signed by Supabase Auth
14
+ def _get_jwt_secret() -> str:
15
+ global SUPABASE_JWT_SECRET
16
+ if SUPABASE_JWT_SECRET:
17
+ return SUPABASE_JWT_SECRET
18
+ anon_key = os.getenv("SUPABASE_KEY", "")
19
+ if not anon_key:
20
+ raise RuntimeError("SUPABASE_KEY not configured")
21
+ SUPABASE_JWT_SECRET = anon_key
22
+ return SUPABASE_JWT_SECRET
23
+
24
+
25
+ async def verify_token(authorization: str = Header(None)) -> dict | None:
26
+ if not authorization or not authorization.startswith("Bearer "):
27
+ return None
28
+ token = authorization[7:]
29
+ try:
30
+ secret = _get_jwt_secret()
31
+ payload = jwt.decode(
32
+ token,
33
+ secret,
34
+ algorithms=["HS256"],
35
+ options={"verify_exp": True},
36
+ )
37
+ return payload
38
+ except jwt.PyJWTError:
39
+ return None
40
+
41
+
42
+ async def require_auth(authorization: str = Header(None)) -> dict:
43
+ payload = await verify_token(authorization)
44
+ if not payload:
45
+ raise HTTPException(status_code=401, detail="Unauthorized")
46
+ return payload
backend/chunking/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from backend.chunking.registry import detect_doc_type
2
+ from backend.chunking.chunkers import get_chunker, CHUNKERS
backend/chunking/chunkers.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import csv
3
+ import io
4
+ import re
5
+
6
+
7
+ DEFAULT_CHUNK_SIZE = 2000
8
+ DEFAULT_OVERLAP = 200
9
+
10
+
11
+ def _estimate_tokens(text: str) -> int:
12
+ return len(text) // 4
13
+
14
+
15
+ def _recursive_split(
16
+ text: str, chunk_size: int = DEFAULT_CHUNK_SIZE, overlap: int = DEFAULT_OVERLAP
17
+ ) -> list[str]:
18
+ separators = ["\n\n", "\n", ". ", " "]
19
+ chunks = []
20
+ start = 0
21
+
22
+ while start < len(text):
23
+ end = min(start + chunk_size * 4, len(text))
24
+ if end < len(text):
25
+ best_sep = -1
26
+ for sep in separators:
27
+ pos = text.rfind(sep, start, end)
28
+ if pos > best_sep:
29
+ best_sep = pos
30
+ if best_sep > start:
31
+ end = best_sep + len(sep) if best_sep >= 0 else end
32
+
33
+ chunk = text[start:end].strip()
34
+ if chunk:
35
+ chunks.append(chunk)
36
+ start = end - overlap * 4 if end < len(text) else len(text)
37
+
38
+ return chunks if chunks else [text.strip()]
39
+
40
+
41
+ def chunk_markdown(
42
+ content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
43
+ ) -> list[dict]:
44
+ lines = content.split("\n")
45
+ sections = []
46
+ current_header = "Introduction"
47
+ current_body = []
48
+ current_level = 0
49
+
50
+ for line in lines:
51
+ header_match = re.match(r"^(#{1,6})\s+(.+)$", line)
52
+ if header_match:
53
+ if current_body:
54
+ sections.append((current_header, "\n".join(current_body).strip()))
55
+ current_level = len(header_match.group(1))
56
+ current_header = header_match.group(2).strip()
57
+ current_body = []
58
+ else:
59
+ current_body.append(line)
60
+
61
+ if current_body:
62
+ sections.append((current_header, "\n".join(current_body).strip()))
63
+
64
+ chunks = []
65
+ for i, (header, body) in enumerate(sections):
66
+ if not body:
67
+ continue
68
+ text = f"[{header}] {body}"
69
+ if _estimate_tokens(text) > chunk_size:
70
+ sub_chunks = _recursive_split(body, chunk_size)
71
+ for j, sub in enumerate(sub_chunks):
72
+ chunks.append(
73
+ {
74
+ "text": f"[{header}] {sub}",
75
+ "source_file": filename,
76
+ "chunk_index": i * 1000 + j,
77
+ "doc_type": "markdown",
78
+ "section_header": header,
79
+ }
80
+ )
81
+ else:
82
+ chunks.append(
83
+ {
84
+ "text": text,
85
+ "source_file": filename,
86
+ "chunk_index": i,
87
+ "doc_type": "markdown",
88
+ "section_header": header,
89
+ }
90
+ )
91
+
92
+ return chunks
93
+
94
+
95
+ def chunk_json_array(
96
+ content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
97
+ ) -> list[dict]:
98
+ try:
99
+ data = json.loads(content)
100
+ except json.JSONDecodeError:
101
+ return [
102
+ {
103
+ "text": content,
104
+ "source_file": filename,
105
+ "chunk_index": 0,
106
+ "doc_type": "json_array",
107
+ }
108
+ ]
109
+
110
+ if not isinstance(data, list):
111
+ text = json.dumps(data, indent=2)
112
+ return [
113
+ {
114
+ "text": text,
115
+ "source_file": filename,
116
+ "chunk_index": 0,
117
+ "doc_type": "json_object",
118
+ }
119
+ ]
120
+
121
+ chunks = []
122
+ for i, item in enumerate(data):
123
+ if isinstance(item, dict):
124
+ parts = []
125
+ for key in (
126
+ "text",
127
+ "message",
128
+ "content",
129
+ "subject",
130
+ "description",
131
+ "resolution",
132
+ "body",
133
+ ):
134
+ if item.get(key):
135
+ parts.append(f"{key}: {item[key]}")
136
+ for key in (
137
+ "user",
138
+ "author",
139
+ "channel",
140
+ "priority",
141
+ "customer_plan",
142
+ "status",
143
+ ):
144
+ if item.get(key):
145
+ parts.append(f"{key}: {item[key]}")
146
+ text = " | ".join(parts)
147
+ if not text:
148
+ text = json.dumps(item)
149
+ elif isinstance(item, str):
150
+ text = item
151
+ else:
152
+ text = json.dumps(item)
153
+
154
+ if text:
155
+ chunks.append(
156
+ {
157
+ "text": text,
158
+ "source_file": filename,
159
+ "chunk_index": i,
160
+ "doc_type": "json_array",
161
+ }
162
+ )
163
+
164
+ return chunks
165
+
166
+
167
+ def chunk_csv(
168
+ content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
169
+ ) -> list[dict]:
170
+ reader = csv.DictReader(io.StringIO(content))
171
+ if reader.fieldnames is None:
172
+ return [
173
+ {
174
+ "text": content,
175
+ "source_file": filename,
176
+ "chunk_index": 0,
177
+ "doc_type": "csv",
178
+ }
179
+ ]
180
+
181
+ headers = reader.fieldnames
182
+ rows = list(reader)
183
+ if not rows:
184
+ return []
185
+
186
+ chunks = []
187
+ batch = []
188
+ batch_text = ""
189
+
190
+ for i, row in enumerate(rows):
191
+ row_parts = [f"{k}: {v}" for k, v in row.items() if v]
192
+ row_str = " | ".join(row_parts)
193
+ if _estimate_tokens(batch_text + "\n" + row_str) > chunk_size and batch:
194
+ chunks.append(
195
+ {
196
+ "text": batch_text,
197
+ "source_file": filename,
198
+ "chunk_index": len(chunks),
199
+ "doc_type": "csv",
200
+ }
201
+ )
202
+ batch = [row]
203
+ batch_text = row_str
204
+ else:
205
+ if batch_text:
206
+ batch_text += "\n"
207
+ batch_text += row_str
208
+ batch.append(row)
209
+
210
+ if batch:
211
+ chunks.append(
212
+ {
213
+ "text": batch_text,
214
+ "source_file": filename,
215
+ "chunk_index": len(chunks),
216
+ "doc_type": "csv",
217
+ }
218
+ )
219
+
220
+ return chunks
221
+
222
+
223
+ def chunk_html(
224
+ content: str, filename: str, chunk_size: int = DEFAULT_CHUNK_SIZE
225
+ ) -> list[dict]:
226
+ text = re.sub(r"<style[^>]*>.*?</style>", "", content, flags=re.DOTALL)
227
+ text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
228
+ text = re.sub(r"<[^>]+>", " ", text)
229
+ text = re.sub(r"\s+", " ", text).strip()
230
+
231
+ sections = re.split(r"\n\s*(?=(?:##|###|####|h[1-6]))", text)
232
+ chunks = []
233
+ for i, section in enumerate(sections):
234
+ section = section.strip()
235
+ if not section:
236
+ continue
237
+ if _estimate_tokens(section) > chunk_size:
238
+ subs = _recursive_split(section, chunk_size)
239
+ for j, sub in enumerate(subs):
240
+ chunks.append(
241
+ {
242
+ "text": sub,
243
+ "source_file": filename,
244
+ "chunk_index": i * 1000 + j,
245
+ "doc_type": "html",
246
+ }
247
+ )
248
+ else:
249
+ chunks.append(
250
+ {
251
+ "text": section,
252
+ "source_file": filename,
253
+ "chunk_index": i,
254
+ "doc_type": "html",
255
+ }
256
+ )
257
+
258
+ return (
259
+ chunks
260
+ if chunks
261
+ else [
262
+ {
263
+ "text": text[: chunk_size * 4],
264
+ "source_file": filename,
265
+ "chunk_index": 0,
266
+ "doc_type": "html",
267
+ }
268
+ ]
269
+ )
270
+
271
+
272
+ def chunk_plain_text(
273
+ content: str,
274
+ filename: str,
275
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
276
+ overlap: int = DEFAULT_OVERLAP,
277
+ ) -> list[dict]:
278
+ parts = _recursive_split(content, chunk_size, overlap)
279
+ return [
280
+ {
281
+ "text": part,
282
+ "source_file": filename,
283
+ "chunk_index": i,
284
+ "doc_type": "plain_text",
285
+ }
286
+ for i, part in enumerate(parts)
287
+ ]
288
+
289
+
290
+ CHUNKERS = {
291
+ "markdown": chunk_markdown,
292
+ "json_array": chunk_json_array,
293
+ "json_object": chunk_json_array,
294
+ "csv": chunk_csv,
295
+ "html": chunk_html,
296
+ "plain_text": chunk_plain_text,
297
+ }
298
+
299
+
300
+ def get_chunker(doc_type: str):
301
+ return CHUNKERS.get(doc_type, chunk_plain_text)
backend/chunking/registry.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+
4
+ def _detect_by_content(content: str) -> str | None:
5
+ stripped = content.strip()
6
+ if not stripped:
7
+ return None
8
+
9
+ if stripped.startswith("<!DOCTYPE html") or stripped.startswith("<html"):
10
+ return "html"
11
+
12
+ if stripped.startswith("|") or stripped.startswith("|---"):
13
+ return "markdown"
14
+
15
+ lines = [l for l in stripped.split("\n") if l.strip()]
16
+ if lines:
17
+ header_count = sum(1 for l in lines[:20] if l.startswith("#"))
18
+ if header_count >= 2 or (lines and lines[0].startswith("#")):
19
+ return "markdown"
20
+
21
+ if stripped.startswith("{") or stripped.startswith("["):
22
+ try:
23
+ parsed = json.loads(stripped)
24
+ if isinstance(parsed, list):
25
+ return "json_array"
26
+ if isinstance(parsed, dict):
27
+ return "json_object"
28
+ except json.JSONDecodeError:
29
+ pass
30
+
31
+ if "," in stripped and "\n" in stripped[:500]:
32
+ first_line = stripped.split("\n")[0]
33
+ if "," in first_line and len(first_line.split(",")) >= 2:
34
+ return "csv"
35
+
36
+ return None
37
+
38
+
39
+ def _detect_by_extension(filename: str) -> str | None:
40
+ fn = filename.lower()
41
+ ext_map = {
42
+ ".md": "markdown",
43
+ ".markdown": "markdown",
44
+ ".json": "json_array",
45
+ ".csv": "csv",
46
+ ".tsv": "csv",
47
+ ".html": "html",
48
+ ".htm": "html",
49
+ ".txt": "plain_text",
50
+ ".log": "plain_text",
51
+ ".yaml": "plain_text",
52
+ ".yml": "plain_text",
53
+ ".xml": "plain_text",
54
+ }
55
+ for ext, dtype in ext_map.items():
56
+ if fn.endswith(ext):
57
+ return dtype
58
+ return None
59
+
60
+
61
+ def detect_doc_type(filename: str, content: str) -> str:
62
+ detected = _detect_by_content(content)
63
+ if detected:
64
+ return detected
65
+ detected = _detect_by_extension(filename)
66
+ if detected:
67
+ return detected
68
+ return "plain_text"
backend/db/schema.sql CHANGED
@@ -3,6 +3,9 @@
3
  CREATE TABLE companies (
4
  id TEXT PRIMARY KEY,
5
  name TEXT NOT NULL,
 
 
 
6
  created_at TIMESTAMPTZ DEFAULT now()
7
  );
8
 
 
3
  CREATE TABLE companies (
4
  id TEXT PRIMARY KEY,
5
  name TEXT NOT NULL,
6
+ industry TEXT,
7
+ company_size TEXT,
8
+ description TEXT,
9
  created_at TIMESTAMPTZ DEFAULT now()
10
  );
11
 
backend/db/supabase.py CHANGED
@@ -99,3 +99,107 @@ def get_brain_by_version(company_id: str, version: str):
99
  if res.data:
100
  return res.data[0]
101
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  if res.data:
100
  return res.data[0]
101
  return None
102
+
103
+
104
+ # ─────────────────────────────────────────────
105
+ # Phase 3 — Company CRUD
106
+ # ─────────────────────────────────────────────
107
+
108
+
109
+ def get_company(company_id: str):
110
+ if not supabase:
111
+ return None
112
+ res = supabase.table("companies").select("*").eq("id", company_id).execute()
113
+ return res.data[0] if res.data else None
114
+
115
+
116
+ def upsert_company(company_id: str, data: dict):
117
+ if not supabase:
118
+ return None
119
+ existing = get_company(company_id)
120
+ if existing:
121
+ data.pop("id", None)
122
+ data.pop("created_at", None)
123
+ res = supabase.table("companies").update(data).eq("id", company_id).execute()
124
+ else:
125
+ data.setdefault("id", company_id)
126
+ res = supabase.table("companies").insert(data).execute()
127
+ return res.data[0] if res.data else None
128
+
129
+
130
+ def get_company_stats(company_id: str):
131
+ if not supabase:
132
+ return {"skill_count": 0, "source_count": 0, "last_compile": None}
133
+ skill_res = (
134
+ supabase.table("skills")
135
+ .select("id", count="exact")
136
+ .eq("company_id", company_id)
137
+ .eq("stale", False)
138
+ .execute()
139
+ )
140
+ source_res = (
141
+ supabase.table("source_files")
142
+ .select("id", count="exact")
143
+ .eq("company_id", company_id)
144
+ .execute()
145
+ )
146
+ compile_res = (
147
+ supabase.table("compile_runs")
148
+ .select("completed_at, result_version")
149
+ .eq("company_id", company_id)
150
+ .eq("status", "complete")
151
+ .order("completed_at", desc=True)
152
+ .limit(1)
153
+ .execute()
154
+ )
155
+ return {
156
+ "skill_count": getattr(skill_res, "count", 0) or len(skill_res.data or []),
157
+ "source_count": getattr(source_res, "count", 0) or len(source_res.data or []),
158
+ "last_compile": compile_res.data[0] if compile_res.data else None,
159
+ }
160
+
161
+
162
+ # ─────────────────────────────────────────────
163
+ # Phase 4 — Skills Marketplace
164
+ # ─────────────────────────────────────────────
165
+
166
+
167
+ def import_skills_file(
168
+ company_id: str, skills: list, version: str, source_label: str
169
+ ) -> dict | None:
170
+ if not supabase:
171
+ return None
172
+ supabase.table("skills_files").update({"is_current": False}).eq(
173
+ "company_id", company_id
174
+ ).eq("is_current", True).execute()
175
+ brain_json = {
176
+ "skills": skills,
177
+ "meta": {"imported": True, "source": source_label, "version": version},
178
+ }
179
+ skills_file = {
180
+ "company_id": company_id,
181
+ "version": version,
182
+ "brain_json": brain_json,
183
+ "source_hashes": {},
184
+ "is_current": True,
185
+ }
186
+ skills_file_res = supabase.table("skills_files").insert(skills_file).execute()
187
+ if not skills_file_res.data:
188
+ return None
189
+ sf = skills_file_res.data[0]
190
+ skill_rows = [
191
+ {
192
+ "id": s.get("id", f"imported_{i}"),
193
+ "company_id": company_id,
194
+ "skills_file_id": sf["id"],
195
+ "name": s.get("rule", "")[:255],
196
+ "domain": s.get("category", "Unknown"),
197
+ "version": version,
198
+ "confidence": s.get("confidence", 0.5),
199
+ "skill_json": s,
200
+ }
201
+ for i, s in enumerate(skills)
202
+ ]
203
+ if skill_rows:
204
+ supabase.table("skills").insert(skill_rows).execute()
205
+ return sf
backend/graph/graph.py CHANGED
@@ -2,10 +2,7 @@ from langgraph.graph import StateGraph, END
2
  from langgraph.types import Send
3
  from backend.graph.state import BrainState
4
  from backend.graph.nodes.load_sources import load_sources
5
- from backend.graph.nodes.ingest_notion import ingest_notion
6
- from backend.graph.nodes.ingest_slack import ingest_slack
7
- from backend.graph.nodes.ingest_tickets import ingest_tickets
8
- from backend.graph.nodes.ingest_join import ingest_join
9
  from backend.graph.nodes.extract_decisions import extract_decisions
10
  from backend.graph.nodes.extract_workflows import extract_workflows
11
  from backend.graph.nodes.extract_exceptions import extract_exceptions
@@ -16,27 +13,7 @@ from backend.graph.nodes.score_confidence import score_confidence
16
  from backend.graph.nodes.write_brain import write_brain
17
 
18
 
19
- def route_to_ingestion(state: BrainState) -> list[Send]:
20
- """Fan-out: dispatch source files to type-specific ingestion nodes."""
21
- sends = []
22
- for f in state.get("source_files", []):
23
- dt = f.get("doc_type", "unknown")
24
- payload = {
25
- "company_id": state["company_id"],
26
- "job_id": state["job_id"],
27
- "source_files": [f],
28
- }
29
- if dt == "notion_md":
30
- sends.append(Send("ingest_notion", payload))
31
- elif dt == "slack_json":
32
- sends.append(Send("ingest_slack", payload))
33
- elif dt == "tickets_json":
34
- sends.append(Send("ingest_tickets", payload))
35
- return sends
36
-
37
-
38
  def route_to_extraction(state: BrainState) -> list[Send]:
39
- """Fan-out: dispatch all chunks to 4 parallel extraction agents."""
40
  return [
41
  Send("extract_decisions", dict(state)),
42
  Send("extract_workflows", dict(state)),
@@ -47,59 +24,30 @@ def route_to_extraction(state: BrainState) -> list[Send]:
47
 
48
  def build_compilation_graph() -> StateGraph:
49
  """
50
- Parallel multi-agent graph:
51
-
52
- load_sources
53
- → route_to_ingestion (Send fan-out)
54
- → [ingest_notion, ingest_slack, ingest_tickets] (parallel)
55
- → ingest_join (barrier)
56
- → route_to_extraction (Send fan-out)
57
- → [extract_decisions, extract_workflows, extract_exceptions, detect_contradictions] (parallel)
58
- → synthesize_skills → link_evidence → score_confidence → write_brain
59
  """
60
  workflow = StateGraph(BrainState)
61
 
62
- # --- Ingestion layer ---
63
  workflow.add_node("load_sources", load_sources)
64
- workflow.add_node("ingest_notion", ingest_notion)
65
- workflow.add_node("ingest_slack", ingest_slack)
66
- workflow.add_node("ingest_tickets", ingest_tickets)
67
- workflow.add_node("ingest_join", ingest_join)
68
 
69
- # --- Extraction layer ---
70
  workflow.add_node("extract_decisions", extract_decisions)
71
  workflow.add_node("extract_workflows", extract_workflows)
72
  workflow.add_node("extract_exceptions", extract_exceptions)
73
  workflow.add_node("detect_contradictions", detect_contradictions)
74
 
75
- # --- Compilation layer ---
76
  workflow.add_node("synthesize_skills", synthesize_skills)
77
  workflow.add_node("link_evidence", link_evidence)
78
  workflow.add_node("score_confidence", score_confidence)
79
  workflow.add_node("write_brain", write_brain)
80
 
81
- # --- Edges ---
82
  workflow.set_entry_point("load_sources")
 
83
 
84
- # load_sources fans out to 3 parallel ingest nodes
85
- workflow.add_conditional_edges(
86
- "load_sources",
87
- route_to_ingestion,
88
- [
89
- "ingest_notion",
90
- "ingest_slack",
91
- "ingest_tickets",
92
- ],
93
- )
94
-
95
- # All 3 ingest nodes converge at the barrier join
96
- workflow.add_edge("ingest_notion", "ingest_join")
97
- workflow.add_edge("ingest_slack", "ingest_join")
98
- workflow.add_edge("ingest_tickets", "ingest_join")
99
-
100
- # ingest_join fans out to 4 parallel extraction agents
101
  workflow.add_conditional_edges(
102
- "ingest_join",
103
  route_to_extraction,
104
  [
105
  "extract_decisions",
@@ -109,13 +57,11 @@ def build_compilation_graph() -> StateGraph:
109
  ],
110
  )
111
 
112
- # All 4 extraction agents converge at synthesize_skills
113
  workflow.add_edge("extract_decisions", "synthesize_skills")
114
  workflow.add_edge("extract_workflows", "synthesize_skills")
115
  workflow.add_edge("extract_exceptions", "synthesize_skills")
116
  workflow.add_edge("detect_contradictions", "synthesize_skills")
117
 
118
- # Sequential compilation pipeline
119
  workflow.add_edge("synthesize_skills", "link_evidence")
120
  workflow.add_edge("link_evidence", "score_confidence")
121
  workflow.add_edge("score_confidence", "write_brain")
 
2
  from langgraph.types import Send
3
  from backend.graph.state import BrainState
4
  from backend.graph.nodes.load_sources import load_sources
5
+ from backend.graph.nodes.chunk_documents import chunk_documents
 
 
 
6
  from backend.graph.nodes.extract_decisions import extract_decisions
7
  from backend.graph.nodes.extract_workflows import extract_workflows
8
  from backend.graph.nodes.extract_exceptions import extract_exceptions
 
13
  from backend.graph.nodes.write_brain import write_brain
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def route_to_extraction(state: BrainState) -> list[Send]:
 
17
  return [
18
  Send("extract_decisions", dict(state)),
19
  Send("extract_workflows", dict(state)),
 
24
 
25
  def build_compilation_graph() -> StateGraph:
26
  """
27
+ load_sources chunk_documents → route_to_extraction (Send fan-out)
28
+ → [extract_decisions, extract_workflows, extract_exceptions, detect_contradictions] (parallel)
29
+ → synthesize_skills → link_evidence → score_confidence → write_brain
 
 
 
 
 
 
30
  """
31
  workflow = StateGraph(BrainState)
32
 
 
33
  workflow.add_node("load_sources", load_sources)
34
+ workflow.add_node("chunk_documents", chunk_documents)
 
 
 
35
 
 
36
  workflow.add_node("extract_decisions", extract_decisions)
37
  workflow.add_node("extract_workflows", extract_workflows)
38
  workflow.add_node("extract_exceptions", extract_exceptions)
39
  workflow.add_node("detect_contradictions", detect_contradictions)
40
 
 
41
  workflow.add_node("synthesize_skills", synthesize_skills)
42
  workflow.add_node("link_evidence", link_evidence)
43
  workflow.add_node("score_confidence", score_confidence)
44
  workflow.add_node("write_brain", write_brain)
45
 
 
46
  workflow.set_entry_point("load_sources")
47
+ workflow.add_edge("load_sources", "chunk_documents")
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  workflow.add_conditional_edges(
50
+ "chunk_documents",
51
  route_to_extraction,
52
  [
53
  "extract_decisions",
 
57
  ],
58
  )
59
 
 
60
  workflow.add_edge("extract_decisions", "synthesize_skills")
61
  workflow.add_edge("extract_workflows", "synthesize_skills")
62
  workflow.add_edge("extract_exceptions", "synthesize_skills")
63
  workflow.add_edge("detect_contradictions", "synthesize_skills")
64
 
 
65
  workflow.add_edge("synthesize_skills", "link_evidence")
66
  workflow.add_edge("link_evidence", "score_confidence")
67
  workflow.add_edge("score_confidence", "write_brain")
backend/graph/nodes/chunk_documents.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.graph.state import BrainState
2
+ from backend.chunking import get_chunker
3
+ from backend.sse import emit
4
+
5
+
6
+ async def chunk_documents(state: BrainState) -> dict:
7
+ job_id = state["job_id"]
8
+ source_files = state.get("source_files", [])
9
+
10
+ print(f"[{job_id}] Node chunk_documents: processing {len(source_files)} files")
11
+ await emit(
12
+ job_id,
13
+ "stage",
14
+ {
15
+ "name": "CHUNKING",
16
+ "detail": f"Chunking {len(source_files)} source files",
17
+ },
18
+ )
19
+
20
+ all_chunks = []
21
+ for sf in source_files:
22
+ doc_type = sf.get("doc_type", "plain_text")
23
+ filename = sf.get("filename", "unknown")
24
+ content = sf.get("content", "")
25
+ chunker = get_chunker(doc_type)
26
+ chunks = chunker(content, filename)
27
+ all_chunks.extend(chunks)
28
+
29
+ print(
30
+ f"[{job_id}] chunk_documents: produced {len(all_chunks)} chunks from {len(source_files)} files"
31
+ )
32
+ await emit(
33
+ job_id,
34
+ "stage",
35
+ {
36
+ "name": "CHUNKING_DONE",
37
+ "detail": f"Produced {len(all_chunks)} chunks from {len(source_files)} files",
38
+ },
39
+ )
40
+
41
+ return {"all_chunks": all_chunks}
backend/graph/nodes/detect_contradictions.py CHANGED
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  SYSTEM = """You are a contradiction detection specialist. Your ONLY job is to find CONTRADICTIONS, CONFLICTS, and INCONSISTENCIES across company communications.
7
 
@@ -34,7 +48,7 @@ async def detect_contradictions(state: BrainState) -> dict:
34
  },
35
  )
36
 
37
- chunk_text = "\n\n---\n\n".join([c.get("text", "") for c in chunks])
38
  user = f"Detect contradictions and conflicting instructions across this company data:\n\n{chunk_text}"
39
 
40
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
 
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
5
+ MAX_CHUNK_CHARS = 12000
6
+
7
+
8
+ def _cap_chunks(chunks: list[dict]) -> str:
9
+ parts = []
10
+ chars = 0
11
+ for c in chunks:
12
+ text = c.get("text", "")
13
+ if chars + len(text) > MAX_CHUNK_CHARS:
14
+ break
15
+ parts.append(text)
16
+ chars += len(text)
17
+ return "\n\n---\n\n".join(parts)
18
+
19
 
20
  SYSTEM = """You are a contradiction detection specialist. Your ONLY job is to find CONTRADICTIONS, CONFLICTS, and INCONSISTENCIES across company communications.
21
 
 
48
  },
49
  )
50
 
51
+ chunk_text = _cap_chunks(chunks)
52
  user = f"Detect contradictions and conflicting instructions across this company data:\n\n{chunk_text}"
53
 
54
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
backend/graph/nodes/extract_decisions.py CHANGED
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  SYSTEM = """You are a policy extraction specialist. Your ONLY job is to extract DECISIONS, RULES, and POLICIES from company communications.
7
 
@@ -29,7 +43,7 @@ async def extract_decisions(state: BrainState) -> dict:
29
  {"name": "EXTRACT_DECISIONS", "detail": "Extracting rules and policies..."},
30
  )
31
 
32
- chunk_text = "\n\n---\n\n".join([c.get("text", "") for c in chunks])
33
  user = f"Extract all decisions, rules, and policies from this company data:\n\n{chunk_text}"
34
 
35
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
 
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
5
+ MAX_CHUNK_CHARS = 12000
6
+
7
+
8
+ def _cap_chunks(chunks: list[dict]) -> str:
9
+ parts = []
10
+ chars = 0
11
+ for c in chunks:
12
+ text = c.get("text", "")
13
+ if chars + len(text) > MAX_CHUNK_CHARS:
14
+ break
15
+ parts.append(text)
16
+ chars += len(text)
17
+ return "\n\n---\n\n".join(parts)
18
+
19
 
20
  SYSTEM = """You are a policy extraction specialist. Your ONLY job is to extract DECISIONS, RULES, and POLICIES from company communications.
21
 
 
43
  {"name": "EXTRACT_DECISIONS", "detail": "Extracting rules and policies..."},
44
  )
45
 
46
+ chunk_text = _cap_chunks(chunks)
47
  user = f"Extract all decisions, rules, and policies from this company data:\n\n{chunk_text}"
48
 
49
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
backend/graph/nodes/extract_exceptions.py CHANGED
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  SYSTEM = """You are an exception extraction specialist. Your ONLY job is to extract EXCEPTIONS, EDGE CASES, CONSTRAINTS, CONDITIONAL RULES, and FORBIDDEN ACTIONS from company communications.
7
 
@@ -32,7 +46,7 @@ async def extract_exceptions(state: BrainState) -> dict:
32
  },
33
  )
34
 
35
- chunk_text = "\n\n---\n\n".join([c.get("text", "") for c in chunks])
36
  user = f"Extract all exceptions, edge cases, constraints, and forbidden actions from this company data:\n\n{chunk_text}"
37
 
38
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
 
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
5
+ MAX_CHUNK_CHARS = 12000
6
+
7
+
8
+ def _cap_chunks(chunks: list[dict]) -> str:
9
+ parts = []
10
+ chars = 0
11
+ for c in chunks:
12
+ text = c.get("text", "")
13
+ if chars + len(text) > MAX_CHUNK_CHARS:
14
+ break
15
+ parts.append(text)
16
+ chars += len(text)
17
+ return "\n\n---\n\n".join(parts)
18
+
19
 
20
  SYSTEM = """You are an exception extraction specialist. Your ONLY job is to extract EXCEPTIONS, EDGE CASES, CONSTRAINTS, CONDITIONAL RULES, and FORBIDDEN ACTIONS from company communications.
21
 
 
46
  },
47
  )
48
 
49
+ chunk_text = _cap_chunks(chunks)
50
  user = f"Extract all exceptions, edge cases, constraints, and forbidden actions from this company data:\n\n{chunk_text}"
51
 
52
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
backend/graph/nodes/extract_workflows.py CHANGED
@@ -2,6 +2,20 @@ from backend.graph.state import BrainState
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  SYSTEM = """You are a workflow extraction specialist. Your ONLY job is to extract WORKFLOWS, PROCESSES, and SEQUENTIAL STEPS from company communications.
7
 
@@ -32,7 +46,7 @@ async def extract_workflows(state: BrainState) -> dict:
32
  },
33
  )
34
 
35
- chunk_text = "\n\n---\n\n".join([c.get("text", "") for c in chunks])
36
  user = f"Extract all workflows, processes, and step-by-step procedures from this company data:\n\n{chunk_text}"
37
 
38
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
 
2
  from backend.llm import safe_llm_json_call
3
  from backend.sse import emit
4
 
5
+ MAX_CHUNK_CHARS = 12000
6
+
7
+
8
+ def _cap_chunks(chunks: list[dict]) -> str:
9
+ parts = []
10
+ chars = 0
11
+ for c in chunks:
12
+ text = c.get("text", "")
13
+ if chars + len(text) > MAX_CHUNK_CHARS:
14
+ break
15
+ parts.append(text)
16
+ chars += len(text)
17
+ return "\n\n---\n\n".join(parts)
18
+
19
 
20
  SYSTEM = """You are a workflow extraction specialist. Your ONLY job is to extract WORKFLOWS, PROCESSES, and SEQUENTIAL STEPS from company communications.
21
 
 
46
  },
47
  )
48
 
49
+ chunk_text = _cap_chunks(chunks)
50
  user = f"Extract all workflows, processes, and step-by-step procedures from this company data:\n\n{chunk_text}"
51
 
52
  results = await safe_llm_json_call(SYSTEM, user, max_tokens=2048)
backend/graph/nodes/ingest_join.py DELETED
@@ -1,29 +0,0 @@
1
- from backend.graph.state import BrainState
2
- from backend.sse import emit
3
-
4
-
5
- async def ingest_join(state: BrainState) -> dict:
6
- job_id = state["job_id"]
7
-
8
- structured_sops = state.get("structured_sops", [])
9
- normalized_events = state.get("normalized_events", [])
10
- resolved_cases = state.get("resolved_cases", [])
11
-
12
- all_chunks = []
13
- all_chunks.extend(structured_sops)
14
- all_chunks.extend(normalized_events)
15
- all_chunks.extend(resolved_cases)
16
-
17
- print(
18
- f"[{job_id}] Node ingest_join: merged {len(structured_sops)} SOPs + {len(normalized_events)} events + {len(resolved_cases)} tickets = {len(all_chunks)} chunks"
19
- )
20
-
21
- await emit(
22
- job_id,
23
- "stage",
24
- {
25
- "name": "INGEST_JOIN",
26
- "detail": f"Merged {len(all_chunks)} total chunks from all sources",
27
- },
28
- )
29
- return {"all_chunks": all_chunks}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/graph/nodes/ingest_notion.py DELETED
@@ -1,60 +0,0 @@
1
- from backend.graph.state import BrainState
2
- from backend.sse import emit
3
-
4
-
5
- async def ingest_notion(state: BrainState) -> dict:
6
- job_id = state["job_id"]
7
- source_files = state.get("source_files", [])
8
-
9
- notion_files = [f for f in source_files if f.get("doc_type") == "notion_md"]
10
- print(f"[{job_id}] Node ingest_notion: {len(notion_files)} notion files")
11
-
12
- structured_sops = []
13
- for sf in notion_files:
14
- chunks = _chunk_markdown(sf)
15
- structured_sops.extend(chunks)
16
-
17
- await emit(
18
- job_id,
19
- "stage",
20
- {
21
- "name": "INGEST_NOTION",
22
- "detail": f"Processed {len(notion_files)} SOP files into {len(structured_sops)} chunks",
23
- },
24
- )
25
- print(f"[{job_id}] ingest_notion finished: {len(structured_sops)} chunks")
26
- return {"structured_sops": structured_sops}
27
-
28
-
29
- def _chunk_markdown(sf: dict) -> list:
30
- content = sf["content"]
31
- sections = []
32
- current_header = "Introduction"
33
- current_body = []
34
-
35
- for line in content.split("\n"):
36
- if line.startswith("## "):
37
- if current_body:
38
- sections.append((current_header, "\n".join(current_body).strip()))
39
- current_header = line.lstrip("# ").strip()
40
- current_body = []
41
- else:
42
- current_body.append(line)
43
-
44
- if current_body:
45
- sections.append((current_header, "\n".join(current_body).strip()))
46
-
47
- chunks = []
48
- for i, (header, body) in enumerate(sections):
49
- if not body:
50
- continue
51
- chunks.append(
52
- {
53
- "text": f"[{header}] {body}",
54
- "source_file": sf["filename"],
55
- "chunk_index": i,
56
- "doc_type": "notion_md",
57
- "section_header": header,
58
- }
59
- )
60
- return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/graph/nodes/ingest_slack.py DELETED
@@ -1,50 +0,0 @@
1
- import json
2
- from backend.graph.state import BrainState
3
- from backend.sse import emit
4
-
5
-
6
- async def ingest_slack(state: BrainState) -> dict:
7
- job_id = state["job_id"]
8
- source_files = state.get("source_files", [])
9
-
10
- slack_files = [f for f in source_files if f.get("doc_type") == "slack_json"]
11
- print(f"[{job_id}] Node ingest_slack: {len(slack_files)} slack files")
12
-
13
- normalized_events = []
14
- for sf in slack_files:
15
- chunks = _chunk_slack(sf)
16
- normalized_events.extend(chunks)
17
-
18
- await emit(
19
- job_id,
20
- "stage",
21
- {
22
- "name": "INGEST_SLACK",
23
- "detail": f"Processed {len(slack_files)} Slack exports into {len(normalized_events)} messages",
24
- },
25
- )
26
- print(f"[{job_id}] ingest_slack finished: {len(normalized_events)} messages")
27
- return {"normalized_events": normalized_events}
28
-
29
-
30
- def _chunk_slack(sf: dict) -> list:
31
- try:
32
- messages = json.loads(sf["content"])
33
- except json.JSONDecodeError:
34
- return []
35
- chunks = []
36
- for i, msg in enumerate(messages):
37
- text = msg.get("text", "")
38
- if not text:
39
- continue
40
- user = msg.get("user", "unknown")
41
- channel = msg.get("channel", "unknown")
42
- chunks.append(
43
- {
44
- "text": f"[Slack #{channel} @{user}] {text}",
45
- "source_file": sf["filename"],
46
- "chunk_index": i,
47
- "doc_type": "slack_json",
48
- }
49
- )
50
- return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/graph/nodes/ingest_tickets.py DELETED
@@ -1,59 +0,0 @@
1
- import json
2
- from backend.graph.state import BrainState
3
- from backend.sse import emit
4
-
5
-
6
- async def ingest_tickets(state: BrainState) -> dict:
7
- job_id = state["job_id"]
8
- source_files = state.get("source_files", [])
9
-
10
- ticket_files = [f for f in source_files if f.get("doc_type") == "tickets_json"]
11
- print(f"[{job_id}] Node ingest_tickets: {len(ticket_files)} ticket files")
12
-
13
- resolved_cases = []
14
- for sf in ticket_files:
15
- chunks = _chunk_tickets(sf)
16
- resolved_cases.extend(chunks)
17
-
18
- await emit(
19
- job_id,
20
- "stage",
21
- {
22
- "name": "INGEST_TICKETS",
23
- "detail": f"Processed {len(ticket_files)} ticket files into {len(resolved_cases)} cases",
24
- },
25
- )
26
- print(f"[{job_id}] ingest_tickets finished: {len(resolved_cases)} tickets")
27
- return {"resolved_cases": resolved_cases}
28
-
29
-
30
- def _chunk_tickets(sf: dict) -> list:
31
- try:
32
- tickets = json.loads(sf["content"])
33
- except json.JSONDecodeError:
34
- return []
35
- chunks = []
36
- for i, tkt in enumerate(tickets):
37
- parts = []
38
- if tkt.get("subject"):
39
- parts.append(f"Subject: {tkt['subject']}")
40
- if tkt.get("description"):
41
- parts.append(f"Description: {tkt['description']}")
42
- if tkt.get("resolution"):
43
- parts.append(f"Resolution: {tkt['resolution']}")
44
- if tkt.get("priority"):
45
- parts.append(f"Priority: {tkt['priority']}")
46
- if tkt.get("customer_plan"):
47
- parts.append(f"Plan: {tkt['customer_plan']}")
48
- text = " | ".join(parts)
49
- if not text:
50
- continue
51
- chunks.append(
52
- {
53
- "text": f"[Zendesk Ticket] {text}",
54
- "source_file": sf["filename"],
55
- "chunk_index": i,
56
- "doc_type": "tickets_json",
57
- }
58
- )
59
- return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/graph/nodes/load_sources.py CHANGED
@@ -1,22 +1,10 @@
1
  import os
2
  import hashlib
3
  from backend.graph.state import BrainState
 
4
  from backend.sse import emit
5
 
6
 
7
- def _detect_type(filename: str) -> str:
8
- fn = filename.lower()
9
- if fn.endswith(".json"):
10
- if "slack" in fn:
11
- return "slack_json"
12
- if "ticket" in fn or "zendesk" in fn:
13
- return "tickets_json"
14
- return "json"
15
- if fn.endswith(".md"):
16
- return "notion_md"
17
- return "unknown"
18
-
19
-
20
  async def load_sources(state: BrainState) -> dict:
21
  company_id = state["company_id"]
22
  job_id = state["job_id"]
@@ -49,12 +37,13 @@ async def load_sources(state: BrainState) -> dict:
49
  continue
50
  with open(filepath, "r", encoding="utf-8") as f:
51
  content = f.read()
 
52
  source_files.append(
53
  {
54
  "filename": filename,
55
  "content": content,
56
  "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
57
- "doc_type": _detect_type(filename),
58
  }
59
  )
60
 
 
1
  import os
2
  import hashlib
3
  from backend.graph.state import BrainState
4
+ from backend.chunking import detect_doc_type
5
  from backend.sse import emit
6
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  async def load_sources(state: BrainState) -> dict:
9
  company_id = state["company_id"]
10
  job_id = state["job_id"]
 
37
  continue
38
  with open(filepath, "r", encoding="utf-8") as f:
39
  content = f.read()
40
+ doc_type = detect_doc_type(filename, content)
41
  source_files.append(
42
  {
43
  "filename": filename,
44
  "content": content,
45
  "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
46
+ "doc_type": doc_type,
47
  }
48
  )
49
 
backend/graph/nodes/write_brain.py CHANGED
@@ -4,7 +4,7 @@ import uuid
4
  import datetime
5
  from backend.graph.state import BrainState
6
  from backend.db.supabase import get_client
7
- from backend.llm import get_embedding
8
  from backend.sse import emit
9
 
10
 
@@ -27,19 +27,20 @@ async def write_brain(state: BrainState) -> dict:
27
  },
28
  )
29
 
30
- skills_with_embeddings = []
31
- for skill in final_skills:
32
- skill_text = f"{skill.get('category', '')} {skill.get('rule', '')} {skill.get('rationale', '')}"
33
- emb = get_embedding(skill_text)
 
 
34
  skill["embedding_vector"] = emb
35
- skills_with_embeddings.append(skill)
36
 
37
  skills_file = {
38
- "skills": skills_with_embeddings,
39
  "meta": {
40
  "company_id": company_id,
41
  "compiled_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
42
- "total_skills": len(skills_with_embeddings),
43
  "duration_ms": duration_ms,
44
  },
45
  }
@@ -82,9 +83,10 @@ async def write_brain(state: BrainState) -> dict:
82
 
83
  sf_id = sf_res.data[0]["id"]
84
 
85
- for skill in skills_with_embeddings:
 
86
  skill_copy = {k: v for k, v in skill.items() if k != "embedding_vector"}
87
- db.table("skills").insert(
88
  {
89
  "id": skill.get("id", str(uuid.uuid4())[:8]),
90
  "company_id": company_id,
@@ -95,7 +97,9 @@ async def write_brain(state: BrainState) -> dict:
95
  "confidence": float(skill.get("confidence", 0.5)),
96
  "skill_json": skill_copy,
97
  }
98
- ).execute()
 
 
99
 
100
  db.table("compile_runs").update(
101
  {
@@ -116,7 +120,7 @@ async def write_brain(state: BrainState) -> dict:
116
  "stage",
117
  {
118
  "name": "DONE",
119
- "detail": f"Brain {version_str} written: {len(skills_with_embeddings)} skills, {len(source_hashes)} sources, {duration_ms}ms",
120
  },
121
  )
122
  await emit(
@@ -125,7 +129,7 @@ async def write_brain(state: BrainState) -> dict:
125
  {
126
  "status": "success",
127
  "version": version_str,
128
- "skills_count": len(skills_with_embeddings),
129
  "source_count": len(source_hashes),
130
  "duration_ms": duration_ms,
131
  },
 
4
  import datetime
5
  from backend.graph.state import BrainState
6
  from backend.db.supabase import get_client
7
+ from backend.llm import get_embeddings
8
  from backend.sse import emit
9
 
10
 
 
27
  },
28
  )
29
 
30
+ skill_texts = [
31
+ f"{s.get('category', '')} {s.get('rule', '')} {s.get('rationale', '')}"
32
+ for s in final_skills
33
+ ]
34
+ embeddings = get_embeddings(skill_texts)
35
+ for skill, emb in zip(final_skills, embeddings):
36
  skill["embedding_vector"] = emb
 
37
 
38
  skills_file = {
39
+ "skills": final_skills,
40
  "meta": {
41
  "company_id": company_id,
42
  "compiled_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
43
+ "total_skills": len(final_skills),
44
  "duration_ms": duration_ms,
45
  },
46
  }
 
83
 
84
  sf_id = sf_res.data[0]["id"]
85
 
86
+ skill_rows = []
87
+ for skill in final_skills:
88
  skill_copy = {k: v for k, v in skill.items() if k != "embedding_vector"}
89
+ skill_rows.append(
90
  {
91
  "id": skill.get("id", str(uuid.uuid4())[:8]),
92
  "company_id": company_id,
 
97
  "confidence": float(skill.get("confidence", 0.5)),
98
  "skill_json": skill_copy,
99
  }
100
+ )
101
+ if skill_rows:
102
+ db.table("skills").insert(skill_rows).execute()
103
 
104
  db.table("compile_runs").update(
105
  {
 
120
  "stage",
121
  {
122
  "name": "DONE",
123
+ "detail": f"Brain {version_str} written: {len(final_skills)} skills, {len(source_hashes)} sources, {duration_ms}ms",
124
  },
125
  )
126
  await emit(
 
129
  {
130
  "status": "success",
131
  "version": version_str,
132
+ "skills_count": len(final_skills),
133
  "source_count": len(source_hashes),
134
  "duration_ms": duration_ms,
135
  },
backend/graph/state.py CHANGED
@@ -7,10 +7,6 @@ class BrainState(TypedDict):
7
  job_id: str
8
  source_files: Annotated[List[Dict[str, Any]], operator.add]
9
 
10
- structured_sops: Annotated[List[Dict[str, Any]], operator.add]
11
- normalized_events: Annotated[List[Dict[str, Any]], operator.add]
12
- resolved_cases: Annotated[List[Dict[str, Any]], operator.add]
13
-
14
  all_chunks: List[Dict[str, Any]]
15
 
16
  raw_decisions: Annotated[List[Dict[str, Any]], operator.add]
 
7
  job_id: str
8
  source_files: Annotated[List[Dict[str, Any]], operator.add]
9
 
 
 
 
 
10
  all_chunks: List[Dict[str, Any]]
11
 
12
  raw_decisions: Annotated[List[Dict[str, Any]], operator.add]
backend/llm.py CHANGED
@@ -14,7 +14,7 @@ MODEL_NAME = "RedHatAI/Qwen2.5-72B-Instruct-FP8-dynamic"
14
  llm = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed", timeout=120.0)
15
 
16
  # --- Concurrency throttle for parallel extraction ---
17
- _semaphore = asyncio.Semaphore(4)
18
 
19
  # --- Embedding model (local, fast, centralized here) ---
20
  _embedding_model = None
 
14
  llm = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="not-needed", timeout=120.0)
15
 
16
  # --- Concurrency throttle for parallel extraction ---
17
+ _semaphore = asyncio.Semaphore(8)
18
 
19
  # --- Embedding model (local, fast, centralized here) ---
20
  _embedding_model = None
backend/main.py CHANGED
@@ -1,4 +1,12 @@
1
- from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File, Form
 
 
 
 
 
 
 
 
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.responses import StreamingResponse
4
  import os
@@ -14,9 +22,26 @@ import datetime
14
  from backend.graph.graph import build_compilation_graph
15
  from backend.sse import event_bus, emit
16
  from backend.agent.brain_agent import handle_agent_query
17
- from backend.db.supabase import get_client, get_brain_by_version
18
- from backend.llm import check_vllm_health
19
- from backend.models.schemas import CompileRequest, AgentHandleRequest, AgentQueryRequest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  app = FastAPI(title="Kernl API", version="2.1.0")
22
 
@@ -132,9 +157,6 @@ async def run_compilation_graph(job_id: str, company_id: str):
132
  "job_id": job_id,
133
  "company_id": company_id,
134
  "source_files": [],
135
- "structured_sops": [],
136
- "normalized_events": [],
137
- "resolved_cases": [],
138
  "all_chunks": [],
139
  "raw_decisions": [],
140
  "workflow_steps": [],
@@ -334,11 +356,240 @@ async def list_brain_versions(company_id: str):
334
  return {"versions": versions, "company_id": company_id}
335
 
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  # ─────────────────────────────────────────────
338
  # Semantic Diff Engine
339
  # ─────────────────────────────────────────────
340
  @app.get("/diff/{v1}/{v2}")
341
- async def semantic_diff(v1: str, v2: str, company_id: str = "rivanly-inc"):
342
  db = get_client()
343
  if not db:
344
  raise HTTPException(status_code=500, detail="Database not connected")
 
1
+ from fastapi import (
2
+ FastAPI,
3
+ BackgroundTasks,
4
+ HTTPException,
5
+ UploadFile,
6
+ File,
7
+ Form,
8
+ Depends,
9
+ )
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.responses import StreamingResponse
12
  import os
 
22
  from backend.graph.graph import build_compilation_graph
23
  from backend.sse import event_bus, emit
24
  from backend.agent.brain_agent import handle_agent_query
25
+ from backend.db.supabase import (
26
+ get_client,
27
+ get_brain_by_version,
28
+ get_company,
29
+ get_company_stats,
30
+ upsert_company,
31
+ import_skills_file,
32
+ )
33
+ from backend.llm import check_vllm_health, llm_call, safe_llm_json_call
34
+ from backend.models.schemas import (
35
+ CompileRequest,
36
+ AgentHandleRequest,
37
+ AgentQueryRequest,
38
+ OnboardingAnalysisRequest,
39
+ CompanyUpdate,
40
+ SkillsImportRequest,
41
+ AuthRegisterRequest,
42
+ AuthLoginRequest,
43
+ )
44
+ from backend.auth.jwt import verify_token, require_auth
45
 
46
  app = FastAPI(title="Kernl API", version="2.1.0")
47
 
 
157
  "job_id": job_id,
158
  "company_id": company_id,
159
  "source_files": [],
 
 
 
160
  "all_chunks": [],
161
  "raw_decisions": [],
162
  "workflow_steps": [],
 
356
  return {"versions": versions, "company_id": company_id}
357
 
358
 
359
+ # ─────────────────────────────────────────────
360
+ # Phase 3 — Multi-Company & Onboarding
361
+ # ─────────────────────────────────────────────
362
+ ONBOARDING_SYSTEM_PROMPT = """You are an organizational analyst. Analyze the provided company documents and suggest:
363
+ 1. Industry — what sector this company operates in
364
+ 2. Departments — which departments are present or implied (e.g., Support, Engineering, HR, Finance, Sales, Marketing, Operations)
365
+ 3. Company size — estimate employee count range: "1-10", "11-50", "51-200", "201+"
366
+
367
+ Output ONLY a JSON object with these exact fields:
368
+ {
369
+ "industry": "string",
370
+ "departments": ["string"],
371
+ "size": "string",
372
+ "rationale": "string"
373
+ }
374
+
375
+ No preamble. No explanation. No markdown."""
376
+
377
+
378
+ @app.post("/onboarding/analyze")
379
+ async def onboarding_analyze(req: OnboardingAnalysisRequest):
380
+ src_dir = _company_sources_dir(req.company_id)
381
+ if not os.path.isdir(src_dir):
382
+ raise HTTPException(
383
+ status_code=404, detail=f"No sources found for {req.company_id}"
384
+ )
385
+
386
+ samples = []
387
+ for fn in sorted(os.listdir(src_dir)):
388
+ fp = os.path.join(src_dir, fn)
389
+ if not os.path.isfile(fp):
390
+ continue
391
+ with open(fp, "r", encoding="utf-8", errors="ignore") as f:
392
+ content = f.read(4000)
393
+ samples.append(f"--- {fn} ---\n{content}")
394
+
395
+ user_content = "Analyze these company documents:\n\n" + "\n\n".join(samples[:8])
396
+
397
+ try:
398
+ result = await safe_llm_json_call(
399
+ ONBOARDING_SYSTEM_PROMPT, user_content, max_tokens=1024
400
+ )
401
+ except Exception:
402
+ raise HTTPException(status_code=500, detail="Analysis failed — LLM unavailable")
403
+
404
+ if not isinstance(result, dict):
405
+ result = result[0] if isinstance(result, list) and result else {}
406
+
407
+ return {
408
+ "company_id": req.company_id,
409
+ "suggested_industry": result.get("industry", "Unknown"),
410
+ "suggested_departments": result.get("departments", []),
411
+ "suggested_size": result.get("size", "Unknown"),
412
+ "rationale": result.get("rationale", ""),
413
+ }
414
+
415
+
416
+ @app.get("/companies/{company_id}")
417
+ async def get_company_detail(company_id: str):
418
+ db = get_client()
419
+ company = get_company(company_id) if db else None
420
+ if not company:
421
+ raise HTTPException(status_code=404, detail="Company not found")
422
+ stats = get_company_stats(company_id) if db else {}
423
+ return {**company, **stats}
424
+
425
+
426
+ @app.patch("/companies/{company_id}")
427
+ async def update_company(company_id: str, update: CompanyUpdate):
428
+ db = get_client()
429
+ if not db:
430
+ raise HTTPException(status_code=500, detail="Database not connected")
431
+ payload = update.model_dump(exclude_none=True)
432
+ if not payload:
433
+ raise HTTPException(status_code=400, detail="No fields to update")
434
+ try:
435
+ result = upsert_company(company_id, payload)
436
+ except Exception as e:
437
+ err_msg = str(e)
438
+ if "Could not find" in err_msg or "does not exist" in err_msg:
439
+ raise HTTPException(
440
+ status_code=400,
441
+ detail=f"Database schema needs migration. GET /migrations/pending for SQL to run in Supabase dashboard. Error: {err_msg}",
442
+ )
443
+ raise HTTPException(
444
+ status_code=500, detail=f"Failed to update company: {err_msg}"
445
+ )
446
+ if not result:
447
+ raise HTTPException(status_code=500, detail="Failed to update company")
448
+ stats = get_company_stats(company_id)
449
+ return {**result, **stats}
450
+
451
+
452
+ @app.post("/companies/{company_id}/load-samples")
453
+ async def load_sample_sources(company_id: str):
454
+ """Clone template playbooks from rivanly-inc to a new company directory."""
455
+ template_dir = _company_sources_dir("rivanly-inc")
456
+ if not os.path.isdir(template_dir):
457
+ raise HTTPException(
458
+ status_code=404,
459
+ detail="Template sources not found. Ensure data/sources/rivanly-inc/ exists.",
460
+ )
461
+
462
+ target_dir = _company_sources_dir(company_id)
463
+ os.makedirs(target_dir, exist_ok=True)
464
+
465
+ copied = []
466
+ db = get_client()
467
+ for fn in sorted(os.listdir(template_dir)):
468
+ src = os.path.join(template_dir, fn)
469
+ if not os.path.isfile(src):
470
+ continue
471
+ dst = os.path.join(target_dir, fn)
472
+ shutil.copy2(src, dst)
473
+ copied.append(fn)
474
+
475
+ # Record in DB
476
+ if db:
477
+ try:
478
+ with open(dst, "rb") as f:
479
+ file_hash = hashlib.sha256(f.read()).hexdigest()
480
+ db.table("source_files").insert(
481
+ {
482
+ "company_id": company_id,
483
+ "filename": fn,
484
+ "sha256": file_hash,
485
+ "storage_path": f"data/sources/{company_id}/{fn}",
486
+ }
487
+ ).execute()
488
+ except Exception as e:
489
+ print(f"[load-samples] DB record error for {fn}: {e}")
490
+
491
+ # Ensure company exists in DB
492
+ if db:
493
+ try:
494
+ upsert_company(
495
+ company_id, {"name": company_id.replace("-", " ").title()}
496
+ )
497
+ except Exception as e:
498
+ print(f"[load-samples] Company upsert error: {e}")
499
+
500
+ return {"status": "loaded", "files": copied, "count": len(copied)}
501
+
502
+
503
+
504
+ # ─────────────────────────────────────────────
505
+ # Phase 4 — Skills Marketplace
506
+ # ─────────────────────────────────────────────
507
+
508
+
509
+ @app.get("/skills/{company_id}/download")
510
+ async def download_skills(company_id: str):
511
+ db = get_client()
512
+ if not db:
513
+ raise HTTPException(status_code=500, detail="Database not connected")
514
+ res = (
515
+ db.table("skills_files")
516
+ .select("*")
517
+ .eq("company_id", company_id)
518
+ .eq("is_current", True)
519
+ .execute()
520
+ )
521
+ if not res.data:
522
+ raise HTTPException(
523
+ status_code=404, detail="No skills file found for this company"
524
+ )
525
+ brain = res.data[0]
526
+ return StreamingResponse(
527
+ iter([json.dumps(brain["brain_json"], indent=2)]),
528
+ media_type="application/json",
529
+ headers={
530
+ "Content-Disposition": f'attachment; filename="skills_{company_id}_{brain["version"]}.json"'
531
+ },
532
+ )
533
+
534
+
535
+ @app.post("/skills/import")
536
+ async def import_skills(req: SkillsImportRequest):
537
+ db = get_client()
538
+ if not db:
539
+ raise HTTPException(status_code=500, detail="Database not connected")
540
+ if not req.skills:
541
+ raise HTTPException(status_code=400, detail="No skills provided in payload")
542
+ skills_file = import_skills_file(
543
+ req.company_id, req.skills, req.version, req.source_label
544
+ )
545
+ if not skills_file:
546
+ raise HTTPException(status_code=500, detail="Failed to import skills")
547
+ return {
548
+ "status": "imported",
549
+ "company_id": req.company_id,
550
+ "version": req.version,
551
+ "skill_count": len(req.skills),
552
+ "skills_file_id": skills_file["id"],
553
+ }
554
+
555
+
556
+ # ─────────────────────────────────────────────
557
+ # Phase 6 — Auth
558
+ # ─────────────────────────────────────────────
559
+
560
+
561
+ @app.get("/auth/config")
562
+ async def auth_config():
563
+ return {
564
+ "supabase_url": os.getenv("SUPABASE_URL", ""),
565
+ "supabase_anon_key": os.getenv("SUPABASE_KEY", ""),
566
+ }
567
+
568
+
569
+ @app.get("/auth/me")
570
+ async def auth_me(user: dict = Depends(require_auth)):
571
+ return {"user": user}
572
+
573
+
574
+ @app.get("/migrations/pending")
575
+ async def migrations_pending():
576
+ """Return SQL statements that need to be run in Supabase dashboard."""
577
+ return {
578
+ "database": os.getenv("SUPABASE_URL", ""),
579
+ "sql": [
580
+ "ALTER TABLE companies ADD COLUMN IF NOT EXISTS industry TEXT;",
581
+ "ALTER TABLE companies ADD COLUMN IF NOT EXISTS company_size TEXT;",
582
+ "ALTER TABLE companies ADD COLUMN IF NOT EXISTS description TEXT;",
583
+ ],
584
+ "instructions": "Run these SQL statements in your Supabase dashboard SQL editor at https://supabase.com/dashboard/project/csxswinhxuziyssuuxzx/sql/new",
585
+ }
586
+
587
+
588
  # ─────────────────────────────────────────────
589
  # Semantic Diff Engine
590
  # ─────────────────────────────────────────────
591
  @app.get("/diff/{v1}/{v2}")
592
+ async def semantic_diff(v1: str, v2: str, company_id: str):
593
  db = get_client()
594
  if not db:
595
  raise HTTPException(status_code=500, detail="Database not connected")
backend/models/schemas.py CHANGED
@@ -57,3 +57,59 @@ class DiffResponse(BaseModel):
57
  deleted: List[DiffItem] = []
58
  modified: List[DiffModified] = []
59
  confidence_shifts: List[DiffConfidenceShift] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  deleted: List[DiffItem] = []
58
  modified: List[DiffModified] = []
59
  confidence_shifts: List[DiffConfidenceShift] = []
60
+
61
+
62
+ # ─────────────────────────────────────────────
63
+ # Phase 3 — Multi-Company & Onboarding
64
+ # ─────────────────────────────────────────────
65
+
66
+
67
+ class OnboardingAnalysisRequest(BaseModel):
68
+ company_id: str
69
+
70
+
71
+ class OnboardingSuggestion(BaseModel):
72
+ company_id: str
73
+ suggested_industry: str
74
+ suggested_departments: List[str]
75
+ suggested_size: str
76
+ rationale: str
77
+
78
+
79
+ class CompanyUpdate(BaseModel):
80
+ name: Optional[str] = None
81
+ industry: Optional[str] = None
82
+ company_size: Optional[str] = None
83
+ description: Optional[str] = None
84
+
85
+
86
+ # ─────────────────────────────────────────────
87
+ # Phase 6 — Auth
88
+ # ─────────────────────────────────────────────
89
+
90
+
91
+ class AuthRegisterRequest(BaseModel):
92
+ email: str
93
+ password: str
94
+
95
+
96
+ class AuthLoginRequest(BaseModel):
97
+ email: str
98
+ password: str
99
+
100
+
101
+ class AuthResponse(BaseModel):
102
+ access_token: str
103
+ user: Dict[str, Any]
104
+
105
+
106
+ # ─────────────────────────────────────────────
107
+ # Phase 4 — Skills Marketplace
108
+ # ─────────────────────────────────────────────
109
+
110
+
111
+ class SkillsImportRequest(BaseModel):
112
+ company_id: str
113
+ version: str = "imported"
114
+ skills: List[Dict[str, Any]]
115
+ source_label: str = "marketplace_import"
frontend/src/app/compile/[jobId]/page.tsx CHANGED
@@ -1,40 +1,37 @@
1
  "use client";
2
 
3
- import { useEffect, useState, use } from "react";
4
  import { useRouter } from "next/navigation";
 
 
 
5
 
6
- interface LogEvent {
7
- timestamp: string;
8
- type: string;
9
- data: any;
10
- }
11
 
12
- const STAGE_LABELS: Record<string, string> = {
13
- pipeline_start: "🚀 Pipeline Started",
14
- LOADING_DOCS: "📂 Loading Documents",
15
- LOADING_DOCS_DONE: "✅ Sources Loaded",
16
- INGEST_NOTION: "📝 Ingesting SOPs",
17
- INGEST_SLACK: "💬 Ingesting Slack Messages",
18
- INGEST_TICKETS: "🎫 Ingesting Support Tickets",
19
- INGEST_JOIN: "🔗 Merging All Chunks",
20
- EXTRACT_DECISIONS: "⚖️ Extracting Rules & Policies",
21
- EXTRACT_DECISIONS_DONE: "✅ Rules Extracted",
22
- EXTRACT_WORKFLOWS: "🔁 Extracting Workflows",
23
- EXTRACT_WORKFLOWS_DONE: "✅ Workflows Extracted",
24
- EXTRACT_EXCEPTIONS: "⚠️ Extracting Exceptions & Edge Cases",
25
- EXTRACT_EXCEPTIONS_DONE: " Exceptions Extracted",
26
- DETECT_CONTRADICTIONS: "🔄 Detecting Cross-Source Contradictions",
27
- DETECT_CONTRADICTIONS_DONE: " Contradictions Analyzed",
28
- SYNTHESIZING_SKILLS: "⚡ Synthesizing Skills from All Extractions",
29
- SYNTHESIZING_DONE: " Skills Synthesized",
30
- LINKING_EVIDENCE: "🔗 Linking Evidence to Skills",
31
- LINKING_DONE: " Evidence Linked",
32
- SCORING_CONFIDENCE: "📊 Scoring Confidence",
33
- SCORING_DONE: " Confidence Scored",
34
- WRITING_DB: "💾 Pre-embedding & Writing to Database",
35
- DONE: "✅ Pipeline Complete",
36
- pipeline_complete: "🎉 Compilation Finished",
37
- pipeline_error: "❌ Pipeline Error",
38
  };
39
 
40
  export default function CompileViewer({ params }: { params: Promise<{ jobId: string }> }) {
@@ -42,86 +39,164 @@ export default function CompileViewer({ params }: { params: Promise<{ jobId: str
42
  const jobId = resolvedParams.jobId;
43
  const [logs, setLogs] = useState<LogEvent[]>([]);
44
  const [status, setStatus] = useState("Connecting...");
 
 
 
 
 
45
  const router = useRouter();
46
 
47
  useEffect(() => {
48
  if (!jobId) return;
49
-
50
- const eventSource = new EventSource(`http://localhost:8080/compile/${jobId}/stream`);
51
-
52
- eventSource.onmessage = (event) => {
53
  const parsed = JSON.parse(event.data);
54
- const eventType = parsed.event;
55
- const eventData = parsed.data;
56
-
57
- setLogs((prev) => [
58
- ...prev,
59
- { timestamp: new Date().toLocaleTimeString(), type: eventType, data: eventData },
60
- ]);
61
-
62
- // Update the status bar based on event type
63
- if (eventType === "stage") {
64
- const stageName = eventData.name || "";
65
- const label = STAGE_LABELS[stageName] || stageName;
66
- const detail = eventData.detail || "";
67
- setStatus(`${label}${detail ? ` — ${detail}` : ""}`);
68
- } else if (eventType === "pipeline_start") {
69
- setStatus(STAGE_LABELS.pipeline_start);
70
- } else if (eventType === "pipeline_complete") {
71
- setStatus(STAGE_LABELS.pipeline_complete);
72
- eventSource.close();
73
- } else if (eventType === "pipeline_error") {
74
- setStatus(`❌ Error: ${eventData.error || "Unknown"}`);
75
- eventSource.close();
76
- }
77
  };
 
 
 
78
 
79
- eventSource.onerror = () => {
80
- eventSource.close();
81
- };
82
 
83
- return () => eventSource.close();
84
- }, [jobId]);
 
 
85
 
86
  return (
87
- <div className="min-h-screen p-8 flex flex-col">
88
- <div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
89
- <h1 className="text-2xl font-bold text-primary">Pipeline Stream</h1>
90
- <div className="flex items-center gap-4">
91
- <span
92
- className={`px-3 py-1 font-mono text-sm border ${
93
- status.includes("Finished") || status.includes("Complete")
94
- ? "border-green-500 text-green-500"
95
- : status.includes("Error")
96
- ? "border-red-500 text-red-500"
97
- : "border-primary text-primary animate-pulse"
98
- }`}
99
- >
100
- {status}
101
  </span>
102
- <button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
103
- Back
104
- </button>
105
  </div>
106
- </div>
107
 
108
- <div className="flex-1 bg-surface border border-gray-800 p-4 font-mono text-sm overflow-y-auto">
109
- {logs.map((log, i) => {
110
- const isStage = log.type === "stage";
111
- const stageName = isStage ? log.data?.name : log.type;
112
- const label = STAGE_LABELS[stageName] || stageName;
113
- const detail = isStage ? log.data?.detail || "" : JSON.stringify(log.data);
114
- const isError = stageName?.includes("error") || stageName?.includes("Error");
 
115
 
116
- return (
117
- <div key={i} className="mb-2">
118
- <span className="text-text-secondary">[{log.timestamp}]</span>{" "}
119
- <span className={isError ? "text-red-500" : "text-primary"}>{label}</span>{" "}
120
- <span className="text-foreground">{detail}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  </div>
122
- );
123
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
- </div>
126
  );
127
  }
 
1
  "use client";
2
 
3
+ import { useEffect, useState, use, useRef } from "react";
4
  import { useRouter } from "next/navigation";
5
+ import { API_BASE } from "@/lib/api";
6
+ import DashboardLayout from "@/components/DashboardLayout";
7
+ import GlassCard from "@/components/ui/GlassCard";
8
 
9
+ interface LogEvent { timestamp: string; type: string; data: Record<string, unknown>; }
 
 
 
 
10
 
11
+ const STAGES: Record<string, { label: string; icon: string }> = {
12
+ pipeline_start: { label: "Pipeline Started", icon: "▶" },
13
+ LOADING_DOCS: { label: "Loading Documents", icon: "◈" },
14
+ LOADING_DOCS_DONE: { label: "Sources Loaded", icon: "✓" },
15
+ CHUNKING: { label: "Chunking Documents", icon: "◈" },
16
+ CHUNKING_DONE: { label: "Documents Chunked", icon: "✓" },
17
+ EXTRACT_DECISIONS: { label: "Extracting Rules", icon: "◈" },
18
+ EXTRACT_DECISIONS_DONE: { label: "Rules Extracted", icon: "✓" },
19
+ EXTRACT_WORKFLOWS: { label: "Extracting Workflows", icon: "◈" },
20
+ EXTRACT_WORKFLOWS_DONE: { label: "Workflows Extracted", icon: "✓" },
21
+ EXTRACT_EXCEPTIONS: { label: "Extracting Exceptions", icon: "◈" },
22
+ EXTRACT_EXCEPTIONS_DONE: { label: "Exceptions Extracted", icon: "✓" },
23
+ DETECT_CONTRADICTIONS: { label: "Detecting Contradictions", icon: "◈" },
24
+ DETECT_CONTRADICTIONS_DONE: { label: "Contradictions Analyzed", icon: "✓" },
25
+ SYNTHESIZING_SKILLS: { label: "Synthesizing Skills", icon: "◈" },
26
+ SYNTHESIZING_DONE: { label: "Skills Synthesized", icon: "✓" },
27
+ LINKING_EVIDENCE: { label: "Linking Evidence", icon: "◈" },
28
+ LINKING_DONE: { label: "Evidence Linked", icon: "✓" },
29
+ SCORING_CONFIDENCE: { label: "Scoring Confidence", icon: "◈" },
30
+ SCORING_DONE: { label: "Confidence Scored", icon: "✓" },
31
+ WRITING_DB: { label: "Writing to Database", icon: "◈" },
32
+ DONE: { label: "Pipeline Complete", icon: "✓" },
33
+ pipeline_complete: { label: "Compilation Finished", icon: "✓" },
34
+ pipeline_error: { label: "Pipeline Error", icon: "✕" },
 
 
35
  };
36
 
37
  export default function CompileViewer({ params }: { params: Promise<{ jobId: string }> }) {
 
39
  const jobId = resolvedParams.jobId;
40
  const [logs, setLogs] = useState<LogEvent[]>([]);
41
  const [status, setStatus] = useState("Connecting...");
42
+ const [companyId, setCompanyId] = useState<string | null>(null);
43
+ const [pipelineDone, setPipelineDone] = useState(false);
44
+ const [pipelineError, setPipelineError] = useState<string | null>(null);
45
+ const [currentStage, setCurrentStage] = useState<string | null>(null);
46
+ const logRef = useRef<HTMLDivElement>(null);
47
  const router = useRouter();
48
 
49
  useEffect(() => {
50
  if (!jobId) return;
51
+ const es = new EventSource(`${API_BASE}/compile/${jobId}/stream`);
52
+ es.onmessage = (event) => {
 
 
53
  const parsed = JSON.parse(event.data);
54
+ const et = parsed.event;
55
+ const ed = parsed.data;
56
+ setLogs((prev) => [...prev, { timestamp: new Date().toLocaleTimeString(), type: et, data: ed }]);
57
+ if (et === "pipeline_start" && ed?.company_id) setCompanyId(ed.company_id as string);
58
+ if (et === "stage") {
59
+ const n = (ed.name as string) || "";
60
+ const s = STAGES[n];
61
+ const d = (ed.detail as string) || "";
62
+ setCurrentStage(n);
63
+ setStatus(`${s?.label || n}${d ? ` — ${d}` : ""}`);
64
+ } else if (et === "pipeline_start") { setStatus("Pipeline Started"); }
65
+ else if (et === "pipeline_complete") { setStatus("Compilation Finished"); setPipelineDone(true); es.close(); }
66
+ else if (et === "pipeline_error") { setStatus(`Error: ${(ed.error as string) || "Unknown"}`); setPipelineError((ed.error as string) || "Unknown error"); es.close(); }
 
 
 
 
 
 
 
 
 
 
67
  };
68
+ es.onerror = () => es.close();
69
+ return () => es.close();
70
+ }, [jobId]);
71
 
72
+ useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [logs]);
 
 
73
 
74
+ const stageKeys = ["LOADING_DOCS", "CHUNKING", "EXTRACT_DECISIONS", "EXTRACT_WORKFLOWS", "EXTRACT_EXCEPTIONS", "DETECT_CONTRADICTIONS", "SYNTHESIZING_SKILLS", "LINKING_EVIDENCE", "SCORING_CONFIDENCE", "WRITING_DB"];
75
+ const completedStages = new Set(logs.filter(l => l.type === "stage").map(l => l.data?.name as string));
76
+ const currentIdx = currentStage ? stageKeys.findIndex(k => k === currentStage || k + "_DONE" === currentStage) : -1;
77
+ const progress = pipelineDone ? 100 : pipelineError ? 0 : Math.max(0, Math.round(((currentIdx + 1) / stageKeys.length) * 100));
78
 
79
  return (
80
+ <DashboardLayout>
81
+ <div className="p-6 lg:p-8 max-w-6xl mx-auto animate-fade-in">
82
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
83
+ <div>
84
+ <h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>Compile Pipeline</h1>
85
+ <p className="text-sm mt-1 font-mono" style={{ color: "var(--text-muted)" }}>Job: {jobId}</p>
86
+ </div>
87
+ <span className={`badge ${pipelineDone ? "badge--success" : pipelineError ? "badge--error" : "badge--primary"}`}>
88
+ {pipelineDone ? "✓ Complete" : pipelineError ? "✕ Failed" : "● Running"}
 
 
 
 
 
89
  </span>
 
 
 
90
  </div>
 
91
 
92
+ {/* Progress Bar */}
93
+ <div className="mb-6">
94
+ <div className="flex justify-between items-center mb-2">
95
+ <span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{status}</span>
96
+ <span className="text-xs font-mono font-bold" style={{ color: "var(--primary)" }}>{progress}%</span>
97
+ </div>
98
+ <div className="progress-bar"><div className="progress-bar__fill" style={{ width: `${progress}%`, background: pipelineError ? "var(--error)" : "var(--primary)" }} /></div>
99
+ </div>
100
 
101
+ {/* Success Card */}
102
+ {pipelineDone && (
103
+ <GlassCard className="mb-6" style={{ borderColor: "rgba(52,211,153,0.3)" } as React.CSSProperties}>
104
+ <div className="flex items-center gap-3 mb-4">
105
+ <span className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: "var(--success-bg)", color: "var(--success)" }}>
106
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 10l4 4 8-8" strokeLinecap="round" strokeLinejoin="round" /></svg>
107
+ </span>
108
+ <div>
109
+ <h2 className="text-lg font-bold" style={{ color: "var(--success)" }}>Brain Compiled Successfully!</h2>
110
+ <p className="text-sm" style={{ color: "var(--text-muted)" }}>Your operational brain is ready.{companyId && <> Company: <span className="font-mono" style={{ color: "var(--primary)" }}>{companyId}</span></>}</p>
111
+ </div>
112
+ </div>
113
+ <div className="flex gap-3 flex-wrap">
114
+ {companyId && (<><button onClick={() => router.push(`/demo/${companyId}`)} className="btn-primary">Query Demo</button><button onClick={() => router.push(`/skills/${companyId}`)} className="btn-secondary">View Skills</button></>)}
115
+ <button onClick={() => router.push("/")} className="btn-ghost">Dashboard</button>
116
+ </div>
117
+ </GlassCard>
118
+ )}
119
+
120
+ {/* Error Card */}
121
+ {pipelineError && (
122
+ <GlassCard className="mb-6" style={{ borderColor: "rgba(248,113,113,0.3)" } as React.CSSProperties}>
123
+ <div className="flex items-center gap-3 mb-4">
124
+ <span className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: "var(--error-bg)", color: "var(--error)" }}>
125
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l8 8M14 6l-8 8" strokeLinecap="round" /></svg>
126
+ </span>
127
+ <div>
128
+ <h2 className="text-lg font-bold" style={{ color: "var(--error)" }}>Compilation Failed</h2>
129
+ <p className="text-sm" style={{ color: "var(--text-muted)" }}>{pipelineError}</p>
130
+ </div>
131
+ </div>
132
+ <div className="flex gap-3">
133
+ <button onClick={() => window.location.reload()} className="btn-secondary">Retry</button>
134
+ <button onClick={() => router.push("/")} className="btn-ghost">Dashboard</button>
135
  </div>
136
+ </GlassCard>
137
+ )}
138
+
139
+ {/* Pipeline Stages + Terminal */}
140
+ <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
141
+ {/* Pipeline Stages */}
142
+ <div className="lg:col-span-2">
143
+ <GlassCard elevated padding="lg">
144
+ <p className="input-label mb-4">Pipeline Stages</p>
145
+ <div className="space-y-0">
146
+ {stageKeys.map((key, i) => {
147
+ const s = STAGES[key];
148
+ const done = completedStages.has(key) || completedStages.has(key + "_DONE");
149
+ const active = currentStage === key;
150
+ const isPast = currentIdx > i || pipelineDone;
151
+ return (
152
+ <div key={key}>
153
+ <div className="flex items-center gap-3 py-2.5">
154
+ <span className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" style={{
155
+ background: done || isPast ? "var(--primary)" : active ? "var(--primary-ghost)" : "rgba(255,255,255,0.04)",
156
+ color: done || isPast ? "var(--text-inverse)" : active ? "var(--primary)" : "var(--text-muted)",
157
+ border: active ? "1px solid var(--primary)" : "1px solid transparent",
158
+ }}>
159
+ {done || isPast ? "✓" : i + 1}
160
+ </span>
161
+ <span className="text-sm" style={{ color: done || isPast ? "var(--text-primary)" : active ? "var(--primary)" : "var(--text-muted)" }}>
162
+ {s?.label || key}
163
+ </span>
164
+ {active && !done && !isPast && <span className="w-1.5 h-1.5 rounded-full animate-pulse ml-auto" style={{ background: "var(--primary)" }} />}
165
+ </div>
166
+ {i < stageKeys.length - 1 && <div className="ml-3.5 w-px h-3" style={{ background: isPast ? "var(--primary-dim)" : "var(--border)" }} />}
167
+ </div>
168
+ );
169
+ })}
170
+ </div>
171
+ </GlassCard>
172
+ </div>
173
+
174
+ {/* Terminal Log */}
175
+ <div className="lg:col-span-3">
176
+ <div className="terminal h-[500px]" ref={logRef}>
177
+ <div className="flex items-center gap-2 mb-3 pb-3" style={{ borderBottom: "1px solid var(--border)" }}>
178
+ <span className="w-2 h-2 rounded-full" style={{ background: pipelineDone ? "var(--success)" : pipelineError ? "var(--error)" : "var(--primary)" }} />
179
+ <span className="text-xs font-bold uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>Live Log</span>
180
+ </div>
181
+ {logs.map((log, i) => {
182
+ const isStage = log.type === "stage";
183
+ const sn = isStage ? (log.data?.name as string) : log.type;
184
+ const s = STAGES[sn];
185
+ const label = s?.label || sn;
186
+ const detail = isStage ? (log.data?.detail as string) || "" : JSON.stringify(log.data);
187
+ const isErr = sn?.includes("error");
188
+ return (
189
+ <div key={i} className="terminal-line mb-1">
190
+ <span className="terminal-time">{log.timestamp}</span>
191
+ <span className={isErr ? "terminal-error" : "terminal-event"}>{label}</span>
192
+ {detail && <span className="terminal-detail">{detail}</span>}
193
+ </div>
194
+ );
195
+ })}
196
+ </div>
197
+ </div>
198
+ </div>
199
  </div>
200
+ </DashboardLayout>
201
  );
202
  }
frontend/src/app/demo/[companyId]/page.tsx CHANGED
@@ -2,6 +2,10 @@
2
 
3
  import { useState, use } from "react";
4
  import { useRouter } from "next/navigation";
 
 
 
 
5
 
6
  type AgentResponse = {
7
  recommended_action?: string;
@@ -14,256 +18,100 @@ type AgentResponse = {
14
  error?: string;
15
  };
16
 
 
 
 
 
 
 
17
  export default function QueryDemo({ params }: { params: Promise<{ companyId: string }> }) {
18
  const resolvedParams = use(params);
19
  const companyId = resolvedParams.companyId;
20
  const [scenario, setScenario] = useState("");
21
- const [contextJson, setContextJson] = useState("{}");
22
  const [loading, setLoading] = useState(false);
23
-
24
- const [withBrainResponse, setWithBrainResponse] = useState<AgentResponse | null>(null);
25
- const [withoutBrainResponse, setWithoutBrainResponse] = useState<AgentResponse | null>(null);
26
-
27
  const router = useRouter();
28
 
 
 
29
  const handleQuery = async (e: React.FormEvent) => {
30
  e.preventDefault();
31
  if (!scenario) return;
32
- setLoading(true);
33
- setWithBrainResponse(null);
34
- setWithoutBrainResponse(null);
35
-
36
- let parsedContext = {};
37
- try {
38
- if (contextJson.trim()) {
39
- parsedContext = JSON.parse(contextJson);
40
- }
41
- } catch {
42
- alert("Invalid JSON in context field");
43
- setLoading(false);
44
- return;
45
- }
46
-
47
  try {
48
- const [resWithBrain, resWithoutBrain] = await Promise.all([
49
- fetch("http://localhost:8080/agent/handle", {
50
- method: "POST",
51
- headers: { "Content-Type": "application/json" },
52
- body: JSON.stringify({ company_id: companyId, scenario, context: parsedContext, with_brain: true }),
53
- }),
54
- fetch("http://localhost:8080/agent/handle", {
55
- method: "POST",
56
- headers: { "Content-Type": "application/json" },
57
- body: JSON.stringify({ company_id: companyId, scenario, context: parsedContext, with_brain: false }),
58
- }),
59
  ]);
60
-
61
- setWithBrainResponse(await resWithBrain.json());
62
- setWithoutBrainResponse(await resWithoutBrain.json());
63
- } catch (err) {
64
- console.error(err);
65
- alert("Query failed — is the backend running?");
66
- } finally {
67
- setLoading(false);
68
- }
69
- };
70
-
71
- const confidenceColor = (c: number) => {
72
- if (c >= 0.75) return "bg-green-500";
73
- if (c >= 0.5) return "bg-yellow-500";
74
- if (c >= 0.25) return "bg-orange-500";
75
- return "bg-red-500";
76
  };
77
 
78
  return (
79
- <div className="min-h-screen p-8 flex flex-col items-center">
80
- <div className="w-full max-w-5xl">
81
- <div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
82
- <h1 className="text-2xl font-bold text-primary">Brain Query Demo</h1>
83
- <button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
84
- Back to Dashboard
85
- </button>
 
86
  </div>
87
 
88
- <form onSubmit={handleQuery} className="mb-8 bg-surface p-6 border border-gray-800">
89
- <div className="flex flex-col gap-4">
90
- <div>
91
- <label className="block text-text-secondary text-sm font-bold mb-2">Scenario</label>
92
- <textarea
93
- className="w-full px-4 py-3 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary min-h-[100px]"
94
- placeholder="Enterprise customer, 18 months tenure, wants $1,200 refund"
95
- value={scenario}
96
- onChange={(e) => setScenario(e.target.value)}
97
- />
98
- </div>
99
- <div>
100
- <label className="block text-text-secondary text-sm font-bold mb-2">Context (JSON)</label>
101
- <textarea
102
- className="w-full px-4 py-3 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary font-mono text-sm min-h-[80px]"
103
- placeholder='{"plan": "enterprise", "tenure_months": 18, "refund_amount": 1200}'
104
- value={contextJson}
105
- onChange={(e) => setContextJson(e.target.value)}
106
- />
107
- </div>
108
- <button
109
- type="submit"
110
- disabled={loading || !scenario}
111
- className="bg-primary text-background font-bold py-3 px-6 hover:opacity-90 disabled:opacity-50 self-end"
112
- >
113
- {loading ? "Thinking..." : "Compare Models"}
114
- </button>
115
  </div>
116
- </form>
117
-
118
- {(withBrainResponse || withoutBrainResponse) && (
119
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
120
- {/* WITHOUT BRAIN */}
121
- <div className="bg-surface border border-gray-800 p-6 opacity-75">
122
- <h2 className="text-xl font-bold text-gray-400 mb-4 flex items-center gap-2">
123
- <span className="w-2 h-2 rounded-full bg-gray-500"></span>
124
- Without Brain (Generic AI)
125
- </h2>
126
-
127
- {withoutBrainResponse ? (
128
- <div className="space-y-4 text-gray-300">
129
- <div>
130
- <h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Response</h3>
131
- <p className="text-lg bg-background p-4 border border-gray-800 rounded">
132
- {withoutBrainResponse.recommended_action || "No action"}
133
- </p>
134
- </div>
135
- <div>
136
- <h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Rule Applied</h3>
137
- <p className="italic">{withoutBrainResponse.rule_applied || "General knowledge"}</p>
138
- </div>
139
- {withoutBrainResponse.reasoning && (
140
- <div>
141
- <h3 className="text-gray-500 text-sm font-bold uppercase tracking-wider mb-1">Reasoning</h3>
142
- <p className="text-sm">{withoutBrainResponse.reasoning}</p>
143
- </div>
144
- )}
145
- </div>
146
- ) : (
147
- <p>Loading...</p>
148
- )}
149
- </div>
150
 
151
- {/* WITH BRAIN */}
152
- <div className="bg-surface border-2 border-primary p-6 relative shadow-[0_0_15px_rgba(45,212,191,0.1)]">
153
- <div className="absolute -top-3 -right-3 bg-primary text-background text-xs font-bold px-3 py-1 uppercase tracking-wider rounded-full">
154
- Company Brain
 
 
 
 
 
 
 
 
 
 
 
155
  </div>
156
- <h2 className="text-xl font-bold text-primary mb-4 flex items-center gap-2">
157
- <span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
158
- With Brain (Compiled Agent)
159
- </h2>
160
-
161
- {withBrainResponse ? (
162
- <div className="space-y-4">
163
- {withBrainResponse.error ? (
164
- <p className="text-red-400">{withBrainResponse.error}</p>
165
- ) : (
166
- <>
167
- <div>
168
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
169
- Recommended Action
170
- </h3>
171
- <p className="text-xl font-semibold text-white bg-primary/10 p-4 border border-primary/30 rounded">
172
- {withBrainResponse.recommended_action}
173
- </p>
174
- </div>
175
-
176
- <div className="grid grid-cols-2 gap-4">
177
- <div>
178
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
179
- Skill Matched
180
- </h3>
181
- <p className="font-mono text-sm bg-background p-2 rounded">
182
- {withBrainResponse.skill_matched || "N/A"}
183
- </p>
184
- </div>
185
- <div>
186
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
187
- Confidence
188
- </h3>
189
- <div className="flex items-center gap-2 mt-2">
190
- <div className="flex-1 bg-background h-2 rounded-full overflow-hidden">
191
- <div
192
- className={`h-full ${confidenceColor(withBrainResponse.confidence || 0)}`}
193
- style={{ width: `${(withBrainResponse.confidence || 0) * 100}%` }}
194
- ></div>
195
- </div>
196
- <span className="text-xs font-mono">
197
- {((withBrainResponse.confidence || 0) * 100).toFixed(0)}%
198
- </span>
199
- </div>
200
- </div>
201
- </div>
202
-
203
- {/* Retrieval Scores */}
204
- {withBrainResponse.retrieval_scores && withBrainResponse.retrieval_scores.length > 0 && (
205
- <div>
206
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
207
- Retrieval Scores (Top {withBrainResponse.retrieval_scores.length} Skills)
208
- </h3>
209
- <div className="flex gap-2 flex-wrap">
210
- {withBrainResponse.retrieval_scores.map((score, i) => (
211
- <span
212
- key={i}
213
- className="bg-background border border-gray-700 px-2 py-1 rounded text-xs font-mono"
214
- >
215
- #{i + 1}: {(score * 100).toFixed(1)}%
216
- </span>
217
- ))}
218
- </div>
219
- </div>
220
- )}
221
-
222
- <div>
223
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
224
- Rule Applied
225
- </h3>
226
- <p className="text-white border-l-2 border-primary pl-3 py-1 font-medium">
227
- {withBrainResponse.rule_applied}
228
- </p>
229
- </div>
230
-
231
- {/* Reasoning */}
232
- {withBrainResponse.reasoning && (
233
- <div>
234
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-1">
235
- LLM Reasoning
236
- </h3>
237
- <p className="text-sm text-gray-300 bg-background p-3 rounded border border-gray-800">
238
- {withBrainResponse.reasoning}
239
- </p>
240
- </div>
241
- )}
242
-
243
- {withBrainResponse.evidence && withBrainResponse.evidence.length > 0 && (
244
- <div>
245
- <h3 className="text-primary/70 text-sm font-bold uppercase tracking-wider mb-2">
246
- Evidence Trail
247
- </h3>
248
- <ul className="space-y-2">
249
- {withBrainResponse.evidence.map((src, i) => (
250
- <li key={i} className="text-gray-300 text-sm bg-background p-3 rounded border border-gray-800">
251
- {src}
252
- </li>
253
- ))}
254
- </ul>
255
- </div>
256
- )}
257
- </>
258
- )}
259
- </div>
260
- ) : (
261
- <p>Loading...</p>
262
- )}
263
  </div>
264
  </div>
265
  )}
266
  </div>
267
- </div>
268
  );
269
  }
 
2
 
3
  import { useState, use } from "react";
4
  import { useRouter } from "next/navigation";
5
+ import { API_BASE } from "@/lib/api";
6
+ import DashboardLayout from "@/components/DashboardLayout";
7
+ import GlassCard from "@/components/ui/GlassCard";
8
+ import ConfidenceBadge from "@/components/ui/ConfidenceBadge";
9
 
10
  type AgentResponse = {
11
  recommended_action?: string;
 
18
  error?: string;
19
  };
20
 
21
+ const PRESETS = [
22
+ { label: "Enterprise Refund", scenario: "Enterprise customer, 18 months tenure, wants a $1,200 refund for unused seats", context: '{"plan": "enterprise", "tenure_months": 18, "refund_amount": 1200}' },
23
+ { label: "Priority Escalation", scenario: "Customer has been waiting 3 days for a response on a billing issue and is threatening to churn", context: '{"issue_type": "billing", "wait_days": 3, "sentiment": "frustrated"}' },
24
+ { label: "New Hire Onboarding", scenario: "New support agent just started, needs to know the standard process for handling refund requests", context: '{"agent_level": "junior", "department": "support"}' },
25
+ ];
26
+
27
  export default function QueryDemo({ params }: { params: Promise<{ companyId: string }> }) {
28
  const resolvedParams = use(params);
29
  const companyId = resolvedParams.companyId;
30
  const [scenario, setScenario] = useState("");
31
+ const [contextJson, setContextJson] = useState("");
32
  const [loading, setLoading] = useState(false);
33
+ const [withBrain, setWithBrain] = useState<AgentResponse | null>(null);
34
+ const [withoutBrain, setWithoutBrain] = useState<AgentResponse | null>(null);
 
 
35
  const router = useRouter();
36
 
37
+ const applyPreset = (p: (typeof PRESETS)[0]) => { setScenario(p.scenario); setContextJson(p.context); setWithBrain(null); setWithoutBrain(null); };
38
+
39
  const handleQuery = async (e: React.FormEvent) => {
40
  e.preventDefault();
41
  if (!scenario) return;
42
+ setLoading(true); setWithBrain(null); setWithoutBrain(null);
43
+ let ctx = {};
44
+ try { if (contextJson.trim()) ctx = JSON.parse(contextJson); } catch { alert("Invalid JSON"); setLoading(false); return; }
 
 
 
 
 
 
 
 
 
 
 
 
45
  try {
46
+ const [r1, r2] = await Promise.all([
47
+ fetch(`${API_BASE}/agent/handle`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId, scenario, context: ctx, with_brain: true }) }),
48
+ fetch(`${API_BASE}/agent/handle`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId, scenario, context: ctx, with_brain: false }) }),
 
 
 
 
 
 
 
 
49
  ]);
50
+ setWithBrain(await r1.json()); setWithoutBrain(await r2.json());
51
+ } catch { alert("Query failed — is the backend running?"); } finally { setLoading(false); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  };
53
 
54
  return (
55
+ <DashboardLayout>
56
+ <div className="p-6 lg:p-8 max-w-6xl mx-auto animate-fade-in">
57
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
58
+ <div>
59
+ <h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>Brain Query Demo</h1>
60
+ <p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>Compare AI responses with and without your compiled brain</p>
61
+ </div>
62
+ <button onClick={() => router.push(`/skills/${companyId}`)} className="btn-secondary">View Skills</button>
63
  </div>
64
 
65
+ <div className="mb-6">
66
+ <p className="input-label mb-2">Quick Presets</p>
67
+ <div className="flex gap-2 flex-wrap">
68
+ {PRESETS.map((p) => (<button key={p.label} onClick={() => applyPreset(p)} className="badge" style={{ background: "var(--primary-ghost)", color: "var(--primary)", border: "1px solid rgba(0,210,180,0.2)", cursor: "pointer" }}>{p.label}</button>))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  </div>
70
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
+ <GlassCard className="mb-8">
73
+ <form onSubmit={handleQuery} className="space-y-4">
74
+ <div><label className="input-label">Scenario</label><textarea className="input-field" style={{ minHeight: "100px" }} placeholder="Describe the scenario..." value={scenario} onChange={(e) => setScenario(e.target.value)} /></div>
75
+ <div><label className="input-label">Context (JSON)</label><textarea className="input-field input-field--mono" style={{ minHeight: "80px" }} placeholder='{"plan": "enterprise"}' value={contextJson} onChange={(e) => setContextJson(e.target.value)} /></div>
76
+ <div className="flex justify-end"><button type="submit" disabled={loading || !scenario} className="btn-primary">{loading ? "Processing..." : "Compare Models"}</button></div>
77
+ </form>
78
+ </GlassCard>
79
+
80
+ {(withBrain || withoutBrain) && (
81
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 stagger-children">
82
+ <GlassCard className="opacity-70">
83
+ <div className="flex items-center gap-2 mb-5">
84
+ <span className="w-2.5 h-2.5 rounded-full" style={{ background: "var(--text-muted)" }} />
85
+ <h2 className="text-lg font-bold" style={{ color: "var(--text-secondary)" }}>Without Brain</h2>
86
+ <span className="badge badge--neutral ml-auto">Generic AI</span>
87
  </div>
88
+ {withoutBrain ? (<div className="space-y-4">
89
+ <div><p className="input-label">Response</p><div className="p-4 rounded text-sm" style={{ background: "var(--bg-input)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>{withoutBrain.recommended_action || "No action"}</div></div>
90
+ <div><p className="input-label">Rule</p><p className="text-sm" style={{ color: "var(--text-muted)" }}>{withoutBrain.rule_applied || "General knowledge"}</p></div>
91
+ {withoutBrain.reasoning && <div><p className="input-label">Reasoning</p><p className="text-sm" style={{ color: "var(--text-muted)" }}>{withoutBrain.reasoning}</p></div>}
92
+ </div>) : <div className="animate-shimmer h-32 rounded" />}
93
+ </GlassCard>
94
+
95
+ <div className="glass-card p-5 relative" style={{ borderColor: "rgba(0,210,180,0.3)", boxShadow: "0 0 32px -8px rgba(0,210,180,0.08)" }}>
96
+ <span className="absolute -top-3 right-4 text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full" style={{ background: "var(--primary)", color: "var(--text-inverse)" }}>Company Brain</span>
97
+ <div className="flex items-center gap-2 mb-5"><span className="w-2.5 h-2.5 rounded-full animate-pulse" style={{ background: "var(--primary)" }} /><h2 className="text-lg font-bold" style={{ color: "var(--primary)" }}>With Brain</h2></div>
98
+ {withBrain ? (<div className="space-y-4">
99
+ {withBrain.error ? <p style={{ color: "var(--error)" }}>{withBrain.error}</p> : (<>
100
+ <div><p className="input-label">Recommended Action</p><div className="p-4 rounded text-base font-medium" style={{ background: "var(--primary-ghost)", color: "var(--text-primary)", border: "1px solid rgba(0,210,180,0.15)" }}>{withBrain.recommended_action}</div></div>
101
+ <div className="grid grid-cols-2 gap-4">
102
+ <div><p className="input-label">Skill Matched</p><p className="font-mono text-sm" style={{ color: "var(--text-primary)" }}>{withBrain.skill_matched || "N/A"}</p></div>
103
+ <div><p className="input-label">Confidence</p><div className="flex items-center gap-2 mt-1"><div className="progress-bar flex-1"><div className="progress-bar__fill" style={{ width: `${(withBrain.confidence || 0) * 100}%`, background: "var(--primary)" }} /></div><ConfidenceBadge value={withBrain.confidence || 0} /></div></div>
104
+ </div>
105
+ {withBrain.retrieval_scores && withBrain.retrieval_scores.length > 0 && <div><p className="input-label">Retrieval Scores</p><div className="flex gap-2 flex-wrap">{withBrain.retrieval_scores.map((s, i) => <span key={i} className="badge badge--neutral font-mono">#{i+1}: {(s*100).toFixed(1)}%</span>)}</div></div>}
106
+ <div><p className="input-label">Rule Applied</p><p className="text-sm font-medium pl-3 py-1" style={{ color: "var(--text-primary)", borderLeft: "2px solid var(--primary)" }}>{withBrain.rule_applied}</p></div>
107
+ {withBrain.reasoning && <div><p className="input-label">Reasoning</p><div className="text-sm p-3 rounded" style={{ color: "var(--text-secondary)", background: "var(--bg-input)", border: "1px solid var(--border)" }}>{withBrain.reasoning}</div></div>}
108
+ {withBrain.evidence && withBrain.evidence.length > 0 && <div><p className="input-label">Evidence Trail</p><div className="space-y-2">{withBrain.evidence.map((src, i) => <div key={i} className="text-sm p-3 rounded" style={{ color: "var(--text-secondary)", background: "var(--bg-input)", border: "1px solid var(--border)" }}>{src}</div>)}</div></div>}
109
+ </>)}
110
+ </div>) : <div className="animate-shimmer h-32 rounded" />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  </div>
112
  </div>
113
  )}
114
  </div>
115
+ </DashboardLayout>
116
  );
117
  }
frontend/src/app/globals.css CHANGED
@@ -1,24 +1,517 @@
1
  @import "tailwindcss";
2
 
 
 
 
 
 
3
  :root {
4
- --background: #0A0F14;
5
- --foreground: #E2E8F0;
6
- --surface: #131B23;
 
 
 
 
 
7
  --primary: #00D2B4;
8
- --text-secondary: #94A3B8;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
 
11
  @theme inline {
12
- --color-background: var(--background);
13
- --color-foreground: var(--foreground);
14
- --color-surface: var(--surface);
 
15
  --color-primary: var(--primary);
 
 
 
16
  --color-text-secondary: var(--text-secondary);
17
- --font-sans: var(--font-geist-sans);
18
- --font-mono: var(--font-geist-mono);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
 
21
  body {
22
- background: var(--background);
23
- color: var(--foreground);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
 
1
  @import "tailwindcss";
2
 
3
+ /* ═══════════════════════════════════════════════
4
+ KERNL DESIGN SYSTEM — "Aerospace Grade"
5
+ Glassmorphism × Minimalism × Dark-first
6
+ ═══════════════════════════════════════════════ */
7
+
8
  :root {
9
+ /* ── Core Palette ── */
10
+ --bg-base: #0A0F14;
11
+ --bg-surface: #111820;
12
+ --bg-elevated: #19222D;
13
+ --bg-input: #0C1218;
14
+ --bg-hover: #1E2A36;
15
+
16
+ /* ── Brand Teal ── */
17
  --primary: #00D2B4;
18
+ --primary-hover: #00E8C8;
19
+ --primary-dim: #00A893;
20
+ --primary-ghost: rgba(0, 210, 180, 0.08);
21
+ --primary-glow: rgba(0, 210, 180, 0.15);
22
+
23
+ /* ── Text ── */
24
+ --text-primary: #E8EDF2;
25
+ --text-secondary: #8899A6;
26
+ --text-muted: #5C6F7E;
27
+ --text-inverse: #0A0F14;
28
+
29
+ /* ── Borders ── */
30
+ --border: rgba(255, 255, 255, 0.06);
31
+ --border-hover: rgba(255, 255, 255, 0.12);
32
+ --border-active: rgba(0, 210, 180, 0.3);
33
+
34
+ /* ── Status ── */
35
+ --success: #34D399;
36
+ --success-bg: rgba(52, 211, 153, 0.1);
37
+ --warning: #FBBF24;
38
+ --warning-bg: rgba(251, 191, 36, 0.1);
39
+ --error: #F87171;
40
+ --error-bg: rgba(248, 113, 113, 0.1);
41
+ --info: #60A5FA;
42
+ --info-bg: rgba(96, 165, 250, 0.1);
43
+
44
+ /* ── Spacing (8pt grid) ── */
45
+ --space-1: 4px;
46
+ --space-2: 8px;
47
+ --space-3: 12px;
48
+ --space-4: 16px;
49
+ --space-5: 20px;
50
+ --space-6: 24px;
51
+ --space-8: 32px;
52
+ --space-10: 40px;
53
+ --space-12: 48px;
54
+ --space-16: 64px;
55
+
56
+ /* ── Radii ── */
57
+ --radius-sm: 4px;
58
+ --radius: 8px;
59
+ --radius-md: 12px;
60
+ --radius-lg: 16px;
61
+ --radius-full: 9999px;
62
+
63
+ /* ── Sidebar ── */
64
+ --sidebar-width: 64px;
65
+
66
+ /* ── Typography ── */
67
+ --font-sans: 'Inter', var(--font-geist-sans), system-ui, sans-serif;
68
+ --font-mono: 'JetBrains Mono', var(--font-geist-mono), 'Fira Code', monospace;
69
  }
70
 
71
+ /* ── Tailwind Theme Bridge ── */
72
  @theme inline {
73
+ --color-background: var(--bg-base);
74
+ --color-foreground: var(--text-primary);
75
+ --color-surface: var(--bg-surface);
76
+ --color-elevated: var(--bg-elevated);
77
  --color-primary: var(--primary);
78
+ --color-primary-hover: var(--primary-hover);
79
+ --color-primary-dim: var(--primary-dim);
80
+ --color-primary-ghost: var(--primary-ghost);
81
  --color-text-secondary: var(--text-secondary);
82
+ --color-text-muted: var(--text-muted);
83
+ --color-border: var(--border);
84
+ --color-border-hover: var(--border-hover);
85
+ --color-success: var(--success);
86
+ --color-warning: var(--warning);
87
+ --color-error: var(--error);
88
+ --color-info: var(--info);
89
+ --font-sans: var(--font-sans);
90
+ --font-mono: var(--font-mono);
91
+ }
92
+
93
+ /* ═══════════════ Global Base ═══════════════ */
94
+
95
+ *,
96
+ *::before,
97
+ *::after {
98
+ box-sizing: border-box;
99
+ }
100
+
101
+ html {
102
+ scroll-behavior: smooth;
103
+ color-scheme: dark;
104
+ background-color: #0A0F14;
105
  }
106
 
107
  body {
108
+ background: #0A0F14 !important;
109
+ background-color: #0A0F14 !important;
110
+ color: var(--text-primary);
111
+ font-family: var(--font-sans);
112
+ -webkit-font-smoothing: antialiased;
113
+ -moz-osx-font-smoothing: grayscale;
114
+ }
115
+
116
+ /* ── Selection ── */
117
+ ::selection {
118
+ background: rgba(0, 210, 180, 0.25);
119
+ color: #fff;
120
+ }
121
+
122
+ /* ── Scrollbar ── */
123
+ ::-webkit-scrollbar {
124
+ width: 6px;
125
+ height: 6px;
126
+ }
127
+
128
+ ::-webkit-scrollbar-track {
129
+ background: transparent;
130
+ }
131
+
132
+ ::-webkit-scrollbar-thumb {
133
+ background: var(--border-hover);
134
+ border-radius: var(--radius-full);
135
+ }
136
+
137
+ ::-webkit-scrollbar-thumb:hover {
138
+ background: var(--text-muted);
139
+ }
140
+
141
+ /* ═══════════════ Animation Keyframes ═══════════════ */
142
+
143
+ @keyframes fade-in {
144
+ from { opacity: 0; transform: translateY(8px); }
145
+ to { opacity: 1; transform: translateY(0); }
146
+ }
147
+
148
+ @keyframes fade-up {
149
+ from { opacity: 0; transform: translateY(16px); }
150
+ to { opacity: 1; transform: translateY(0); }
151
+ }
152
+
153
+ @keyframes slide-in-right {
154
+ from { opacity: 0; transform: translateX(24px); }
155
+ to { opacity: 1; transform: translateX(0); }
156
+ }
157
+
158
+ @keyframes slide-in-left {
159
+ from { opacity: 0; transform: translateX(-24px); }
160
+ to { opacity: 1; transform: translateX(0); }
161
+ }
162
+
163
+ @keyframes pulse-glow {
164
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 210, 180, 0.2); }
165
+ 50% { box-shadow: 0 0 16px 4px rgba(0, 210, 180, 0.15); }
166
+ }
167
+
168
+ @keyframes shimmer {
169
+ 0% { background-position: -200% 0; }
170
+ 100% { background-position: 200% 0; }
171
+ }
172
+
173
+ @keyframes spin {
174
+ from { transform: rotate(0deg); }
175
+ to { transform: rotate(360deg); }
176
+ }
177
+
178
+ @keyframes flow-dots {
179
+ 0% { background-position: 0 0; }
180
+ 100% { background-position: 24px 0; }
181
+ }
182
+
183
+ /* ═══════════════ Utility Classes ═══════════════ */
184
+
185
+ .animate-fade-in {
186
+ animation: fade-in 0.4s ease-out both;
187
+ }
188
+
189
+ .animate-fade-up {
190
+ animation: fade-up 0.5s ease-out both;
191
+ }
192
+
193
+ .animate-slide-right {
194
+ animation: slide-in-right 0.4s ease-out both;
195
+ }
196
+
197
+ .animate-pulse-glow {
198
+ animation: pulse-glow 2s ease-in-out infinite;
199
+ }
200
+
201
+ .animate-shimmer {
202
+ background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.04) 50%, transparent 75%);
203
+ background-size: 200% 100%;
204
+ animation: shimmer 2s linear infinite;
205
+ }
206
+
207
+ .animate-spin-slow {
208
+ animation: spin 2s linear infinite;
209
+ }
210
+
211
+ /* ── Stagger children ── */
212
+ .stagger-children > * {
213
+ animation: fade-up 0.4s ease-out both;
214
+ }
215
+ .stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
216
+ .stagger-children > *:nth-child(2) { animation-delay: 0.1s; }
217
+ .stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
218
+ .stagger-children > *:nth-child(4) { animation-delay: 0.2s; }
219
+ .stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
220
+ .stagger-children > *:nth-child(6) { animation-delay: 0.3s; }
221
+
222
+ /* ═══════════════ Component Primitives ═══════════════ */
223
+
224
+ /* ── Glass Card ── */
225
+ .glass-card {
226
+ background: var(--bg-surface);
227
+ border: 1px solid var(--border);
228
+ border-radius: var(--radius);
229
+ backdrop-filter: blur(12px);
230
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
231
+ }
232
+
233
+ .glass-card:hover {
234
+ border-color: var(--border-hover);
235
+ }
236
+
237
+ .glass-card--interactive:hover {
238
+ border-color: var(--border-active);
239
+ box-shadow: 0 0 24px -8px rgba(0, 210, 180, 0.08);
240
+ cursor: pointer;
241
+ }
242
+
243
+ .glass-card--elevated {
244
+ background: var(--bg-elevated);
245
+ border-color: var(--border-hover);
246
+ }
247
+
248
+ /* ── Gradient Mesh Background ── */
249
+ .mesh-gradient {
250
+ position: fixed;
251
+ inset: 0;
252
+ z-index: -1;
253
+ background:
254
+ radial-gradient(ellipse 60% 40% at 20% 80%, rgba(0, 210, 180, 0.04) 0%, transparent 60%),
255
+ radial-gradient(ellipse 50% 50% at 80% 20%, rgba(96, 165, 250, 0.03) 0%, transparent 60%),
256
+ radial-gradient(ellipse 40% 60% at 50% 50%, rgba(139, 92, 246, 0.02) 0%, transparent 60%);
257
+ pointer-events: none;
258
+ }
259
+
260
+ /* ── Button Styles ── */
261
+ .btn-primary {
262
+ display: inline-flex;
263
+ align-items: center;
264
+ justify-content: center;
265
+ gap: 8px;
266
+ padding: 10px 20px;
267
+ background: var(--primary);
268
+ color: var(--text-inverse);
269
+ font-weight: 600;
270
+ font-size: 14px;
271
+ border-radius: var(--radius);
272
+ border: none;
273
+ cursor: pointer;
274
+ transition: all 0.2s ease;
275
+ white-space: nowrap;
276
+ }
277
+
278
+ .btn-primary:hover {
279
+ background: var(--primary-hover);
280
+ box-shadow: 0 0 20px -4px rgba(0, 210, 180, 0.3);
281
+ }
282
+
283
+ .btn-primary:disabled {
284
+ opacity: 0.5;
285
+ cursor: not-allowed;
286
+ box-shadow: none;
287
+ }
288
+
289
+ .btn-secondary {
290
+ display: inline-flex;
291
+ align-items: center;
292
+ justify-content: center;
293
+ gap: 8px;
294
+ padding: 10px 20px;
295
+ background: transparent;
296
+ color: var(--text-primary);
297
+ font-weight: 600;
298
+ font-size: 14px;
299
+ border-radius: var(--radius);
300
+ border: 1px solid var(--border-hover);
301
+ cursor: pointer;
302
+ transition: all 0.2s ease;
303
+ white-space: nowrap;
304
+ }
305
+
306
+ .btn-secondary:hover {
307
+ background: var(--bg-hover);
308
+ border-color: var(--text-muted);
309
+ }
310
+
311
+ .btn-ghost {
312
+ display: inline-flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ gap: 8px;
316
+ padding: 10px 20px;
317
+ background: transparent;
318
+ color: var(--primary);
319
+ font-weight: 600;
320
+ font-size: 14px;
321
+ border-radius: var(--radius);
322
+ border: none;
323
+ cursor: pointer;
324
+ transition: all 0.2s ease;
325
+ white-space: nowrap;
326
+ }
327
+
328
+ .btn-ghost:hover {
329
+ background: var(--primary-ghost);
330
+ }
331
+
332
+ /* ── Input Styles ── */
333
+ .input-field {
334
+ width: 100%;
335
+ padding: 10px 14px;
336
+ background: var(--bg-input);
337
+ border: 1px solid var(--border);
338
+ border-radius: var(--radius);
339
+ color: var(--text-primary);
340
+ font-size: 14px;
341
+ font-family: var(--font-sans);
342
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
343
+ outline: none;
344
+ }
345
+
346
+ .input-field::placeholder {
347
+ color: var(--text-muted);
348
+ }
349
+
350
+ .input-field:focus {
351
+ border-color: var(--primary);
352
+ box-shadow: 0 0 0 3px rgba(0, 210, 180, 0.1);
353
+ }
354
+
355
+ .input-field--mono {
356
+ font-family: var(--font-mono);
357
+ font-size: 13px;
358
+ }
359
+
360
+ /* ── Label ── */
361
+ .input-label {
362
+ display: block;
363
+ font-size: 12px;
364
+ font-weight: 600;
365
+ font-family: var(--font-mono);
366
+ letter-spacing: 0.05em;
367
+ text-transform: uppercase;
368
+ color: var(--text-secondary);
369
+ margin-bottom: 6px;
370
+ }
371
+
372
+ /* ── Badge/Chip ── */
373
+ .badge {
374
+ display: inline-flex;
375
+ align-items: center;
376
+ gap: 4px;
377
+ padding: 3px 10px;
378
+ font-size: 11px;
379
+ font-weight: 600;
380
+ font-family: var(--font-mono);
381
+ letter-spacing: 0.02em;
382
+ border-radius: var(--radius-sm);
383
+ white-space: nowrap;
384
+ }
385
+
386
+ .badge--primary {
387
+ background: var(--primary-ghost);
388
+ color: var(--primary);
389
+ border: 1px solid rgba(0, 210, 180, 0.2);
390
+ }
391
+
392
+ .badge--success {
393
+ background: var(--success-bg);
394
+ color: var(--success);
395
+ border: 1px solid rgba(52, 211, 153, 0.2);
396
+ }
397
+
398
+ .badge--warning {
399
+ background: var(--warning-bg);
400
+ color: var(--warning);
401
+ border: 1px solid rgba(251, 191, 36, 0.2);
402
+ }
403
+
404
+ .badge--error {
405
+ background: var(--error-bg);
406
+ color: var(--error);
407
+ border: 1px solid rgba(248, 113, 113, 0.2);
408
+ }
409
+
410
+ .badge--info {
411
+ background: var(--info-bg);
412
+ color: var(--info);
413
+ border: 1px solid rgba(96, 165, 250, 0.2);
414
+ }
415
+
416
+ .badge--neutral {
417
+ background: rgba(255, 255, 255, 0.04);
418
+ color: var(--text-secondary);
419
+ border: 1px solid var(--border);
420
+ }
421
+
422
+ /* ── Pipeline Node ── */
423
+ .pipeline-connector {
424
+ width: 2px;
425
+ height: 32px;
426
+ margin: 0 auto;
427
+ background: linear-gradient(to bottom, var(--primary-dim), var(--border));
428
+ position: relative;
429
+ }
430
+
431
+ .pipeline-connector--active {
432
+ background: var(--primary);
433
+ box-shadow: 0 0 8px rgba(0, 210, 180, 0.3);
434
+ }
435
+
436
+ .pipeline-connector--pending {
437
+ background: var(--border);
438
+ }
439
+
440
+ /* ── Terminal Log ── */
441
+ .terminal {
442
+ background: #05070A;
443
+ border: 1px solid var(--border);
444
+ border-radius: var(--radius);
445
+ font-family: var(--font-mono);
446
+ font-size: 12px;
447
+ line-height: 1.7;
448
+ overflow-y: auto;
449
+ padding: var(--space-4);
450
+ }
451
+
452
+ .terminal-line {
453
+ display: flex;
454
+ gap: 8px;
455
+ }
456
+
457
+ .terminal-time {
458
+ color: var(--text-muted);
459
+ flex-shrink: 0;
460
+ }
461
+
462
+ .terminal-event {
463
+ color: var(--primary);
464
+ }
465
+
466
+ .terminal-detail {
467
+ color: var(--text-secondary);
468
+ }
469
+
470
+ .terminal-error {
471
+ color: var(--error);
472
+ }
473
+
474
+ /* ── Progress Bar ── */
475
+ .progress-bar {
476
+ height: 6px;
477
+ background: rgba(255, 255, 255, 0.06);
478
+ border-radius: var(--radius-full);
479
+ overflow: hidden;
480
+ }
481
+
482
+ .progress-bar__fill {
483
+ height: 100%;
484
+ border-radius: var(--radius-full);
485
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
486
+ }
487
+
488
+ /* ── Empty State ── */
489
+ .empty-state {
490
+ text-align: center;
491
+ padding: var(--space-16) var(--space-8);
492
+ }
493
+
494
+ .empty-state__icon {
495
+ width: 56px;
496
+ height: 56px;
497
+ margin: 0 auto var(--space-6);
498
+ border-radius: var(--radius-md);
499
+ background: var(--primary-ghost);
500
+ display: flex;
501
+ align-items: center;
502
+ justify-content: center;
503
+ color: var(--primary);
504
+ font-size: 24px;
505
+ }
506
+
507
+ /* ═══════════════ Reduced Motion ═══════════════ */
508
+
509
+ @media (prefers-reduced-motion: reduce) {
510
+ *,
511
+ *::before,
512
+ *::after {
513
+ animation-duration: 0.01ms !important;
514
+ animation-iteration-count: 1 !important;
515
+ transition-duration: 0.01ms !important;
516
+ }
517
  }
frontend/src/app/layout.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import type { Metadata } from "next";
2
  import { Geist, Geist_Mono } from "next/font/google";
3
  import "./globals.css";
 
4
 
5
  const geistSans = Geist({
6
  variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ 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({
@@ -27,7 +28,11 @@ export default function RootLayout({
27
  lang="en"
28
  className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
  >
30
- <body className="min-h-full flex flex-col">{children}</body>
 
 
 
 
31
  </html>
32
  );
33
  }
 
1
  import type { Metadata } from "next";
2
  import { Geist, Geist_Mono } from "next/font/google";
3
  import "./globals.css";
4
+ import { AuthProvider } from "@/lib/auth";
5
 
6
  const geistSans = Geist({
7
  variable: "--font-geist-sans",
 
14
  });
15
 
16
  export const metadata: Metadata = {
17
+ title: "Kernl Company Brain Compiler",
18
+ description: "Compile operational knowledge into executable skills",
19
  };
20
 
21
  export default function RootLayout({
 
28
  lang="en"
29
  className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
30
  >
31
+ <body className="min-h-full" style={{ background: "var(--bg-base)" }}>
32
+ <AuthProvider>
33
+ {children}
34
+ </AuthProvider>
35
+ </body>
36
  </html>
37
  );
38
  }
frontend/src/app/login/page.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useAuth } from "@/lib/auth";
6
+
7
+ export default function LoginPage() {
8
+ const [email, setEmail] = useState("");
9
+ const [password, setPassword] = useState("");
10
+ const [error, setError] = useState("");
11
+ const [loading, setLoading] = useState(false);
12
+ const { login } = useAuth();
13
+ const router = useRouter();
14
+
15
+ const handleSubmit = async (e: React.FormEvent) => {
16
+ e.preventDefault();
17
+ setError("");
18
+ setLoading(true);
19
+ try {
20
+ await login(email, password);
21
+ router.push("/");
22
+ } catch (err: unknown) {
23
+ setError(err instanceof Error ? err.message : "Login failed");
24
+ } finally {
25
+ setLoading(false);
26
+ }
27
+ };
28
+
29
+ return (
30
+ <div className="min-h-screen flex items-center justify-center p-6 relative">
31
+ {/* Background mesh */}
32
+ <div className="mesh-gradient" />
33
+
34
+ {/* Auth Card */}
35
+ <div
36
+ className="w-full max-w-sm animate-fade-up"
37
+ style={{ animationDelay: "0.1s" }}
38
+ >
39
+ {/* Logo */}
40
+ <div className="flex items-center justify-center mb-8">
41
+ <div
42
+ className="w-12 h-12 rounded-xl flex items-center justify-center animate-pulse-glow"
43
+ style={{
44
+ background: "var(--primary-ghost)",
45
+ color: "var(--primary)",
46
+ }}
47
+ >
48
+ <svg width="24" height="24" viewBox="0 0 18 18" fill="none">
49
+ <path
50
+ d="M3 2h3v14H3V2zm5 0h2l5 7-5 7H8l5-7-5-7z"
51
+ fill="currentColor"
52
+ />
53
+ </svg>
54
+ </div>
55
+ </div>
56
+
57
+ <div className="glass-card p-8">
58
+ <h1
59
+ className="text-xl font-bold mb-1 text-center"
60
+ style={{ color: "var(--text-primary)" }}
61
+ >
62
+ Welcome back
63
+ </h1>
64
+ <p
65
+ className="text-sm text-center mb-6"
66
+ style={{ color: "var(--text-muted)" }}
67
+ >
68
+ Sign in to your Kernl workspace
69
+ </p>
70
+
71
+ <form onSubmit={handleSubmit} className="space-y-4">
72
+ <div>
73
+ <label className="input-label">Email</label>
74
+ <input
75
+ type="email"
76
+ className="input-field"
77
+ placeholder="you@company.com"
78
+ value={email}
79
+ onChange={(e) => setEmail(e.target.value)}
80
+ required
81
+ />
82
+ </div>
83
+ <div>
84
+ <label className="input-label">Password</label>
85
+ <input
86
+ type="password"
87
+ className="input-field"
88
+ placeholder="••••••••"
89
+ value={password}
90
+ onChange={(e) => setPassword(e.target.value)}
91
+ required
92
+ />
93
+ </div>
94
+
95
+ {error && (
96
+ <div
97
+ className="text-sm px-3 py-2 rounded"
98
+ style={{
99
+ color: "var(--error)",
100
+ background: "var(--error-bg)",
101
+ border: "1px solid rgba(248, 113, 113, 0.2)",
102
+ }}
103
+ >
104
+ {error}
105
+ </div>
106
+ )}
107
+
108
+ <button
109
+ type="submit"
110
+ disabled={loading}
111
+ className="btn-primary w-full"
112
+ style={{ padding: "12px 20px" }}
113
+ >
114
+ {loading ? (
115
+ <span className="animate-spin-slow inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
116
+ ) : (
117
+ "Sign In"
118
+ )}
119
+ </button>
120
+ </form>
121
+
122
+ <p
123
+ className="text-sm mt-6 text-center"
124
+ style={{ color: "var(--text-muted)" }}
125
+ >
126
+ No account?{" "}
127
+ <a
128
+ href="/register"
129
+ className="font-medium transition-colors"
130
+ style={{ color: "var(--primary)" }}
131
+ >
132
+ Create one
133
+ </a>
134
+ </p>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ );
139
+ }
frontend/src/app/onboarding/page.tsx ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { API_BASE } from "@/lib/api";
6
+ import DashboardLayout from "@/components/DashboardLayout";
7
+ import GlassCard from "@/components/ui/GlassCard";
8
+
9
+ type AnalysisResult = { suggested_industry: string; suggested_departments: string[]; suggested_size: string; rationale: string };
10
+
11
+ const STEP_LABELS = ["Company", "Upload", "Configure", "Compile"];
12
+
13
+ export default function OnboardingWizard() {
14
+ const [step, setStep] = useState(1);
15
+ const [companyName, setCompanyName] = useState("");
16
+ const [companyId, setCompanyId] = useState("");
17
+ const [files, setFiles] = useState<FileList | null>(null);
18
+ const [uploading, setUploading] = useState(false);
19
+ const [analyzing, setAnalyzing] = useState(false);
20
+ const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
21
+ const [compiling, setCompiling] = useState(false);
22
+ const [error, setError] = useState("");
23
+ const [skippedUpload, setSkippedUpload] = useState(false);
24
+ const [showSkipOptions, setShowSkipOptions] = useState(false);
25
+ const [loadingSamples, setLoadingSamples] = useState(false);
26
+ const [sourceCount, setSourceCount] = useState(0);
27
+ const [manualIndustry, setManualIndustry] = useState("");
28
+ const [manualSize, setManualSize] = useState("");
29
+ const [manualDescription, setManualDescription] = useState("");
30
+ const router = useRouter();
31
+
32
+ const generateId = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
33
+
34
+ const handleBack = () => { setError(""); if (step > 1) { setStep(step - 1); if (step === 3) setShowSkipOptions(false); } else router.push("/"); };
35
+
36
+ const handleNameSubmit = async () => {
37
+ if (!companyName.trim()) return;
38
+ const id = generateId(companyName);
39
+ setCompanyId(id); setError("");
40
+ try { await fetch(`${API_BASE}/companies/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: companyName }) }); } catch { /* ok */ }
41
+ // Persist so dashboard auto-loads this company
42
+ sessionStorage.setItem("kernl_company_id", id);
43
+ setStep(2);
44
+ };
45
+
46
+ const handleUpload = async () => {
47
+ if (!files || files.length === 0) return;
48
+ setUploading(true); setError("");
49
+ try {
50
+ for (const file of Array.from(files)) { const form = new FormData(); form.append("company_id", companyId); form.append("file", file); const res = await fetch(`${API_BASE}/sources/upload`, { method: "POST", body: form }); if (!res.ok) throw new Error(`Failed: ${file.name}`); }
51
+ setSourceCount(files.length); setSkippedUpload(false); setStep(3);
52
+ } catch (err: unknown) { setError(err instanceof Error ? err.message : "Upload failed"); } finally { setUploading(false); }
53
+ };
54
+
55
+ const handleLoadSamples = async () => {
56
+ setLoadingSamples(true); setError("");
57
+ try { const res = await fetch(`${API_BASE}/companies/${companyId}/load-samples`, { method: "POST" }); if (!res.ok) throw new Error("Failed to load sample data"); const data = await res.json(); setSourceCount(data.count || 0); setSkippedUpload(false); setShowSkipOptions(false); setStep(3); }
58
+ catch (err: unknown) { setError(err instanceof Error ? err.message : "Failed to load samples"); } finally { setLoadingSamples(false); }
59
+ };
60
+
61
+ const handleSkipToManual = () => { setSkippedUpload(true); setShowSkipOptions(false); setSourceCount(0); setStep(3); };
62
+
63
+ const handleAnalyze = async () => {
64
+ setAnalyzing(true); setError("");
65
+ try { const res = await fetch(`${API_BASE}/onboarding/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId }) }); if (!res.ok) throw new Error("Analysis failed"); setAnalysis(await res.json()); }
66
+ catch (err: unknown) { setError(err instanceof Error ? err.message : "Analysis failed"); } finally { setAnalyzing(false); }
67
+ };
68
+
69
+ const handleSaveProfile = async () => {
70
+ setError(""); const payload: Record<string, string> = {};
71
+ if (analysis) { if (analysis.suggested_industry) payload.industry = analysis.suggested_industry; if (analysis.suggested_size) payload.company_size = analysis.suggested_size; if (analysis.rationale) payload.description = analysis.rationale; }
72
+ else { if (manualIndustry) payload.industry = manualIndustry; if (manualSize) payload.company_size = manualSize; if (manualDescription) payload.description = manualDescription; }
73
+ if (Object.keys(payload).length > 0) { try { await fetch(`${API_BASE}/companies/${companyId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); } catch (err) { console.warn("Failed to save:", err); } }
74
+ setStep(4);
75
+ };
76
+
77
+ const handleCompile = async () => {
78
+ setCompiling(true); setError("");
79
+ try { const res = await fetch(`${API_BASE}/compile`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: companyId }) }); const data = await res.json(); if (data.job_id) router.push(`/compile/${data.job_id}`); }
80
+ catch { setError("Failed to start compilation"); setCompiling(false); }
81
+ };
82
+
83
+ return (
84
+ <DashboardLayout>
85
+ <div className="p-6 lg:p-8 max-w-2xl mx-auto animate-fade-in">
86
+ {/* Header */}
87
+ <div className="flex items-center gap-3 mb-6">
88
+ <button onClick={handleBack} className="btn-ghost" style={{ padding: "6px 12px" }}>
89
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 2L4 7l5 5" strokeLinecap="round" strokeLinejoin="round" /></svg>
90
+ {step > 1 ? "Back" : "Home"}
91
+ </button>
92
+ <h1 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>Onboarding</h1>
93
+ </div>
94
+
95
+ {/* Progress Steps */}
96
+ <div className="flex mb-8 gap-1">
97
+ {STEP_LABELS.map((label, i) => (
98
+ <div key={label} className="flex-1">
99
+ <div className="flex items-center gap-2 mb-1.5">
100
+ <span className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold" style={{
101
+ background: step > i + 1 || step === i + 1 ? "var(--primary)" : "rgba(255,255,255,0.05)",
102
+ color: step > i + 1 || step === i + 1 ? "var(--text-inverse)" : "var(--text-muted)",
103
+ }}>{step > i + 1 ? "✓" : i + 1}</span>
104
+ <span className="text-xs font-medium" style={{ color: step === i + 1 ? "var(--primary)" : "var(--text-muted)" }}>{label}</span>
105
+ </div>
106
+ <div className="h-0.5 rounded-full" style={{ background: step > i + 1 ? "var(--primary)" : step === i + 1 ? "var(--primary-dim)" : "var(--border)" }} />
107
+ </div>
108
+ ))}
109
+ </div>
110
+
111
+ {/* Error */}
112
+ {error && (
113
+ <div className="flex items-center justify-between mb-6 p-3 rounded text-sm" style={{ background: "var(--error-bg)", color: "var(--error)", border: "1px solid rgba(248,113,113,0.2)" }}>
114
+ <span>{error}</span>
115
+ <button onClick={() => setError("")} style={{ color: "var(--error)" }}>✕</button>
116
+ </div>
117
+ )}
118
+
119
+ {/* Step 1 */}
120
+ {step === 1 && (
121
+ <GlassCard elevated padding="lg">
122
+ <h2 className="text-lg font-bold mb-4" style={{ color: "var(--text-primary)" }}>Name your company</h2>
123
+ <input type="text" className="input-field mb-3" placeholder="e.g. Rivanly Inc." value={companyName} onChange={(e) => setCompanyName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && companyName && handleNameSubmit()} />
124
+ {companyName && <p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>ID: <span className="font-mono" style={{ color: "var(--primary)" }}>{generateId(companyName)}</span></p>}
125
+ <button onClick={handleNameSubmit} disabled={!companyName.trim()} className="btn-primary">Next →</button>
126
+ </GlassCard>
127
+ )}
128
+
129
+ {/* Step 2 */}
130
+ {step === 2 && (
131
+ <GlassCard elevated padding="lg">
132
+ <h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>Upload source documents</h2>
133
+ <p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>SOPs, Slack exports, Zendesk tickets — anything with operational knowledge.</p>
134
+ <input type="file" multiple onChange={(e) => setFiles(e.target.files)} className="w-full text-sm mb-4 file:mr-4 file:py-2 file:px-4 file:border-0 file:rounded file:font-medium" style={{ color: "var(--text-secondary)" }} />
135
+ <div className="flex gap-3 mb-4">
136
+ <button onClick={handleUpload} disabled={!files || uploading} className="btn-primary">{uploading ? "Uploading..." : "Upload & Continue"}</button>
137
+ <button onClick={() => setShowSkipOptions(true)} className="btn-secondary">Skip</button>
138
+ </div>
139
+ {showSkipOptions && (
140
+ <div className="space-y-3 p-4 rounded" style={{ background: "var(--bg-input)", border: "1px solid var(--border)" }}>
141
+ <p className="text-sm" style={{ color: "var(--text-muted)" }}>No files? Choose how to proceed:</p>
142
+ <button onClick={handleLoadSamples} disabled={loadingSamples} className="w-full text-left p-3 rounded transition-colors" style={{ background: "var(--primary-ghost)", border: "1px solid rgba(0,210,180,0.2)", color: "var(--primary)" }}>
143
+ <span className="font-semibold">{loadingSamples ? "Loading..." : "Load Sample Playbooks"}</span>
144
+ <span className="block text-xs mt-1" style={{ color: "var(--text-muted)" }}>Pre-configured demo data</span>
145
+ </button>
146
+ <button onClick={handleSkipToManual} className="w-full text-left p-3 rounded transition-colors" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
147
+ <span className="font-semibold">Configure Manually</span>
148
+ <span className="block text-xs mt-1" style={{ color: "var(--text-muted)" }}>Set up by hand</span>
149
+ </button>
150
+ <button onClick={() => setShowSkipOptions(false)} className="text-sm" style={{ color: "var(--text-muted)" }}>Cancel</button>
151
+ </div>
152
+ )}
153
+ </GlassCard>
154
+ )}
155
+
156
+ {/* Step 3 */}
157
+ {step === 3 && (
158
+ <div className="space-y-6">
159
+ {!skippedUpload && (
160
+ <GlassCard elevated padding="lg">
161
+ <h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>AI Analysis</h2>
162
+ <p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>Analyze your {sourceCount} document{sourceCount !== 1 ? "s" : ""} to suggest profile settings.</p>
163
+ {!analysis ? (
164
+ <div className="flex gap-3">
165
+ <button onClick={handleAnalyze} disabled={analyzing} className="btn-primary">{analyzing ? "Analyzing..." : "Analyze Documents"}</button>
166
+ <button onClick={handleSaveProfile} className="btn-secondary">Skip to Compile →</button>
167
+ </div>
168
+ ) : (
169
+ <div className="space-y-4">
170
+ <div className="grid grid-cols-3 gap-3">
171
+ {[{ label: "Industry", val: analysis.suggested_industry }, { label: "Size", val: analysis.suggested_size }, { label: "Depts", val: String(analysis.suggested_departments.length) }].map(({ label, val }) => (
172
+ <div key={label} className="p-3 rounded" style={{ background: "var(--bg-input)", border: "1px solid var(--border)" }}>
173
+ <p className="text-[10px] uppercase tracking-wider font-mono" style={{ color: "var(--text-muted)" }}>{label}</p>
174
+ <p className="font-bold" style={{ color: "var(--text-primary)" }}>{val}</p>
175
+ </div>
176
+ ))}
177
+ </div>
178
+ {analysis.suggested_departments.length > 0 && <div className="flex flex-wrap gap-2">{analysis.suggested_departments.map((d) => <span key={d} className="badge badge--primary">{d}</span>)}</div>}
179
+ {analysis.rationale && <p className="text-sm p-3 rounded" style={{ color: "var(--text-muted)", background: "var(--bg-input)", border: "1px solid var(--border)" }}>{analysis.rationale}</p>}
180
+ <button onClick={handleSaveProfile} className="btn-primary">Save & Continue</button>
181
+ </div>
182
+ )}
183
+ </GlassCard>
184
+ )}
185
+ {skippedUpload && (
186
+ <GlassCard elevated padding="lg">
187
+ <h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>Company Profile</h2>
188
+ <p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>Set up manually. You can update later.</p>
189
+ <div className="space-y-4">
190
+ <div><label className="input-label">Industry</label><select value={manualIndustry} onChange={(e) => setManualIndustry(e.target.value)} className="input-field"><option value="">Select industry...</option>{["SaaS", "E-commerce", "FinTech", "HealthTech", "EdTech", "Consulting", "Manufacturing", "Other"].map(v => <option key={v} value={v}>{v}</option>)}</select></div>
191
+ <div><label className="input-label">Size</label><select value={manualSize} onChange={(e) => setManualSize(e.target.value)} className="input-field"><option value="">Select size...</option>{["1-10", "11-50", "51-200", "201+"].map(v => <option key={v} value={v}>{v} employees</option>)}</select></div>
192
+ <div><label className="input-label">Description</label><textarea value={manualDescription} onChange={(e) => setManualDescription(e.target.value)} placeholder="Brief description..." className="input-field" style={{ minHeight: "80px" }} /></div>
193
+ </div>
194
+ <div className="flex gap-3 mt-6">
195
+ <button onClick={handleSaveProfile} className="btn-primary">Save & Continue →</button>
196
+ <button onClick={() => setStep(4)} className="btn-secondary">Skip to Compile</button>
197
+ </div>
198
+ </GlassCard>
199
+ )}
200
+ </div>
201
+ )}
202
+
203
+ {/* Step 4 */}
204
+ {step === 4 && (
205
+ <GlassCard elevated padding="lg" className="text-center">
206
+ <div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ background: "var(--primary-ghost)", color: "var(--primary)" }}>
207
+ <svg width="28" height="28" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="10" r="7" /><path d="M10 6v4l3 2" strokeLinecap="round" /></svg>
208
+ </div>
209
+ <h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>Ready to compile</h2>
210
+ <p className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
211
+ Company <span className="font-mono" style={{ color: "var(--primary)" }}>{companyId}</span>
212
+ {sourceCount > 0 && <> has <span className="font-bold" style={{ color: "var(--primary)" }}>{sourceCount}</span> source document{sourceCount !== 1 ? "s" : ""}</>}
213
+ . Compile your brain now.
214
+ </p>
215
+ <div className="flex gap-3 justify-center">
216
+ <button onClick={handleCompile} disabled={compiling} className="btn-primary">{compiling ? "Starting..." : "Compile Brain"}</button>
217
+ <button onClick={() => router.push(`/skills/${companyId}`)} className="btn-secondary">View Skills</button>
218
+ </div>
219
+ </GlassCard>
220
+ )}
221
+ </div>
222
+ </DashboardLayout>
223
+ );
224
+ }
frontend/src/app/page.tsx CHANGED
@@ -1,90 +1,342 @@
1
  "use client";
2
 
3
- import { useState } from "react";
4
  import { useRouter } from "next/navigation";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  export default function Dashboard() {
7
  const [companyId, setCompanyId] = useState("");
8
  const [loading, setLoading] = useState(false);
 
 
 
 
9
  const router = useRouter();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  const handleCompile = async () => {
12
  if (!companyId) return;
13
  setLoading(true);
14
  try {
15
- const res = await fetch("http://localhost:8080/compile", {
16
  method: "POST",
17
  headers: { "Content-Type": "application/json" },
18
  body: JSON.stringify({ company_id: companyId }),
19
  });
20
  const data = await res.json();
21
- if (data.job_id) {
22
- router.push(`/compile/${data.job_id}`);
23
- }
24
- } catch (err) {
25
- console.error(err);
26
  alert("Failed to start compilation");
27
  } finally {
28
  setLoading(false);
29
  }
30
  };
31
 
32
- const handleQuery = () => {
33
- if (companyId) {
34
- router.push(`/demo/${companyId}`);
35
- }
36
- };
37
-
38
- const handleViewSkills = () => {
39
- if (companyId) {
40
- router.push(`/skills/${companyId}`);
41
- }
42
- };
43
 
44
  return (
45
- <div className="min-h-screen p-8 flex flex-col items-center justify-center">
46
- <div className="max-w-md w-full bg-surface p-8 border border-gray-800 shadow-2xl">
47
- <h1 className="text-3xl font-bold text-primary mb-6">Kernl Compilation</h1>
48
-
49
- <div className="mb-6">
50
- <label className="block text-text-secondary text-sm font-bold mb-2">
51
- Company ID
52
- </label>
53
- <input
54
- type="text"
55
- className="w-full px-3 py-2 bg-background border border-gray-700 text-foreground focus:outline-none focus:border-primary"
56
- placeholder="e.g. comp_123"
57
- value={companyId}
58
- onChange={(e) => setCompanyId(e.target.value)}
59
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
 
62
- <div className="flex flex-col gap-3">
63
- <button
64
- onClick={handleCompile}
65
- disabled={loading || !companyId}
66
- className="w-full bg-primary text-background font-bold py-2 px-4 hover:opacity-90 disabled:opacity-50"
67
- >
68
- {loading ? "Starting..." : "Compile Brain"}
69
- </button>
70
-
71
- <button
72
- onClick={handleViewSkills}
73
- disabled={!companyId}
74
- className="w-full border border-primary text-primary font-bold py-2 px-4 hover:bg-primary/10 disabled:opacity-50"
75
- >
76
- View Skills File
77
- </button>
78
-
79
- <button
80
- onClick={handleQuery}
81
- disabled={!companyId}
82
- className="w-full border border-gray-600 text-foreground font-bold py-2 px-4 hover:bg-gray-800 disabled:opacity-50"
83
- >
84
- Query Agent Demo
85
- </button>
86
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </div>
88
- </div>
89
  );
90
  }
 
1
  "use client";
2
 
3
+ import { useState, useEffect, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
5
+ import { API_BASE } from "@/lib/api";
6
+ import { useAuth } from "@/lib/auth";
7
+ import DashboardLayout from "@/components/DashboardLayout";
8
+ import StatCard from "@/components/ui/StatCard";
9
+ import GlassCard from "@/components/ui/GlassCard";
10
+
11
+ type CompanyInfo = {
12
+ id: string;
13
+ name?: string;
14
+ industry?: string;
15
+ company_size?: string;
16
+ skill_count?: number;
17
+ source_count?: number;
18
+ last_compile?: { completed_at: string; result_version: string } | null;
19
+ };
20
 
21
  export default function Dashboard() {
22
  const [companyId, setCompanyId] = useState("");
23
  const [loading, setLoading] = useState(false);
24
+ const [company, setCompany] = useState<CompanyInfo | null>(null);
25
+ const [fetching, setFetching] = useState(false);
26
+ const [error, setError] = useState("");
27
+ const [autoLoaded, setAutoLoaded] = useState(false);
28
  const router = useRouter();
29
+ const { user } = useAuth();
30
+
31
+ const fetchCompany = useCallback(async (id: string) => {
32
+ if (!id) return;
33
+ setFetching(true);
34
+ setError("");
35
+ try {
36
+ const res = await fetch(`${API_BASE}/companies/${id}`);
37
+ if (!res.ok) throw new Error("Not found");
38
+ const data = await res.json();
39
+ setCompany(data);
40
+ // Persist the company ID so it auto-loads next time
41
+ sessionStorage.setItem("kernl_company_id", id);
42
+ } catch {
43
+ setCompany(null);
44
+ setError("Company not found. Create it first via Onboarding.");
45
+ } finally {
46
+ setFetching(false);
47
+ }
48
+ }, []);
49
+
50
+ // Auto-load saved company when user is signed in
51
+ useEffect(() => {
52
+ if (autoLoaded) return;
53
+ const savedCompanyId = sessionStorage.getItem("kernl_company_id");
54
+ if (savedCompanyId) {
55
+ setCompanyId(savedCompanyId);
56
+ fetchCompany(savedCompanyId);
57
+ setAutoLoaded(true);
58
+ }
59
+ }, [autoLoaded, fetchCompany]);
60
 
61
  const handleCompile = async () => {
62
  if (!companyId) return;
63
  setLoading(true);
64
  try {
65
+ const res = await fetch(`${API_BASE}/compile`, {
66
  method: "POST",
67
  headers: { "Content-Type": "application/json" },
68
  body: JSON.stringify({ company_id: companyId }),
69
  });
70
  const data = await res.json();
71
+ if (data.job_id) router.push(`/compile/${data.job_id}`);
72
+ } catch {
 
 
 
73
  alert("Failed to start compilation");
74
  } finally {
75
  setLoading(false);
76
  }
77
  };
78
 
79
+ // Determine if we should show the lookup section
80
+ // Hide it when we have a loaded company (auto or manual)
81
+ const showLookup = !company;
 
 
 
 
 
 
 
 
82
 
83
  return (
84
+ <DashboardLayout>
85
+ <div className="p-6 lg:p-8 max-w-6xl mx-auto animate-fade-in">
86
+ {/* Page Header */}
87
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
88
+ <div>
89
+ <h1
90
+ className="text-2xl font-bold tracking-tight"
91
+ style={{ color: "var(--text-primary)" }}
92
+ >
93
+ {company ? company.name || companyId : "Command Center"}
94
+ </h1>
95
+ <p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
96
+ {company
97
+ ? "Operational brain dashboard"
98
+ : "Compile operational knowledge into an executable AI brain"}
99
+ </p>
100
+ </div>
101
+ <div className="flex gap-3">
102
+ {company && (
103
+ <button
104
+ onClick={() => {
105
+ setCompany(null);
106
+ setCompanyId("");
107
+ setError("");
108
+ sessionStorage.removeItem("kernl_company_id");
109
+ }}
110
+ className="btn-ghost"
111
+ style={{ fontSize: "13px", padding: "8px 14px" }}
112
+ >
113
+ Switch Company
114
+ </button>
115
+ )}
116
+ <button
117
+ onClick={() => router.push("/onboarding")}
118
+ className="btn-secondary"
119
+ >
120
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
121
+ <path d="M7 1v12M1 7h12" strokeLinecap="round" />
122
+ </svg>
123
+ New Company
124
+ </button>
125
+ </div>
126
  </div>
127
 
128
+ {/* Company Lookup — only visible when no company is loaded */}
129
+ {showLookup && (
130
+ <GlassCard className="mb-8">
131
+ <label className="input-label">Company ID</label>
132
+ <div className="flex gap-3">
133
+ <input
134
+ type="text"
135
+ className="input-field input-field--mono flex-1"
136
+ placeholder="e.g. rivanly-inc"
137
+ value={companyId}
138
+ onChange={(e) => setCompanyId(e.target.value)}
139
+ onKeyDown={(e) =>
140
+ e.key === "Enter" && companyId && fetchCompany(companyId)
141
+ }
142
+ />
143
+ <button
144
+ onClick={() => fetchCompany(companyId)}
145
+ disabled={!companyId || fetching}
146
+ className="btn-primary"
147
+ >
148
+ {fetching ? (
149
+ <span className="animate-spin-slow inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
150
+ ) : (
151
+ "Load"
152
+ )}
153
+ </button>
154
+ </div>
155
+
156
+ {error && (
157
+ <p className="text-xs mt-2" style={{ color: "var(--error)" }}>
158
+ {error}
159
+ </p>
160
+ )}
161
+
162
+ {!company && !error && (
163
+ <p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}>
164
+ Try:{" "}
165
+ <button
166
+ onClick={() => {
167
+ setCompanyId("rivanly-inc");
168
+ fetchCompany("rivanly-inc");
169
+ }}
170
+ className="font-mono transition-colors"
171
+ style={{ color: "var(--primary)" }}
172
+ >
173
+ rivanly-inc
174
+ </button>{" "}
175
+ (pre-loaded demo company)
176
+ </p>
177
+ )}
178
+ </GlassCard>
179
+ )}
180
+
181
+ {/* Company Dashboard */}
182
+ {company && (
183
+ <div className="stagger-children">
184
+ {/* Stat Cards */}
185
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
186
+ <StatCard
187
+ label="Skills"
188
+ value={company.skill_count ?? 0}
189
+ accent="primary"
190
+ icon={
191
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
192
+ <path d="M2 4h12M2 8h8M2 12h10" strokeLinecap="round" />
193
+ </svg>
194
+ }
195
+ />
196
+ <StatCard
197
+ label="Sources"
198
+ value={company.source_count ?? 0}
199
+ accent="info"
200
+ icon={
201
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
202
+ <rect x="3" y="2" width="10" height="12" rx="1.5" />
203
+ <path d="M6 5h4M6 8h4M6 11h2" strokeLinecap="round" />
204
+ </svg>
205
+ }
206
+ />
207
+ <StatCard
208
+ label="Company Size"
209
+ value={company.company_size || "—"}
210
+ accent="warning"
211
+ icon={
212
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
213
+ <circle cx="8" cy="5" r="3" />
214
+ <path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" strokeLinecap="round" />
215
+ </svg>
216
+ }
217
+ />
218
+ <StatCard
219
+ label="Industry"
220
+ value={company.industry || "—"}
221
+ accent="success"
222
+ icon={
223
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
224
+ <rect x="2" y="6" width="4" height="8" rx="0.5" />
225
+ <rect x="6" y="3" width="4" height="11" rx="0.5" />
226
+ <rect x="10" y="1" width="4" height="13" rx="0.5" />
227
+ </svg>
228
+ }
229
+ />
230
+ </div>
231
+
232
+ {/* Quick Actions */}
233
+ <GlassCard className="mb-6" elevated>
234
+ <p
235
+ className="text-[10px] font-semibold uppercase tracking-[0.08em] font-mono mb-4"
236
+ style={{ color: "var(--text-muted)" }}
237
+ >
238
+ Quick Actions
239
+ </p>
240
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
241
+ <button onClick={handleCompile} disabled={loading} className="btn-primary w-full">
242
+ {loading ? "Starting..." : "Compile Brain"}
243
+ </button>
244
+ <button
245
+ onClick={() => router.push(`/skills/${companyId}`)}
246
+ className="btn-secondary w-full"
247
+ >
248
+ View Skills
249
+ </button>
250
+ <button
251
+ onClick={() => router.push(`/demo/${companyId}`)}
252
+ className="btn-secondary w-full"
253
+ >
254
+ Query Agent
255
+ </button>
256
+ <button
257
+ onClick={() => {
258
+ const a = document.createElement("a");
259
+ a.href = `${API_BASE}/skills/${companyId}/download`;
260
+ a.click();
261
+ }}
262
+ className="btn-ghost w-full"
263
+ >
264
+ Download JSON
265
+ </button>
266
+ </div>
267
+ </GlassCard>
268
+
269
+ {/* Last Compilation */}
270
+ {company.last_compile && (
271
+ <GlassCard>
272
+ <div className="flex items-center gap-3">
273
+ <span
274
+ className="w-8 h-8 rounded-lg flex items-center justify-center"
275
+ style={{
276
+ background: "var(--success-bg)",
277
+ color: "var(--success)",
278
+ }}
279
+ >
280
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
281
+ <path d="M3 8l4 4 6-8" strokeLinecap="round" strokeLinejoin="round" />
282
+ </svg>
283
+ </span>
284
+ <div>
285
+ <p
286
+ className="text-[10px] font-semibold uppercase tracking-[0.08em] font-mono"
287
+ style={{ color: "var(--text-muted)" }}
288
+ >
289
+ Last Compilation
290
+ </p>
291
+ <p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
292
+ {new Date(company.last_compile.completed_at).toLocaleString()}
293
+ {company.last_compile.result_version && (
294
+ <span
295
+ className="ml-2 font-mono text-xs"
296
+ style={{ color: "var(--text-muted)" }}
297
+ >
298
+ {company.last_compile.result_version}
299
+ </span>
300
+ )}
301
+ </p>
302
+ </div>
303
+ </div>
304
+ </GlassCard>
305
+ )}
306
+ </div>
307
+ )}
308
+
309
+ {/* Empty State — only when no company ID has been entered and nothing loaded */}
310
+ {!company && !error && !companyId && (
311
+ <div className="empty-state animate-fade-up">
312
+ <div className="empty-state__icon">
313
+ <svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="1.5">
314
+ <circle cx="14" cy="14" r="10" />
315
+ <path d="M14 9v5l3 3" strokeLinecap="round" strokeLinejoin="round" />
316
+ </svg>
317
+ </div>
318
+ <h2 className="text-xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
319
+ Your company&apos;s operational brain
320
+ </h2>
321
+ <p
322
+ className="text-sm mb-8 max-w-md mx-auto leading-relaxed"
323
+ style={{ color: "var(--text-secondary)" }}
324
+ >
325
+ Upload SOPs, Slack exports, and support tickets. Kernl compiles them into structured,
326
+ queryable skills that make your AI agent actually understand your business.
327
+ </p>
328
+ <button
329
+ onClick={() => router.push("/onboarding")}
330
+ className="btn-primary"
331
+ >
332
+ Get Started
333
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
334
+ <path d="M1 7h12M8 2l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
335
+ </svg>
336
+ </button>
337
+ </div>
338
+ )}
339
  </div>
340
+ </DashboardLayout>
341
  );
342
  }
frontend/src/app/register/page.tsx ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useAuth } from "@/lib/auth";
6
+
7
+ export default function RegisterPage() {
8
+ const [email, setEmail] = useState("");
9
+ const [password, setPassword] = useState("");
10
+ const [error, setError] = useState("");
11
+ const [loading, setLoading] = useState(false);
12
+ const { register, login } = useAuth();
13
+ const router = useRouter();
14
+
15
+ const handleSubmit = async (e: React.FormEvent) => {
16
+ e.preventDefault();
17
+ setError("");
18
+ setLoading(true);
19
+ try {
20
+ await register(email, password);
21
+ // Auto-login after registration (works when Confirm Email is OFF)
22
+ try {
23
+ await login(email, password);
24
+ router.push("/");
25
+ return;
26
+ } catch {
27
+ // If auto-login fails, Confirm Email might be ON — show message
28
+ }
29
+ // Fallback: show confirmation message
30
+ setError("");
31
+ router.push("/login");
32
+ } catch (err: unknown) {
33
+ setError(err instanceof Error ? err.message : "Registration failed");
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <div className="min-h-screen flex items-center justify-center p-6 relative">
41
+ {/* Background mesh */}
42
+ <div className="mesh-gradient" />
43
+
44
+ <div
45
+ className="w-full max-w-sm animate-fade-up"
46
+ style={{ animationDelay: "0.1s" }}
47
+ >
48
+ {/* Logo */}
49
+ <div className="flex items-center justify-center mb-8">
50
+ <div
51
+ className="w-12 h-12 rounded-xl flex items-center justify-center animate-pulse-glow"
52
+ style={{
53
+ background: "var(--primary-ghost)",
54
+ color: "var(--primary)",
55
+ }}
56
+ >
57
+ <svg width="24" height="24" viewBox="0 0 18 18" fill="none">
58
+ <path
59
+ d="M3 2h3v14H3V2zm5 0h2l5 7-5 7H8l5-7-5-7z"
60
+ fill="currentColor"
61
+ />
62
+ </svg>
63
+ </div>
64
+ </div>
65
+
66
+ <div className="glass-card p-8">
67
+ <h1
68
+ className="text-xl font-bold mb-1 text-center"
69
+ style={{ color: "var(--text-primary)" }}
70
+ >
71
+ Create your account
72
+ </h1>
73
+ <p
74
+ className="text-sm text-center mb-6"
75
+ style={{ color: "var(--text-muted)" }}
76
+ >
77
+ Start compiling your company brain
78
+ </p>
79
+
80
+ <form onSubmit={handleSubmit} className="space-y-4">
81
+ <div>
82
+ <label className="input-label">Email</label>
83
+ <input
84
+ type="email"
85
+ className="input-field"
86
+ placeholder="you@company.com"
87
+ value={email}
88
+ onChange={(e) => setEmail(e.target.value)}
89
+ required
90
+ />
91
+ </div>
92
+ <div>
93
+ <label className="input-label">Password</label>
94
+ <input
95
+ type="password"
96
+ className="input-field"
97
+ placeholder="••••••••"
98
+ value={password}
99
+ onChange={(e) => setPassword(e.target.value)}
100
+ required
101
+ minLength={6}
102
+ />
103
+ <p
104
+ className="text-[11px] mt-1.5"
105
+ style={{ color: "var(--text-muted)" }}
106
+ >
107
+ Minimum 6 characters
108
+ </p>
109
+ </div>
110
+
111
+ {error && (
112
+ <div
113
+ className="text-sm px-3 py-2 rounded"
114
+ style={{
115
+ color: "var(--error)",
116
+ background: "var(--error-bg)",
117
+ border: "1px solid rgba(248, 113, 113, 0.2)",
118
+ }}
119
+ >
120
+ {error}
121
+ </div>
122
+ )}
123
+
124
+ <button
125
+ type="submit"
126
+ disabled={loading}
127
+ className="btn-primary w-full"
128
+ style={{ padding: "12px 20px" }}
129
+ >
130
+ {loading ? (
131
+ <span className="animate-spin-slow inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
132
+ ) : (
133
+ "Create Account"
134
+ )}
135
+ </button>
136
+ </form>
137
+
138
+ <p
139
+ className="text-sm mt-6 text-center"
140
+ style={{ color: "var(--text-muted)" }}
141
+ >
142
+ Already have an account?{" "}
143
+ <a
144
+ href="/login"
145
+ className="font-medium transition-colors"
146
+ style={{ color: "var(--primary)" }}
147
+ >
148
+ Sign In
149
+ </a>
150
+ </p>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ );
155
+ }
frontend/src/app/skills/[companyId]/page.tsx CHANGED
@@ -2,6 +2,10 @@
2
 
3
  import { useEffect, useState, use } from "react";
4
  import { useRouter } from "next/navigation";
 
 
 
 
5
 
6
  type Skill = {
7
  id?: string;
@@ -10,6 +14,8 @@ type Skill = {
10
  rationale?: string;
11
  evidence?: string[];
12
  confidence?: number;
 
 
13
  };
14
 
15
  type SkillsData = {
@@ -26,19 +32,17 @@ export default function SkillsViewer({ params }: { params: Promise<{ companyId:
26
  const [loading, setLoading] = useState(true);
27
  const [filter, setFilter] = useState("");
28
  const [sortBy, setSortBy] = useState<"category" | "confidence">("category");
 
29
  const router = useRouter();
30
 
31
  useEffect(() => {
32
- fetch(`http://localhost:8080/skills/${companyId}`)
33
  .then((res) => res.json())
34
  .then((d) => {
35
  setData(d);
36
  setLoading(false);
37
  })
38
- .catch((err) => {
39
- console.error(err);
40
- setLoading(false);
41
- });
42
  }, [companyId]);
43
 
44
  const skills = data?.skills || [];
@@ -47,116 +51,279 @@ export default function SkillsViewer({ params }: { params: Promise<{ companyId:
47
  const filtered = skills
48
  .filter((s) => {
49
  if (!filter) return true;
50
- return (s.category || "").toLowerCase().includes(filter.toLowerCase());
51
  })
52
  .sort((a, b) => {
53
  if (sortBy === "confidence") return (b.confidence || 0) - (a.confidence || 0);
54
  return (a.category || "").localeCompare(b.category || "");
55
  });
56
 
57
- const confidenceColor = (c: number) => {
58
- if (c >= 0.8) return "text-green-400 border-green-400/30";
59
- if (c >= 0.6) return "text-yellow-400 border-yellow-400/30";
60
- if (c >= 0.4) return "text-orange-400 border-orange-400/30";
61
- return "text-red-400 border-red-400/30";
62
- };
63
-
64
  return (
65
- <div className="min-h-screen p-8 flex flex-col">
66
- <div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
67
- <div>
68
- <h1 className="text-2xl font-bold text-primary">Skills File Viewer</h1>
69
- {data?.version && (
70
- <p className="text-text-secondary text-sm mt-1">
71
- Version: <span className="font-mono text-primary">{data.version}</span>
72
- {data.compiled_at && (
73
- <> · Compiled: {new Date(data.compiled_at).toLocaleString()}</>
74
- )}
75
- {" · "}{skills.length} skills
76
- </p>
77
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
79
- <button onClick={() => router.push("/")} className="text-text-secondary hover:text-foreground">
80
- Back
81
- </button>
82
- </div>
83
 
84
- {/* Filter + Sort Controls */}
85
- <div className="flex gap-4 mb-4">
86
- <select
87
- value={filter}
88
- onChange={(e) => setFilter(e.target.value)}
89
- className="bg-surface border border-gray-700 text-foreground px-3 py-2 text-sm"
90
- >
91
- <option value="">All Categories</option>
92
- {categories.map((c) => (
93
- <option key={c} value={c}>{c}</option>
94
- ))}
95
- </select>
96
- <select
97
- value={sortBy}
98
- onChange={(e) => setSortBy(e.target.value as "category" | "confidence")}
99
- className="bg-surface border border-gray-700 text-foreground px-3 py-2 text-sm"
100
- >
101
- <option value="category">Sort by Category</option>
102
- <option value="confidence">Sort by Confidence</option>
103
- </select>
104
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- {/* Skills Grid */}
107
- <div className="flex-1 overflow-y-auto">
108
  {loading ? (
109
- <div className="text-text-secondary">Loading skills...</div>
 
 
 
 
110
  ) : filtered.length === 0 ? (
111
- <div className="text-center py-12">
112
- <p className="text-text-secondary text-lg">No skills compiled yet.</p>
113
- <p className="text-text-secondary text-sm mt-2">
114
- Go to Dashboard Compile Brain to generate skills from your source documents.
 
 
 
 
 
 
 
 
115
  </p>
 
 
 
116
  </div>
117
  ) : (
118
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
119
  {filtered.map((skill, i) => (
120
- <div
121
  key={skill.id || i}
122
- className="bg-surface border border-gray-800 p-5 hover:border-primary/30 transition-colors"
 
123
  >
124
  <div className="flex justify-between items-start mb-3">
125
- <span className="text-xs font-mono bg-primary/10 text-primary px-2 py-1 rounded">
126
- {skill.category || "Unknown"}
127
- </span>
128
- <span
129
- className={`text-xs font-mono px-2 py-1 border rounded ${confidenceColor(
130
- skill.confidence || 0
131
- )}`}
132
- >
133
- {((skill.confidence || 0) * 100).toFixed(0)}%
134
- </span>
135
  </div>
136
 
137
- <p className="text-white font-medium mb-2">{skill.rule}</p>
 
 
138
 
139
  {skill.rationale && (
140
- <p className="text-text-secondary text-sm mb-3 italic">{skill.rationale}</p>
 
 
 
 
 
141
  )}
142
 
143
  {skill.evidence && skill.evidence.length > 0 && (
144
- <div className="border-t border-gray-800 pt-3 mt-3">
145
- <h4 className="text-xs text-text-secondary uppercase tracking-wider mb-2">
146
- Evidence ({skill.evidence.length})
147
- </h4>
148
- {skill.evidence.map((e, j) => (
149
- <p key={j} className="text-xs text-gray-400 mb-1 pl-2 border-l border-gray-700">
150
- {e}
151
- </p>
152
- ))}
153
  </div>
154
  )}
155
- </div>
156
  ))}
157
  </div>
158
  )}
159
  </div>
160
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  );
162
  }
 
2
 
3
  import { useEffect, useState, use } from "react";
4
  import { useRouter } from "next/navigation";
5
+ import { API_BASE } from "@/lib/api";
6
+ import DashboardLayout from "@/components/DashboardLayout";
7
+ import GlassCard from "@/components/ui/GlassCard";
8
+ import ConfidenceBadge from "@/components/ui/ConfidenceBadge";
9
 
10
  type Skill = {
11
  id?: string;
 
14
  rationale?: string;
15
  evidence?: string[];
16
  confidence?: number;
17
+ source_files?: string[];
18
+ embedding_vector?: number[];
19
  };
20
 
21
  type SkillsData = {
 
32
  const [loading, setLoading] = useState(true);
33
  const [filter, setFilter] = useState("");
34
  const [sortBy, setSortBy] = useState<"category" | "confidence">("category");
35
+ const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
36
  const router = useRouter();
37
 
38
  useEffect(() => {
39
+ fetch(`${API_BASE}/skills/${companyId}`)
40
  .then((res) => res.json())
41
  .then((d) => {
42
  setData(d);
43
  setLoading(false);
44
  })
45
+ .catch(() => setLoading(false));
 
 
 
46
  }, [companyId]);
47
 
48
  const skills = data?.skills || [];
 
51
  const filtered = skills
52
  .filter((s) => {
53
  if (!filter) return true;
54
+ return (s.category || "") === filter;
55
  })
56
  .sort((a, b) => {
57
  if (sortBy === "confidence") return (b.confidence || 0) - (a.confidence || 0);
58
  return (a.category || "").localeCompare(b.category || "");
59
  });
60
 
 
 
 
 
 
 
 
61
  return (
62
+ <DashboardLayout>
63
+ <div className="p-6 lg:p-8 max-w-7xl mx-auto animate-fade-in">
64
+ {/* Header */}
65
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
66
+ <div>
67
+ <h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
68
+ Skills Explorer
69
+ </h1>
70
+ {data?.version && (
71
+ <p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
72
+ <span className="font-mono" style={{ color: "var(--primary)" }}>
73
+ {data.version}
74
+ </span>
75
+ {data.compiled_at && (
76
+ <> · {new Date(data.compiled_at).toLocaleDateString()}</>
77
+ )}
78
+ {" · "}
79
+ <span style={{ color: "var(--text-secondary)" }}>{skills.length} skills</span>
80
+ </p>
81
+ )}
82
+ </div>
83
+ <button
84
+ onClick={() => router.push(`/demo/${companyId}`)}
85
+ className="btn-primary"
86
+ >
87
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
88
+ <circle cx="7" cy="7" r="5.5" />
89
+ <path d="M7 4v3l2 1.5" strokeLinecap="round" />
90
+ </svg>
91
+ Query Agent
92
+ </button>
93
  </div>
 
 
 
 
94
 
95
+ {/* Filter Chips */}
96
+ <div className="flex gap-2 flex-wrap mb-6">
97
+ <button
98
+ onClick={() => setFilter("")}
99
+ className="badge transition-all"
100
+ style={{
101
+ background: !filter ? "var(--primary-ghost)" : "transparent",
102
+ color: !filter ? "var(--primary)" : "var(--text-muted)",
103
+ border: `1px solid ${!filter ? "rgba(0,210,180,0.2)" : "var(--border)"}`,
104
+ cursor: "pointer",
105
+ }}
106
+ >
107
+ All ({skills.length})
108
+ </button>
109
+ {categories.map((cat) => {
110
+ const count = skills.filter((s) => (s.category || "Unknown") === cat).length;
111
+ const active = filter === cat;
112
+ return (
113
+ <button
114
+ key={cat}
115
+ onClick={() => setFilter(active ? "" : cat)}
116
+ className="badge transition-all"
117
+ style={{
118
+ background: active ? "var(--primary-ghost)" : "transparent",
119
+ color: active ? "var(--primary)" : "var(--text-muted)",
120
+ border: `1px solid ${active ? "rgba(0,210,180,0.2)" : "var(--border)"}`,
121
+ cursor: "pointer",
122
+ }}
123
+ >
124
+ {cat} ({count})
125
+ </button>
126
+ );
127
+ })}
128
+
129
+ {/* Sort toggle */}
130
+ <div className="ml-auto">
131
+ <button
132
+ onClick={() => setSortBy(sortBy === "category" ? "confidence" : "category")}
133
+ className="badge transition-all"
134
+ style={{
135
+ background: "transparent",
136
+ color: "var(--text-secondary)",
137
+ border: "1px solid var(--border)",
138
+ cursor: "pointer",
139
+ }}
140
+ >
141
+ Sort: {sortBy === "category" ? "Category" : "Confidence"}
142
+ </button>
143
+ </div>
144
+ </div>
145
 
146
+ {/* Skills Grid */}
 
147
  {loading ? (
148
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
149
+ {[1, 2, 3, 4, 5, 6].map((i) => (
150
+ <div key={i} className="glass-card p-5 animate-shimmer" style={{ height: "180px" }} />
151
+ ))}
152
+ </div>
153
  ) : filtered.length === 0 ? (
154
+ <div className="empty-state animate-fade-up">
155
+ <div className="empty-state__icon">
156
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
157
+ <rect x="4" y="4" width="16" height="16" rx="2" />
158
+ <path d="M9 9h6M9 12h4" strokeLinecap="round" />
159
+ </svg>
160
+ </div>
161
+ <h2 className="text-lg font-bold mb-2" style={{ color: "var(--text-primary)" }}>
162
+ No skills compiled yet
163
+ </h2>
164
+ <p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
165
+ Compile your company brain to generate skills from source documents.
166
  </p>
167
+ <button onClick={() => router.push("/onboarding")} className="btn-primary">
168
+ Start Onboarding
169
+ </button>
170
  </div>
171
  ) : (
172
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
173
  {filtered.map((skill, i) => (
174
+ <GlassCard
175
  key={skill.id || i}
176
+ interactive
177
+ onClick={() => setSelectedSkill(skill)}
178
  >
179
  <div className="flex justify-between items-start mb-3">
180
+ <span className="badge badge--primary">{skill.category || "Unknown"}</span>
181
+ <ConfidenceBadge value={skill.confidence || 0} />
 
 
 
 
 
 
 
 
182
  </div>
183
 
184
+ <p className="text-sm font-medium mb-2" style={{ color: "var(--text-primary)" }}>
185
+ {skill.rule}
186
+ </p>
187
 
188
  {skill.rationale && (
189
+ <p
190
+ className="text-xs leading-relaxed line-clamp-2 mb-3"
191
+ style={{ color: "var(--text-muted)" }}
192
+ >
193
+ {skill.rationale}
194
+ </p>
195
  )}
196
 
197
  {skill.evidence && skill.evidence.length > 0 && (
198
+ <div
199
+ className="pt-3 mt-auto"
200
+ style={{ borderTop: "1px solid var(--border)" }}
201
+ >
202
+ <p className="text-[10px] font-mono uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
203
+ {skill.evidence.length} evidence source{skill.evidence.length !== 1 ? "s" : ""}
204
+ </p>
 
 
205
  </div>
206
  )}
207
+ </GlassCard>
208
  ))}
209
  </div>
210
  )}
211
  </div>
212
+
213
+ {/* Detail Slide-in Panel */}
214
+ {selectedSkill && (
215
+ <div
216
+ className="fixed inset-0 z-50 flex"
217
+ onClick={() => setSelectedSkill(null)}
218
+ >
219
+ {/* Backdrop */}
220
+ <div
221
+ className="flex-1"
222
+ style={{ background: "rgba(0, 0, 0, 0.5)" }}
223
+ />
224
+
225
+ {/* Panel */}
226
+ <div
227
+ className="w-full max-w-lg overflow-y-auto animate-slide-right"
228
+ style={{
229
+ background: "var(--bg-surface)",
230
+ borderLeft: "1px solid var(--border)",
231
+ }}
232
+ onClick={(e) => e.stopPropagation()}
233
+ >
234
+ {/* Panel Header */}
235
+ <div
236
+ className="sticky top-0 z-10 flex items-center justify-between px-6 py-4"
237
+ style={{
238
+ background: "var(--bg-surface)",
239
+ borderBottom: "1px solid var(--border)",
240
+ }}
241
+ >
242
+ <h2 className="text-lg font-bold" style={{ color: "var(--primary)" }}>
243
+ Skill Detail
244
+ </h2>
245
+ <button
246
+ onClick={() => setSelectedSkill(null)}
247
+ className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors"
248
+ style={{ color: "var(--text-muted)" }}
249
+ >
250
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
251
+ <path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" />
252
+ </svg>
253
+ </button>
254
+ </div>
255
+
256
+ {/* Panel Body */}
257
+ <div className="p-6 space-y-6">
258
+ <div className="flex justify-between items-start">
259
+ <span className="badge badge--primary">{selectedSkill.category || "Unknown"}</span>
260
+ <ConfidenceBadge value={selectedSkill.confidence || 0} size="md" />
261
+ </div>
262
+
263
+ <div>
264
+ <p className="input-label">Rule</p>
265
+ <p className="text-base font-medium" style={{ color: "var(--text-primary)" }}>
266
+ {selectedSkill.rule}
267
+ </p>
268
+ </div>
269
+
270
+ {selectedSkill.rationale && (
271
+ <div>
272
+ <p className="input-label">Rationale</p>
273
+ <p className="text-sm leading-relaxed" style={{ color: "var(--text-secondary)" }}>
274
+ {selectedSkill.rationale}
275
+ </p>
276
+ </div>
277
+ )}
278
+
279
+ {selectedSkill.evidence && selectedSkill.evidence.length > 0 && (
280
+ <div>
281
+ <p className="input-label">
282
+ Evidence ({selectedSkill.evidence.length})
283
+ </p>
284
+ <div className="space-y-2">
285
+ {selectedSkill.evidence.map((e, j) => (
286
+ <div
287
+ key={j}
288
+ className="text-sm p-3 rounded"
289
+ style={{
290
+ color: "var(--text-secondary)",
291
+ background: "var(--bg-input)",
292
+ border: "1px solid var(--border)",
293
+ }}
294
+ >
295
+ {e}
296
+ </div>
297
+ ))}
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ {selectedSkill.source_files && selectedSkill.source_files.length > 0 && (
303
+ <div>
304
+ <p className="input-label">Source Files</p>
305
+ <div className="flex flex-wrap gap-2">
306
+ {selectedSkill.source_files.map((sf, j) => (
307
+ <span key={j} className="badge badge--neutral font-mono">
308
+ {sf}
309
+ </span>
310
+ ))}
311
+ </div>
312
+ </div>
313
+ )}
314
+
315
+ <div style={{ borderTop: "1px solid var(--border)", paddingTop: "24px" }}>
316
+ <button
317
+ onClick={() => setSelectedSkill(null)}
318
+ className="btn-secondary w-full"
319
+ >
320
+ Close
321
+ </button>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ )}
327
+ </DashboardLayout>
328
  );
329
  }
frontend/src/components/DashboardLayout.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Sidebar from "./Sidebar";
4
+ import TopBar from "./TopBar";
5
+
6
+ interface DashboardLayoutProps {
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export default function DashboardLayout({ children }: DashboardLayoutProps) {
11
+ return (
12
+ <div className="flex min-h-screen">
13
+ {/* Gradient mesh background */}
14
+ <div className="mesh-gradient" />
15
+
16
+ {/* Sidebar */}
17
+ <Sidebar />
18
+
19
+ {/* Main area */}
20
+ <div
21
+ className="flex-1 flex flex-col min-h-screen"
22
+ style={{ marginLeft: "var(--sidebar-width)" }}
23
+ >
24
+ <TopBar />
25
+ <main className="flex-1 overflow-y-auto">
26
+ {children}
27
+ </main>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
frontend/src/components/NavBar.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useAuth } from "@/lib/auth";
4
+ import { useRouter } from "next/navigation";
5
+
6
+ export default function NavBar() {
7
+ const { user, logout, loading } = useAuth();
8
+ const router = useRouter();
9
+
10
+ return (
11
+ <nav className="border-b border-gray-800 bg-surface/80 backdrop-blur-sm">
12
+ <div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
13
+ <button
14
+ onClick={() => router.push("/")}
15
+ className="text-lg font-bold text-primary tracking-tight"
16
+ >
17
+ Kernl
18
+ </button>
19
+ <div className="flex items-center gap-4 text-sm">
20
+ {!loading && user ? (
21
+ <>
22
+ <span className="text-text-secondary">{user.email}</span>
23
+ <button
24
+ onClick={() => { logout(); router.push("/login"); }}
25
+ className="text-text-secondary hover:text-foreground"
26
+ >
27
+ Sign Out
28
+ </button>
29
+ </>
30
+ ) : (
31
+ <>
32
+ <button
33
+ onClick={() => router.push("/login")}
34
+ className="text-text-secondary hover:text-foreground"
35
+ >
36
+ Sign In
37
+ </button>
38
+ <button
39
+ onClick={() => router.push("/register")}
40
+ className="bg-primary text-background font-bold px-4 py-1.5 text-xs hover:opacity-90"
41
+ >
42
+ Sign Up
43
+ </button>
44
+ </>
45
+ )}
46
+ </div>
47
+ </div>
48
+ </nav>
49
+ );
50
+ }
frontend/src/components/Sidebar.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { usePathname, useRouter } from "next/navigation";
4
+
5
+ const NAV_ITEMS = [
6
+ {
7
+ id: "home",
8
+ label: "Dashboard",
9
+ path: "/",
10
+ icon: (
11
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
12
+ <rect x="2" y="2" width="7" height="8" rx="1.5" />
13
+ <rect x="11" y="2" width="7" height="5" rx="1.5" />
14
+ <rect x="2" y="12" width="7" height="6" rx="1.5" />
15
+ <rect x="11" y="9" width="7" height="9" rx="1.5" />
16
+ </svg>
17
+ ),
18
+ },
19
+ {
20
+ id: "onboarding",
21
+ label: "Onboarding",
22
+ path: "/onboarding",
23
+ icon: (
24
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
25
+ <path d="M10 2v16M2 10h16" strokeLinecap="round" />
26
+ </svg>
27
+ ),
28
+ },
29
+ {
30
+ id: "skills",
31
+ label: "Skills",
32
+ path: "/skills",
33
+ icon: (
34
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
35
+ <path d="M3 4h14M3 8h10M3 12h12M3 16h8" strokeLinecap="round" />
36
+ </svg>
37
+ ),
38
+ },
39
+ {
40
+ id: "demo",
41
+ label: "Query",
42
+ path: "/demo",
43
+ icon: (
44
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
45
+ <circle cx="10" cy="10" r="7" />
46
+ <path d="M10 6v4l3 2" strokeLinecap="round" strokeLinejoin="round" />
47
+ </svg>
48
+ ),
49
+ },
50
+ ];
51
+
52
+ export default function Sidebar() {
53
+ const pathname = usePathname();
54
+ const router = useRouter();
55
+
56
+ const isActive = (path: string) => {
57
+ if (path === "/") return pathname === "/";
58
+ return pathname.startsWith(path);
59
+ };
60
+
61
+ return (
62
+ <aside
63
+ className="fixed left-0 top-0 bottom-0 z-40 flex flex-col items-center py-5 border-r"
64
+ style={{
65
+ width: "var(--sidebar-width)",
66
+ background: "var(--bg-surface)",
67
+ borderColor: "var(--border)",
68
+ }}
69
+ >
70
+ {/* Logo */}
71
+ <button
72
+ onClick={() => router.push("/")}
73
+ className="mb-8 flex items-center justify-center w-9 h-9 rounded-lg transition-all hover:scale-105"
74
+ style={{
75
+ background: "var(--primary-ghost)",
76
+ color: "var(--primary)",
77
+ }}
78
+ title="Kernl"
79
+ >
80
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
81
+ <path
82
+ d="M3 2h3v14H3V2zm5 0h2l5 7-5 7H8l5-7-5-7z"
83
+ fill="currentColor"
84
+ />
85
+ </svg>
86
+ </button>
87
+
88
+ {/* Nav Items */}
89
+ <nav className="flex-1 flex flex-col gap-1 w-full px-2">
90
+ {NAV_ITEMS.map((item) => {
91
+ const active = isActive(item.path);
92
+ return (
93
+ <button
94
+ key={item.id}
95
+ onClick={() => router.push(item.path)}
96
+ title={item.label}
97
+ className="relative flex items-center justify-center w-10 h-10 mx-auto rounded-lg transition-all duration-200 group"
98
+ style={{
99
+ color: active ? "var(--primary)" : "var(--text-muted)",
100
+ background: active ? "var(--primary-ghost)" : "transparent",
101
+ }}
102
+ >
103
+ {/* Active indicator */}
104
+ {active && (
105
+ <span
106
+ className="absolute left-0 top-2 bottom-2 w-[2px] rounded-full"
107
+ style={{ background: "var(--primary)" }}
108
+ />
109
+ )}
110
+ {item.icon}
111
+
112
+ {/* Tooltip */}
113
+ <span
114
+ className="absolute left-full ml-3 px-2 py-1 rounded text-xs font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity"
115
+ style={{
116
+ background: "var(--bg-elevated)",
117
+ color: "var(--text-primary)",
118
+ border: "1px solid var(--border-hover)",
119
+ }}
120
+ >
121
+ {item.label}
122
+ </span>
123
+ </button>
124
+ );
125
+ })}
126
+ </nav>
127
+
128
+ {/* Bottom accent dot */}
129
+ <div
130
+ className="w-2 h-2 rounded-full"
131
+ style={{ background: "var(--primary-dim)", opacity: 0.5 }}
132
+ />
133
+ </aside>
134
+ );
135
+ }
frontend/src/components/TopBar.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useAuth } from "@/lib/auth";
4
+ import { useRouter, usePathname } from "next/navigation";
5
+
6
+ function getPageTitle(pathname: string): string {
7
+ if (pathname === "/") return "Dashboard";
8
+ if (pathname.startsWith("/onboarding")) return "Onboarding";
9
+ if (pathname.startsWith("/skills")) return "Skills Explorer";
10
+ if (pathname.startsWith("/demo")) return "Query Agent";
11
+ if (pathname.startsWith("/compile")) return "Compile Pipeline";
12
+ return "Kernl";
13
+ }
14
+
15
+ export default function TopBar() {
16
+ const { user, logout, loading } = useAuth();
17
+ const router = useRouter();
18
+ const pathname = usePathname();
19
+ const pageTitle = getPageTitle(pathname);
20
+
21
+ return (
22
+ <header
23
+ className="sticky top-0 z-30 flex items-center justify-between h-14 px-6 border-b"
24
+ style={{
25
+ background: "rgba(10, 15, 20, 0.85)",
26
+ backdropFilter: "blur(12px)",
27
+ borderColor: "var(--border)",
28
+ }}
29
+ >
30
+ {/* Left: Breadcrumb */}
31
+ <div className="flex items-center gap-2 text-sm">
32
+ <span style={{ color: "var(--text-muted)" }}>Kernl</span>
33
+ <span style={{ color: "var(--text-muted)" }}>/</span>
34
+ <span style={{ color: "var(--text-primary)", fontWeight: 600 }}>
35
+ {pageTitle}
36
+ </span>
37
+ </div>
38
+
39
+ {/* Right: User */}
40
+ <div className="flex items-center gap-4">
41
+ {!loading && user ? (
42
+ <>
43
+ <span
44
+ className="text-xs font-mono"
45
+ style={{ color: "var(--text-muted)" }}
46
+ >
47
+ {user.email}
48
+ </span>
49
+ <button
50
+ onClick={() => {
51
+ logout();
52
+ router.push("/login");
53
+ }}
54
+ className="text-xs font-medium px-3 py-1.5 rounded-md transition-colors"
55
+ style={{
56
+ color: "var(--text-secondary)",
57
+ border: "1px solid var(--border)",
58
+ }}
59
+ onMouseEnter={(e) => {
60
+ e.currentTarget.style.borderColor = "var(--border-hover)";
61
+ e.currentTarget.style.color = "var(--text-primary)";
62
+ }}
63
+ onMouseLeave={(e) => {
64
+ e.currentTarget.style.borderColor = "var(--border)";
65
+ e.currentTarget.style.color = "var(--text-secondary)";
66
+ }}
67
+ >
68
+ Sign Out
69
+ </button>
70
+ </>
71
+ ) : !loading ? (
72
+ <div className="flex items-center gap-2">
73
+ <button
74
+ onClick={() => router.push("/login")}
75
+ className="text-xs font-medium px-3 py-1.5 rounded-md transition-colors"
76
+ style={{ color: "var(--text-secondary)" }}
77
+ >
78
+ Sign In
79
+ </button>
80
+ <button
81
+ onClick={() => router.push("/register")}
82
+ className="btn-primary"
83
+ style={{ fontSize: "12px", padding: "6px 14px" }}
84
+ >
85
+ Sign Up
86
+ </button>
87
+ </div>
88
+ ) : null}
89
+ </div>
90
+ </header>
91
+ );
92
+ }
frontend/src/components/ui/ConfidenceBadge.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface ConfidenceBadgeProps {
2
+ value: number; // 0.0 - 1.0
3
+ size?: "sm" | "md";
4
+ }
5
+
6
+ export default function ConfidenceBadge({ value, size = "sm" }: ConfidenceBadgeProps) {
7
+ const pct = Math.round(value * 100);
8
+
9
+ let colorVar: string;
10
+ let bgVar: string;
11
+ let borderVar: string;
12
+
13
+ if (value >= 0.8) {
14
+ colorVar = "var(--success)";
15
+ bgVar = "var(--success-bg)";
16
+ borderVar = "rgba(52, 211, 153, 0.25)";
17
+ } else if (value >= 0.6) {
18
+ colorVar = "var(--warning)";
19
+ bgVar = "var(--warning-bg)";
20
+ borderVar = "rgba(251, 191, 36, 0.25)";
21
+ } else if (value >= 0.4) {
22
+ colorVar = "var(--info)";
23
+ bgVar = "var(--info-bg)";
24
+ borderVar = "rgba(96, 165, 250, 0.25)";
25
+ } else {
26
+ colorVar = "var(--error)";
27
+ bgVar = "var(--error-bg)";
28
+ borderVar = "rgba(248, 113, 113, 0.25)";
29
+ }
30
+
31
+ const textSize = size === "sm" ? "text-[10px]" : "text-xs";
32
+ const px = size === "sm" ? "px-2 py-0.5" : "px-2.5 py-1";
33
+
34
+ return (
35
+ <span
36
+ className={`inline-flex items-center gap-1 font-mono font-semibold ${textSize} ${px} rounded`}
37
+ style={{
38
+ color: colorVar,
39
+ background: bgVar,
40
+ border: `1px solid ${borderVar}`,
41
+ }}
42
+ >
43
+ <span
44
+ className="inline-block w-1.5 h-1.5 rounded-full"
45
+ style={{ background: colorVar }}
46
+ />
47
+ {pct}%
48
+ </span>
49
+ );
50
+ }
frontend/src/components/ui/GlassCard.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ReactNode } from "react";
2
+
3
+ interface GlassCardProps {
4
+ children: ReactNode;
5
+ className?: string;
6
+ interactive?: boolean;
7
+ elevated?: boolean;
8
+ padding?: "none" | "sm" | "md" | "lg";
9
+ onClick?: () => void;
10
+ style?: React.CSSProperties;
11
+ }
12
+
13
+ const paddingMap = {
14
+ none: "",
15
+ sm: "p-4",
16
+ md: "p-5",
17
+ lg: "p-6",
18
+ };
19
+
20
+ export default function GlassCard({
21
+ children,
22
+ className = "",
23
+ interactive = false,
24
+ elevated = false,
25
+ padding = "md",
26
+ onClick,
27
+ style,
28
+ }: GlassCardProps) {
29
+ const baseClass = elevated ? "glass-card--elevated" : "glass-card";
30
+ const interactiveClass = interactive ? "glass-card--interactive" : "";
31
+ const padClass = paddingMap[padding];
32
+
33
+ return (
34
+ <div
35
+ className={`${baseClass} ${interactiveClass} ${padClass} ${className}`}
36
+ style={style}
37
+ onClick={onClick}
38
+ role={onClick ? "button" : undefined}
39
+ tabIndex={onClick ? 0 : undefined}
40
+ onKeyDown={
41
+ onClick
42
+ ? (e) => {
43
+ if (e.key === "Enter" || e.key === " ") {
44
+ e.preventDefault();
45
+ onClick();
46
+ }
47
+ }
48
+ : undefined
49
+ }
50
+ >
51
+ {children}
52
+ </div>
53
+ );
54
+ }
frontend/src/components/ui/StatCard.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface StatCardProps {
2
+ label: string;
3
+ value: string | number;
4
+ icon?: React.ReactNode;
5
+ accent?: "primary" | "success" | "warning" | "info";
6
+ subtitle?: string;
7
+ }
8
+
9
+ const accentMap = {
10
+ primary: { bg: "var(--primary-ghost)", color: "var(--primary)" },
11
+ success: { bg: "var(--success-bg)", color: "var(--success)" },
12
+ warning: { bg: "var(--warning-bg)", color: "var(--warning)" },
13
+ info: { bg: "var(--info-bg)", color: "var(--info)" },
14
+ };
15
+
16
+ export default function StatCard({
17
+ label,
18
+ value,
19
+ icon,
20
+ accent = "primary",
21
+ subtitle,
22
+ }: StatCardProps) {
23
+ const colors = accentMap[accent];
24
+
25
+ return (
26
+ <div className="glass-card p-5 animate-fade-in">
27
+ <div className="flex items-start justify-between mb-3">
28
+ <span
29
+ className="text-[10px] font-semibold uppercase tracking-[0.08em] font-mono"
30
+ style={{ color: "var(--text-muted)" }}
31
+ >
32
+ {label}
33
+ </span>
34
+ {icon && (
35
+ <span
36
+ className="w-8 h-8 rounded-lg flex items-center justify-center text-sm"
37
+ style={{ background: colors.bg, color: colors.color }}
38
+ >
39
+ {icon}
40
+ </span>
41
+ )}
42
+ </div>
43
+ <p
44
+ className="text-2xl font-bold tracking-tight"
45
+ style={{ color: colors.color }}
46
+ >
47
+ {value}
48
+ </p>
49
+ {subtitle && (
50
+ <p
51
+ className="text-xs mt-1"
52
+ style={{ color: "var(--text-muted)" }}
53
+ >
54
+ {subtitle}
55
+ </p>
56
+ )}
57
+ </div>
58
+ );
59
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export const API_BASE =
2
+ process.env.NEXT_PUBLIC_API_URL || "http://localhost:8081";
frontend/src/lib/auth.tsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { createContext, useContext, useState, useEffect, ReactNode } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { API_BASE } from "@/lib/api";
6
+
7
+ type AuthConfig = {
8
+ supabase_url: string;
9
+ supabase_anon_key: string;
10
+ };
11
+
12
+ type AuthUser = {
13
+ id: string;
14
+ email: string;
15
+ };
16
+
17
+ type AuthContextType = {
18
+ user: AuthUser | null;
19
+ token: string | null;
20
+ loading: boolean;
21
+ login: (email: string, password: string) => Promise<void>;
22
+ register: (email: string, password: string) => Promise<void>;
23
+ logout: () => void;
24
+ };
25
+
26
+ let cachedConfig: AuthConfig | null = null;
27
+
28
+ async function getAuthConfig(): Promise<AuthConfig> {
29
+ if (cachedConfig) return cachedConfig;
30
+ const res = await fetch(`${API_BASE}/auth/config`);
31
+ cachedConfig = await res.json();
32
+ return cachedConfig!;
33
+ }
34
+
35
+ export async function loginWithSupabase(email: string, password: string) {
36
+ const config = await getAuthConfig();
37
+ const res = await fetch(`${config.supabase_url}/auth/v1/token?grant_type=password`, {
38
+ method: "POST",
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ apikey: config.supabase_anon_key,
42
+ },
43
+ body: JSON.stringify({ email, password }),
44
+ });
45
+ if (!res.ok) throw new Error((await res.json()).error_description || "Login failed");
46
+ const data = await res.json();
47
+ sessionStorage.setItem("kernl_token", data.access_token);
48
+ sessionStorage.setItem("kernl_user", JSON.stringify({ id: data.user.id, email: data.user.email }));
49
+ return data;
50
+ }
51
+
52
+ export async function registerWithSupabase(email: string, password: string) {
53
+ const config = await getAuthConfig();
54
+ const res = await fetch(`${config.supabase_url}/auth/v1/signup`, {
55
+ method: "POST",
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ apikey: config.supabase_anon_key,
59
+ },
60
+ body: JSON.stringify({ email, password }),
61
+ });
62
+ if (!res.ok) throw new Error((await res.json()).msg || "Registration failed");
63
+ return res.json();
64
+ }
65
+
66
+ const AuthContext = createContext<AuthContextType | null>(null);
67
+
68
+ export function AuthProvider({ children }: { children: ReactNode }) {
69
+ const [user, setUser] = useState<AuthUser | null>(null);
70
+ const [token, setToken] = useState<string | null>(null);
71
+ const [loading, setLoading] = useState(true);
72
+
73
+ useEffect(() => {
74
+ const savedToken = sessionStorage.getItem("kernl_token");
75
+ const savedUser = sessionStorage.getItem("kernl_user");
76
+ if (savedToken && savedUser) {
77
+ setToken(savedToken);
78
+ setUser(JSON.parse(savedUser));
79
+ }
80
+ setLoading(false);
81
+ }, []);
82
+
83
+ const login = async (email: string, password: string) => {
84
+ const data = await loginWithSupabase(email, password);
85
+ setToken(data.access_token);
86
+ setUser({ id: data.user.id, email: data.user.email });
87
+ };
88
+
89
+ const register = async (email: string, password: string) => {
90
+ await registerWithSupabase(email, password);
91
+ };
92
+
93
+ const logout = () => {
94
+ sessionStorage.removeItem("kernl_token");
95
+ sessionStorage.removeItem("kernl_user");
96
+ setToken(null);
97
+ setUser(null);
98
+ };
99
+
100
+ return (
101
+ <AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
102
+ {children}
103
+ </AuthContext.Provider>
104
+ );
105
+ }
106
+
107
+ export function useAuth() {
108
+ const ctx = useContext(AuthContext);
109
+ if (!ctx) throw new Error("useAuth must be used within AuthProvider");
110
+ return ctx;
111
+ }
112
+
113
+ export function useProtectedAuth() {
114
+ const auth = useAuth();
115
+ const router = useRouter();
116
+
117
+ useEffect(() => {
118
+ if (!auth.loading && !auth.user) {
119
+ router.push("/login");
120
+ }
121
+ }, [auth.loading, auth.user, router]);
122
+
123
+ return auth;
124
+ }