senlinyy commited on
Commit
4b81334
·
1 Parent(s): af24c4f

feat: completed initial dev

Browse files
Files changed (48) hide show
  1. .dockerignore +12 -0
  2. .env.example +24 -0
  3. .gitignore +25 -0
  4. Dockerfile +61 -0
  5. README.md +65 -1
  6. backend/auth.py +51 -0
  7. backend/hf_embedding.py +188 -0
  8. backend/hf_sync.py +261 -0
  9. backend/main.py +506 -0
  10. backend/personas.json +26 -0
  11. backend/personas_store.py +110 -0
  12. backend/pyproject.toml +25 -0
  13. backend/rag.py +174 -0
  14. backend/uv.lock +0 -0
  15. frontend/.eslintrc.js +4 -0
  16. frontend/app/admin/login/page.tsx +68 -0
  17. frontend/app/admin/page.tsx +82 -0
  18. frontend/app/api/chat/route.ts +39 -0
  19. frontend/app/api/upload/route.ts +37 -0
  20. frontend/app/globals.css +47 -0
  21. frontend/app/layout.tsx +15 -0
  22. frontend/app/page.tsx +26 -0
  23. frontend/components/chat-interface.tsx +243 -0
  24. frontend/components/file-list.tsx +85 -0
  25. frontend/components/file-uploader.tsx +87 -0
  26. frontend/components/persona-editor.tsx +297 -0
  27. frontend/components/persona-selector.tsx +43 -0
  28. frontend/components/temperature-slider.tsx +32 -0
  29. frontend/components/ui/button.tsx +48 -0
  30. frontend/components/ui/card.tsx +65 -0
  31. frontend/components/ui/dialog.tsx +109 -0
  32. frontend/components/ui/input.tsx +19 -0
  33. frontend/components/ui/label.tsx +19 -0
  34. frontend/components/ui/select.tsx +81 -0
  35. frontend/components/ui/slider.tsx +24 -0
  36. frontend/lib/personas.ts +63 -0
  37. frontend/lib/utils.ts +6 -0
  38. frontend/next-env.d.ts +6 -0
  39. frontend/next.config.js +22 -0
  40. frontend/package-lock.json +0 -0
  41. frontend/package.json +38 -0
  42. frontend/postcss.config.mjs +6 -0
  43. frontend/tailwind.config.ts +46 -0
  44. frontend/tsconfig.json +43 -0
  45. frontend/tsconfig.tsbuildinfo +0 -0
  46. start.sh +32 -0
  47. test_stream.py +6 -0
  48. test_uvicorn_log.py +14 -0
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **/node_modules
2
+ **/.next
3
+ **/.venv
4
+ **/__pycache__
5
+ **/.lancedb
6
+ **/.tmp_uploads
7
+ **/.cache
8
+ .git
9
+ .env
10
+ .env.local
11
+ *.md
12
+ !README.md
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- Hugging Face ----
2
+ # A read+write token from https://huggingface.co/settings/tokens
3
+ HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4
+
5
+ # The dataset repo that acts as your persistent "cloud drive" for PDFs.
6
+ # Format: "username/dataset-name" — must already exist (create as a private dataset).
7
+ DATASET_ID=your-username/iam-earth-dev-corpus
8
+
9
+ # ---- LLM / Embeddings (Hugging Face Serverless Inference) ----
10
+ # Any chat-completions-capable model on the HF Inference API.
11
+ LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
12
+ EMBED_MODEL=BAAI/bge-small-en-v1.5
13
+
14
+ # Leave reasoning hidden in the student UI. Set true only when debugging models
15
+ # that expose a separate thinking/reasoning stream.
16
+ SHOW_LLM_REASONING=false
17
+
18
+ # ---- Admin auth ----
19
+ # Educators set this as a Space secret; the admin dashboard requires it.
20
+ ADMIN_PASSCODE=password
21
+
22
+ # ---- Internal (defaults are fine for local dev; Docker sets these via Dockerfile ENV) ----
23
+ BACKEND_PORT=8000
24
+ FRONTEND_PORT=7860
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ backend/.venv/
6
+ .uv/
7
+
8
+ # Node
9
+ node_modules/
10
+ frontend/.next/
11
+ frontend/out/
12
+ .next/
13
+
14
+ # Runtime data (must NOT be committed — these are ephemeral on HF Spaces)
15
+ .lancedb/
16
+ .tmp_uploads/
17
+ .cache/
18
+
19
+ # Env
20
+ .env
21
+ .env.local
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.7
2
+ # ---------- Stage 1: Build the Next.js frontend ----------
3
+ FROM node:20-bookworm-slim AS frontend-build
4
+
5
+ WORKDIR /app/frontend
6
+ COPY frontend/package.json frontend/package-lock.json* ./
7
+ RUN npm install --no-audit --no-fund
8
+
9
+ COPY frontend/ ./
10
+ ENV NEXT_TELEMETRY_DISABLED=1
11
+ RUN npm run build
12
+
13
+ # ---------- Stage 2: Runtime (Python 3.13 + Node 20) ----------
14
+ FROM python:3.13-slim-bookworm AS runtime
15
+
16
+ # Install Node 20 (needed to run `next start`) and minimal system libs
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ curl ca-certificates gnupg build-essential \
19
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
20
+ && apt-get install -y --no-install-recommends nodejs \
21
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Install uv (fast Python package manager)
24
+ RUN pip install --no-cache-dir uv
25
+
26
+ # HF Spaces requires the working dir to be writable by an arbitrary uid.
27
+ # We create /app and chmod it 777 so cache + lancedb dirs work at runtime.
28
+ WORKDIR /app
29
+
30
+ # ---- Backend ----
31
+ COPY backend/pyproject.toml backend/uv.lock* ./backend/
32
+ RUN cd backend && uv sync --no-dev --frozen 2>/dev/null || (cd backend && uv sync --no-dev)
33
+
34
+ COPY backend/ ./backend/
35
+
36
+ # ---- Frontend (built artifacts only) ----
37
+ COPY --from=frontend-build /app/frontend/.next ./frontend/.next
38
+ COPY --from=frontend-build /app/frontend/public ./frontend/public
39
+ COPY --from=frontend-build /app/frontend/package.json ./frontend/package.json
40
+ COPY --from=frontend-build /app/frontend/next.config.js ./frontend/next.config.js
41
+ COPY --from=frontend-build /app/frontend/node_modules ./frontend/node_modules
42
+
43
+ # ---- Entry script ----
44
+ COPY start.sh /app/start.sh
45
+ RUN chmod +x /app/start.sh
46
+
47
+ # Cache + data dirs (HF Spaces runs as random uid; make them world-writable)
48
+ RUN mkdir -p /app/.cache /app/.lancedb /app/.tmp_uploads \
49
+ && chmod -R 777 /app
50
+
51
+ ENV HF_HOME=/app/.cache \
52
+ LANCEDB_PATH=/app/.lancedb \
53
+ TMP_UPLOAD_DIR=/app/.tmp_uploads \
54
+ BACKEND_PORT=8000 \
55
+ FRONTEND_PORT=7860 \
56
+ NEXT_TELEMETRY_DISABLED=1 \
57
+ PYTHONUNBUFFERED=1
58
+
59
+ EXPOSE 7860
60
+
61
+ CMD ["/app/start.sh"]
README.md CHANGED
@@ -4,7 +4,71 @@ emoji: 🌖
4
  colorFrom: purple
5
  colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: purple
5
  colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ ## Quick start (clone for your own course)
12
+
13
+ 1. **Duplicate this Space** on Hugging Face.
14
+ 2. **Create a private Dataset** on HF (e.g. `your-username/my-course-corpus`) — leave it empty.
15
+ 3. In your Space → **Settings → Variables and secrets**, add:
16
+ - `HF_TOKEN` — a token with **write** access to the dataset above.
17
+ - `DATASET_ID` — `your-username/my-course-corpus`
18
+ - `ADMIN_PASSCODE` — any string; this protects `/admin`.
19
+ - *(optional)* `LLM_MODEL` — defaults to `Qwen/Qwen2.5-7B-Instruct`.
20
+ - *(optional)* `EMBED_MODEL` — defaults to `BAAI/bge-small-en-v1.5`.
21
+ 4. Restart the Space. Open `/admin`, sign in with the passcode, drag in PDFs.
22
+ 5. Share the root URL with your students — they land on the chat UI.
23
+
24
+ ## Local development
25
+
26
+ Requires Python 3.13, Node 20+, and [`uv`](https://docs.astral.sh/uv/).
27
+
28
+ ```bash
29
+ cp .env.example .env # fill in HF_TOKEN, DATASET_ID, ADMIN_PASSCODE
30
+
31
+ # Backend
32
+ cd backend && uv sync && uv run uvicorn main:app --port 8000 --reload
33
+
34
+ # Frontend (in another shell)
35
+ cd frontend && npm install && npm run dev
36
+ ```
37
+
38
+ Visit <http://localhost:7860>. Next.js proxies `/api/*` to `127.0.0.1:8000`.
39
+
40
+ ### Or with Docker (matches the HF Space environment exactly)
41
+
42
+ ```bash
43
+ docker build -t iamearthdev .
44
+ docker run --rm -p 7860:7860 --env-file .env iamearthdev
45
+ ```
46
+
47
+ ## Customizing personas
48
+
49
+ Edit [`frontend/lib/personas.ts`](frontend/lib/personas.ts) — each persona is just an
50
+ `id`, `name`, `description`, and a `prompt` that gets prepended to the system message.
51
+ Rebuild the Space to deploy.
52
+
53
+ ## Repo layout
54
+
55
+ ```
56
+ /
57
+ ├── Dockerfile # multi-stage: Next build → Python+Node runtime
58
+ ├── start.sh # launches FastAPI (:8000) and Next.js (:7860)
59
+ ├── .env.example
60
+ ├── backend/
61
+ │ ├── pyproject.toml # uv-managed deps
62
+ │ ├── main.py # FastAPI app, /chat streams NDJSON
63
+ │ ├── rag.py # PyMuPDF4LLM → LlamaIndex → LanceDB
64
+ │ ├── hf_sync.py # Dataset upload/download/list/delete
65
+ │ └── auth.py # passcode cookie middleware
66
+ └── frontend/
67
+ ├── next.config.js # /api/* → http://127.0.0.1:8000/*
68
+ ├── package.json
69
+ ├── app/
70
+ │ ├── page.tsx # chat UI
71
+ │ ├── admin/page.tsx # upload + file list
72
+ │ └── admin/login/page.tsx
73
+ └── components/ # chat-interface, persona-selector, file-uploader, …
74
+ ```
backend/auth.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trivial passcode auth for the educator dashboard.
2
+
3
+ The frontend stores the passcode in an httpOnly cookie set by /api/admin/login.
4
+ Protected endpoints depend on `require_admin` to validate the cookie.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import secrets
10
+
11
+ from fastapi import Cookie, HTTPException, status
12
+
13
+ ADMIN_PASSCODE = os.environ.get("ADMIN_PASSCODE", "")
14
+ COOKIE_NAME = "admin_session"
15
+
16
+
17
+ def _expected_token() -> str:
18
+ """A deterministic-but-opaque token derived from the passcode.
19
+
20
+ Avoids storing the raw passcode in the cookie. We don't need full session
21
+ management here — the passcode itself is the credential.
22
+ """
23
+ if not ADMIN_PASSCODE:
24
+ return ""
25
+ # 32-char hex digest is plenty for a single-tenant educator template.
26
+ import hashlib
27
+
28
+ return hashlib.sha256(ADMIN_PASSCODE.encode("utf-8")).hexdigest()
29
+
30
+
31
+ def verify_passcode(passcode: str) -> bool:
32
+ if not ADMIN_PASSCODE:
33
+ return False
34
+ return secrets.compare_digest(passcode, ADMIN_PASSCODE)
35
+
36
+
37
+ def issue_token() -> str:
38
+ return _expected_token()
39
+
40
+
41
+ def require_admin(admin_session: str | None = Cookie(default=None)) -> None:
42
+ if not ADMIN_PASSCODE:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
45
+ detail="ADMIN_PASSCODE is not configured on the server.",
46
+ )
47
+ if not admin_session or not secrets.compare_digest(admin_session, _expected_token()):
48
+ raise HTTPException(
49
+ status_code=status.HTTP_401_UNAUTHORIZED,
50
+ detail="Admin authentication required.",
51
+ )
backend/hf_embedding.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ from huggingface_hub import InferenceClient
9
+ from huggingface_hub.errors import HfHubHTTPError, InferenceTimeoutError
10
+ from llama_index.core.base.embeddings.base import BaseEmbedding, Embedding
11
+ from llama_index.core.bridge.pydantic import Field, PrivateAttr
12
+ from llama_index.embeddings.huggingface_api.pooling import Pooling
13
+ from llama_index.utils.huggingface import format_query, format_text
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DEFAULT_MAX_CHARS = int(os.environ.get("HF_EMBED_MAX_CHARS", "3000"))
19
+ DEFAULT_MIN_CHARS = int(os.environ.get("HF_EMBED_MIN_CHARS", "750"))
20
+ DEFAULT_MAX_RETRIES = int(os.environ.get("HF_EMBED_RETRIES", "3"))
21
+ DEFAULT_BACKOFF_SECONDS = float(os.environ.get("HF_EMBED_RETRY_BACKOFF", "1.0"))
22
+
23
+
24
+ class SyncHuggingFaceInferenceEmbedding(BaseEmbedding):
25
+ """Sync-only embedding adapter for HF Inference API.
26
+
27
+ The upstream LlamaIndex HF wrapper uses AsyncInferenceClient internally even
28
+ for sync calls, which is brittle under uvicorn/asyncio. This adapter uses
29
+ only the regular InferenceClient, so indexing and retrieval can run safely
30
+ inside a worker thread.
31
+ """
32
+
33
+ pooling: Optional[Pooling] = Field(default=Pooling.CLS)
34
+ query_instruction: Optional[str] = Field(default=None)
35
+ text_instruction: Optional[str] = Field(default=None)
36
+ model_name: str = Field(default="BAAI/bge-small-en-v1.5")
37
+ token: Union[str, bool, None] = Field(default=None)
38
+ timeout: Optional[float] = Field(default=None)
39
+ headers: Optional[Dict[str, str]] = Field(default=None)
40
+ cookies: Optional[Dict[str, str]] = Field(default=None)
41
+ max_chars_per_request: int = Field(default=DEFAULT_MAX_CHARS, gt=0)
42
+ min_chars_per_request: int = Field(default=DEFAULT_MIN_CHARS, gt=0)
43
+ max_retries: int = Field(default=DEFAULT_MAX_RETRIES, ge=1)
44
+ retry_backoff_seconds: float = Field(default=DEFAULT_BACKOFF_SECONDS, ge=0.0)
45
+
46
+ _client: InferenceClient = PrivateAttr()
47
+
48
+ def __init__(self, **kwargs: Any) -> None:
49
+ super().__init__(**kwargs)
50
+ self._client = InferenceClient(
51
+ model=self.model_name,
52
+ token=self.token,
53
+ timeout=self.timeout,
54
+ headers=self.headers,
55
+ cookies=self.cookies,
56
+ )
57
+
58
+ @classmethod
59
+ def class_name(cls) -> str:
60
+ return "SyncHuggingFaceInferenceEmbedding"
61
+
62
+ @staticmethod
63
+ def _mean_pool_vectors(vectors: List[Embedding]) -> Embedding:
64
+ if not vectors:
65
+ raise ValueError("Cannot average an empty list of embeddings.")
66
+ if len(vectors) == 1:
67
+ return vectors[0]
68
+ return [sum(values) / len(values) for values in zip(*vectors)]
69
+
70
+ @staticmethod
71
+ def _split_text(text: str, max_chars: int) -> List[str]:
72
+ stripped = text.strip()
73
+ if len(stripped) <= max_chars:
74
+ return [stripped] if stripped else [" "]
75
+
76
+ paragraphs = [part.strip() for part in stripped.split("\n\n") if part.strip()]
77
+ if not paragraphs:
78
+ paragraphs = [stripped]
79
+
80
+ segments: List[str] = []
81
+ current = ""
82
+
83
+ for paragraph in paragraphs:
84
+ pieces = [paragraph[i : i + max_chars] for i in range(0, len(paragraph), max_chars)]
85
+ for piece in pieces:
86
+ candidate = piece if not current else f"{current}\n\n{piece}"
87
+ if len(candidate) <= max_chars:
88
+ current = candidate
89
+ else:
90
+ if current:
91
+ segments.append(current)
92
+ current = piece
93
+
94
+ if current:
95
+ segments.append(current)
96
+
97
+ return segments or [" "]
98
+
99
+ def _embed_request(self, text: str) -> Embedding:
100
+ embedding = self._client.feature_extraction(
101
+ text,
102
+ truncate=True,
103
+ truncation_direction="right",
104
+ )
105
+ if len(embedding.shape) == 1:
106
+ return embedding.tolist()
107
+
108
+ embedding = embedding.squeeze(axis=0)
109
+ if len(embedding.shape) == 1:
110
+ return embedding.tolist()
111
+
112
+ if self.pooling is None:
113
+ raise ValueError(
114
+ f"Pooling is required for {self.model_name} because it returned "
115
+ "a > 1-D value."
116
+ )
117
+
118
+ return self.pooling(embedding).tolist()
119
+
120
+ def _embed_with_retry(self, text: str) -> Embedding:
121
+ last_error: Exception | None = None
122
+
123
+ for attempt in range(1, self.max_retries + 1):
124
+ try:
125
+ return self._embed_request(text)
126
+ except (HfHubHTTPError, InferenceTimeoutError) as exc:
127
+ last_error = exc
128
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
129
+ retryable = isinstance(exc, InferenceTimeoutError) or status_code in (429, 500, 502, 503, 504)
130
+ if not retryable or attempt == self.max_retries:
131
+ break
132
+ delay = self.retry_backoff_seconds * (2 ** (attempt - 1))
133
+ logger.warning(
134
+ "HF embedding request failed for %s (status=%s, attempt %d/%d). Retrying in %.1fs.",
135
+ self.model_name,
136
+ status_code,
137
+ attempt,
138
+ self.max_retries,
139
+ delay,
140
+ )
141
+ time.sleep(delay)
142
+
143
+ if last_error is not None:
144
+ raise last_error
145
+ raise RuntimeError("Embedding request failed without an exception.")
146
+
147
+ def _embed_single(self, text: str, *, max_chars: Optional[int] = None) -> Embedding:
148
+ stripped = text.strip() or " "
149
+ current_max_chars = max_chars or self.max_chars_per_request
150
+ segments = self._split_text(stripped, current_max_chars)
151
+
152
+ if len(segments) > 1:
153
+ embeddings = [self._embed_single(segment, max_chars=current_max_chars) for segment in segments]
154
+ return self._mean_pool_vectors(embeddings)
155
+
156
+ try:
157
+ return self._embed_with_retry(segments[0])
158
+ except (HfHubHTTPError, InferenceTimeoutError) as exc:
159
+ if current_max_chars <= self.min_chars_per_request or len(stripped) <= self.min_chars_per_request:
160
+ raise exc
161
+
162
+ smaller_max_chars = max(self.min_chars_per_request, current_max_chars // 2)
163
+ if smaller_max_chars >= current_max_chars:
164
+ raise exc
165
+
166
+ logger.warning(
167
+ "HF embedding request for %s failed after retries. Falling back to smaller text segments (%d -> %d chars).",
168
+ self.model_name,
169
+ current_max_chars,
170
+ smaller_max_chars,
171
+ )
172
+ return self._embed_single(stripped, max_chars=smaller_max_chars)
173
+
174
+ def _get_query_embedding(self, query: str) -> Embedding:
175
+ return self._embed_single(
176
+ format_query(query, self.model_name, self.query_instruction)
177
+ )
178
+
179
+ async def _aget_query_embedding(self, query: str) -> Embedding:
180
+ return self._get_query_embedding(query)
181
+
182
+ def _get_text_embedding(self, text: str) -> Embedding:
183
+ return self._embed_single(
184
+ format_text(text, self.model_name, self.text_instruction)
185
+ )
186
+
187
+ async def _aget_text_embedding(self, text: str) -> Embedding:
188
+ return self._get_text_embedding(text)
backend/hf_sync.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Dataset acts as the persistent 'cloud drive' for uploaded PDFs.
2
+
3
+ Layout inside the dataset repo:
4
+ pdfs/<filename>.pdf <- raw PDFs (source of truth)
5
+ vector_cache/manifest.json <- vector cache metadata
6
+ vector_cache/index/** <- persisted LanceDB files
7
+
8
+ Local disk on HF Spaces is ephemeral, so we try to restore the vector cache
9
+ from the dataset first and only rebuild from PDFs when the cache is missing or
10
+ stale.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ from pathlib import Path
18
+ import shutil
19
+ from tempfile import TemporaryDirectory
20
+ from typing import List
21
+
22
+ from huggingface_hub import HfApi, hf_hub_download
23
+ from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ HF_TOKEN = os.environ.get("HF_TOKEN")
28
+ DATASET_ID = os.environ.get("DATASET_ID")
29
+ PDF_PREFIX = "pdfs/"
30
+ VECTOR_PREFIX = "vector_cache/"
31
+ VECTOR_INDEX_PREFIX = f"{VECTOR_PREFIX}index/"
32
+ VECTOR_MANIFEST_PATH = f"{VECTOR_PREFIX}manifest.json"
33
+ VECTOR_CACHE_SCHEMA_VERSION = 1
34
+
35
+
36
+ def is_configured() -> bool:
37
+ return bool(HF_TOKEN and DATASET_ID)
38
+
39
+
40
+ def _vector_manifest(pdf_filenames: List[str], embed_model: str) -> dict:
41
+ return {
42
+ "schema_version": VECTOR_CACHE_SCHEMA_VERSION,
43
+ "embed_model": embed_model,
44
+ "pdf_files": sorted(pdf_filenames),
45
+ }
46
+
47
+
48
+ def _api() -> HfApi:
49
+ if not HF_TOKEN or not DATASET_ID:
50
+ raise RuntimeError(
51
+ "HF_TOKEN and DATASET_ID must be set as environment variables / Space secrets."
52
+ )
53
+ return HfApi(token=HF_TOKEN)
54
+
55
+
56
+ def _ensure_dataset_exists(api: HfApi) -> None:
57
+ try:
58
+ api.repo_info(repo_id=DATASET_ID, repo_type="dataset")
59
+ except RepositoryNotFoundError:
60
+ try:
61
+ api.create_repo(
62
+ repo_id=DATASET_ID,
63
+ repo_type="dataset",
64
+ private=True,
65
+ exist_ok=True,
66
+ )
67
+ logger.info("Created dataset repo %s", DATASET_ID)
68
+ except Exception as exc: # noqa: BLE001
69
+ raise RuntimeError(
70
+ f"Dataset {DATASET_ID!r} does not exist and could not be created automatically. "
71
+ "Create it on Hugging Face or update DATASET_ID / HF_TOKEN permissions."
72
+ ) from exc
73
+
74
+
75
+ def list_remote_pdfs() -> List[str]:
76
+ """Return PDF filenames (basename only) currently stored in the dataset."""
77
+ api = _api()
78
+ try:
79
+ files = api.list_repo_files(repo_id=DATASET_ID, repo_type="dataset")
80
+ except RepositoryNotFoundError:
81
+ return []
82
+ return [
83
+ f[len(PDF_PREFIX):]
84
+ for f in files
85
+ if f.startswith(PDF_PREFIX) and f.lower().endswith(".pdf")
86
+ ]
87
+
88
+
89
+ def download_vector_store(
90
+ target_dir: Path,
91
+ expected_pdf_filenames: List[str],
92
+ embed_model: str,
93
+ ) -> bool:
94
+ """Restore the persisted LanceDB folder when its manifest matches the dataset PDFs."""
95
+ if not is_configured():
96
+ return False
97
+
98
+ api = _api()
99
+ try:
100
+ manifest_path = hf_hub_download(
101
+ repo_id=DATASET_ID,
102
+ repo_type="dataset",
103
+ filename=VECTOR_MANIFEST_PATH,
104
+ token=HF_TOKEN,
105
+ )
106
+ except (EntryNotFoundError, RepositoryNotFoundError):
107
+ logger.info("No vector cache manifest in dataset yet")
108
+ return False
109
+ except Exception as exc: # noqa: BLE001
110
+ logger.warning("Could not download vector cache manifest: %s", exc)
111
+ return False
112
+
113
+ try:
114
+ manifest = json.loads(Path(manifest_path).read_text("utf-8"))
115
+ except Exception as exc: # noqa: BLE001
116
+ logger.warning("Could not parse vector cache manifest: %s", exc)
117
+ return False
118
+
119
+ expected_manifest = _vector_manifest(expected_pdf_filenames, embed_model)
120
+ if manifest != expected_manifest:
121
+ logger.info("Vector cache manifest is stale; rebuilding from PDFs")
122
+ return False
123
+
124
+ try:
125
+ files = api.list_repo_files(repo_id=DATASET_ID, repo_type="dataset")
126
+ except RepositoryNotFoundError:
127
+ return False
128
+
129
+ vector_files = [f for f in files if f.startswith(VECTOR_INDEX_PREFIX)]
130
+ if not vector_files:
131
+ logger.info("Vector cache manifest exists but no cache files were found")
132
+ return False
133
+
134
+ shutil.rmtree(target_dir, ignore_errors=True)
135
+ target_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ for repo_path in vector_files:
138
+ relative_path = repo_path[len(VECTOR_INDEX_PREFIX):]
139
+ if not relative_path:
140
+ continue
141
+ downloaded = hf_hub_download(
142
+ repo_id=DATASET_ID,
143
+ repo_type="dataset",
144
+ filename=repo_path,
145
+ token=HF_TOKEN,
146
+ )
147
+ destination = target_dir / relative_path
148
+ destination.parent.mkdir(parents=True, exist_ok=True)
149
+ shutil.copy2(downloaded, destination)
150
+
151
+ logger.info("Vector cache restored from dataset (%d files)", len(vector_files))
152
+ return True
153
+
154
+
155
+ def delete_vector_store() -> bool:
156
+ """Remove the persisted vector cache folder from the dataset."""
157
+ if not is_configured():
158
+ return False
159
+
160
+ api = _api()
161
+ try:
162
+ api.delete_folder(
163
+ path_in_repo=VECTOR_PREFIX.rstrip("/"),
164
+ repo_id=DATASET_ID,
165
+ repo_type="dataset",
166
+ commit_message="Delete vector cache",
167
+ )
168
+ logger.info("Vector cache deleted from HF Dataset")
169
+ return True
170
+ except (EntryNotFoundError, RepositoryNotFoundError):
171
+ return False
172
+
173
+
174
+ def upload_vector_store(local_dir: Path, pdf_filenames: List[str], embed_model: str) -> bool:
175
+ """Persist the local LanceDB folder into the dataset along with a manifest."""
176
+ if not is_configured():
177
+ return False
178
+
179
+ api = _api()
180
+ _ensure_dataset_exists(api)
181
+
182
+ local_files = [p for p in local_dir.rglob("*") if p.is_file()]
183
+ if not pdf_filenames or not local_files:
184
+ delete_vector_store()
185
+ return bool(not pdf_filenames)
186
+
187
+ with TemporaryDirectory(prefix="iam-earth-vector-cache-") as tmp_dir:
188
+ stage_dir = Path(tmp_dir)
189
+ index_dir = stage_dir / "index"
190
+ shutil.copytree(local_dir, index_dir, dirs_exist_ok=True)
191
+ (stage_dir / "manifest.json").write_text(
192
+ json.dumps(_vector_manifest(pdf_filenames, embed_model), indent=2, ensure_ascii=False),
193
+ "utf-8",
194
+ )
195
+
196
+ api.upload_folder(
197
+ repo_id=DATASET_ID,
198
+ repo_type="dataset",
199
+ folder_path=stage_dir,
200
+ path_in_repo=VECTOR_PREFIX.rstrip("/"),
201
+ commit_message="Sync vector cache",
202
+ delete_patterns="**",
203
+ ignore_patterns=["**/.DS_Store"],
204
+ )
205
+
206
+ logger.info("Vector cache synced to HF Dataset")
207
+ return True
208
+
209
+
210
+ def download_all_pdfs(target_dir: Path) -> List[Path]:
211
+ """Download every PDF in the dataset into target_dir. Returns local paths."""
212
+ target_dir.mkdir(parents=True, exist_ok=True)
213
+ paths: List[Path] = []
214
+ for name in list_remote_pdfs():
215
+ try:
216
+ local = hf_hub_download(
217
+ repo_id=DATASET_ID,
218
+ repo_type="dataset",
219
+ filename=f"{PDF_PREFIX}{name}",
220
+ local_dir=str(target_dir),
221
+ token=HF_TOKEN,
222
+ )
223
+ paths.append(Path(local))
224
+ except EntryNotFoundError:
225
+ continue
226
+ return paths
227
+
228
+
229
+ def upload_pdf(local_path: Path, filename: str) -> None:
230
+ """Push a single PDF into the dataset under pdfs/<filename>."""
231
+ api = _api()
232
+ try:
233
+ _ensure_dataset_exists(api)
234
+ api.upload_file(
235
+ path_or_fileobj=str(local_path),
236
+ path_in_repo=f"{PDF_PREFIX}{filename}",
237
+ repo_id=DATASET_ID,
238
+ repo_type="dataset",
239
+ commit_message=f"Add {filename}",
240
+ )
241
+ except RuntimeError:
242
+ raise
243
+ except Exception as exc: # noqa: BLE001
244
+ raise RuntimeError(
245
+ f"Failed to upload {filename} to dataset {DATASET_ID!r}: {exc}"
246
+ ) from exc
247
+
248
+
249
+ def delete_pdf(filename: str) -> bool:
250
+ """Delete pdfs/<filename> from the dataset. Returns True if removed."""
251
+ api = _api()
252
+ try:
253
+ api.delete_file(
254
+ path_in_repo=f"{PDF_PREFIX}{filename}",
255
+ repo_id=DATASET_ID,
256
+ repo_type="dataset",
257
+ commit_message=f"Delete {filename}",
258
+ )
259
+ return True
260
+ except EntryNotFoundError:
261
+ return False
backend/main.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI entrypoint — runs internally on :8000, fronted by Next.js on :7860."""
2
+ from __future__ import annotations
3
+
4
+ # ── Load .env FIRST so all module-level os.environ.get() calls in local
5
+ # modules (auth, rag, hf_sync) pick up the values when they are imported.
6
+ import asyncio
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from dotenv import load_dotenv
11
+
12
+ _env_file = Path(__file__).parent / ".env"
13
+ if not _env_file.exists():
14
+ _env_file = Path(__file__).parent.parent / ".env"
15
+ load_dotenv(_env_file)
16
+
17
+ import logging
18
+ import shutil
19
+ import uuid
20
+ from contextlib import asynccontextmanager
21
+ from typing import Any, AsyncIterator, List, Literal, Optional
22
+
23
+ from fastapi import Depends, FastAPI, File, Form, HTTPException, Response, UploadFile
24
+ from fastapi.responses import StreamingResponse
25
+ from huggingface_hub import AsyncInferenceClient
26
+ from pydantic import BaseModel, Field
27
+
28
+ # Local (imported AFTER load_dotenv so their module-level env reads are correct)
29
+ import auth
30
+ import hf_sync
31
+ import personas_store
32
+ from rag import RagEngine
33
+
34
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
35
+ logger = logging.getLogger("backend")
36
+
37
+ _REPO_ROOT = Path(__file__).parent.parent
38
+ TMP_UPLOAD_DIR = Path(os.environ.get("TMP_UPLOAD_DIR", str(_REPO_ROOT / ".tmp_uploads")))
39
+ LANCEDB_DIR = Path(os.environ.get("LANCEDB_PATH", str(_REPO_ROOT / ".lancedb")))
40
+ LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")
41
+ LLM_MODEL_LOWER = LLM_MODEL.lower()
42
+ EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
43
+ HF_TOKEN = os.environ.get("HF_TOKEN")
44
+ LLM_EXTRA_BODY_JSON = os.environ.get("LLM_EXTRA_BODY_JSON", "").strip()
45
+ LLM_FINAL_ANSWER_EXTRA_BODY_JSON = os.environ.get("LLM_FINAL_ANSWER_EXTRA_BODY_JSON", "").strip()
46
+ SHOW_LLM_REASONING = os.environ.get("SHOW_LLM_REASONING", "").lower() in {"1", "true", "yes", "on"}
47
+ LLM_MAX_TOKENS = int(
48
+ os.environ.get(
49
+ "LLM_MAX_TOKENS",
50
+ "2048" if "qwen/qwen3" in LLM_MODEL_LOWER or "qwen3" in LLM_MODEL_LOWER else "1024",
51
+ )
52
+ )
53
+ LLM_FINAL_ANSWER_MAX_TOKENS = int(os.environ.get("LLM_FINAL_ANSWER_MAX_TOKENS", "768"))
54
+
55
+ # Globals populated in lifespan.
56
+ rag: Optional[RagEngine] = None
57
+ llm_client: Optional[AsyncInferenceClient] = None
58
+
59
+
60
+ def _load_json_object_env(raw: str, env_name: str) -> Optional[dict[str, Any]]:
61
+ if not raw:
62
+ return None
63
+ try:
64
+ loaded = json.loads(raw)
65
+ except json.JSONDecodeError as exc:
66
+ logger.warning("Ignoring invalid %s: %s", env_name, exc)
67
+ return None
68
+ if not isinstance(loaded, dict):
69
+ logger.warning("Ignoring %s because it is not a JSON object", env_name)
70
+ return None
71
+ return loaded
72
+
73
+
74
+ def _is_qwen_thinking_model() -> bool:
75
+ return "qwen/qwen3" in LLM_MODEL_LOWER or "qwen3" in LLM_MODEL_LOWER
76
+
77
+
78
+ def _non_thinking_extra_body() -> dict[str, Any]:
79
+ return {
80
+ "chat_template_kwargs": {"enable_thinking": False},
81
+ # Some HF router/provider combinations look for this at the top level.
82
+ "enable_thinking": False,
83
+ }
84
+
85
+
86
+ def _chat_extra_body() -> Optional[dict[str, Any]]:
87
+ configured = _load_json_object_env(LLM_EXTRA_BODY_JSON, "LLM_EXTRA_BODY_JSON")
88
+ if configured is not None:
89
+ return configured
90
+
91
+ if _is_qwen_thinking_model():
92
+ return _non_thinking_extra_body()
93
+
94
+ return None
95
+
96
+
97
+ def _final_answer_extra_body() -> Optional[dict[str, Any]]:
98
+ configured = _load_json_object_env(
99
+ LLM_FINAL_ANSWER_EXTRA_BODY_JSON,
100
+ "LLM_FINAL_ANSWER_EXTRA_BODY_JSON",
101
+ )
102
+ if configured is not None:
103
+ return configured
104
+
105
+ if _is_qwen_thinking_model():
106
+ return _non_thinking_extra_body()
107
+
108
+ return None
109
+
110
+
111
+ CHAT_EXTRA_BODY = _chat_extra_body()
112
+ FINAL_ANSWER_EXTRA_BODY = _final_answer_extra_body()
113
+
114
+
115
+ def _sync_vector_cache_sync() -> bool:
116
+ if not hf_sync.is_configured():
117
+ return False
118
+ try:
119
+ return hf_sync.upload_vector_store(LANCEDB_DIR, hf_sync.list_remote_pdfs(), EMBED_MODEL)
120
+ except Exception as exc: # noqa: BLE001
121
+ logger.warning("Vector cache sync failed: %s", exc)
122
+ return False
123
+
124
+
125
+ def _prepare_local_vector_cache_sync() -> tuple[bool, List[str]]:
126
+ """Restore the local LanceDB folder from the dataset when a valid cache exists."""
127
+ if not hf_sync.is_configured():
128
+ return False, []
129
+
130
+ remote_pdfs = hf_sync.list_remote_pdfs()
131
+ cache_restored = hf_sync.download_vector_store(LANCEDB_DIR, remote_pdfs, EMBED_MODEL)
132
+ if not cache_restored:
133
+ shutil.rmtree(LANCEDB_DIR, ignore_errors=True)
134
+ return cache_restored, remote_pdfs
135
+
136
+
137
+ def _cold_start_index_sync() -> None:
138
+ if rag is None:
139
+ return
140
+
141
+ sync_dir = TMP_UPLOAD_DIR / "_sync"
142
+ sync_dir.mkdir(parents=True, exist_ok=True)
143
+ paths = hf_sync.download_all_pdfs(sync_dir)
144
+ if paths:
145
+ logger.info("Cold-start: indexing %d PDF(s) from dataset", len(paths))
146
+ added = rag.index_many((p, p.name) for p in paths)
147
+ logger.info("Cold-start: %d nodes indexed", added)
148
+ _sync_vector_cache_sync()
149
+ else:
150
+ logger.info("Cold-start: dataset has no PDFs yet")
151
+ _sync_vector_cache_sync()
152
+
153
+
154
+ def _upload_and_index_sync(tmp_path: Path, safe_name: str) -> tuple[int, bool]:
155
+ if rag is None:
156
+ return 0, False
157
+
158
+ hf_sync.upload_pdf(tmp_path, safe_name)
159
+ nodes_added = rag.index_pdf(tmp_path, safe_name)
160
+ return nodes_added, _sync_vector_cache_sync()
161
+
162
+
163
+ def _delete_and_sync_sync(filename: str) -> tuple[bool, int, bool]:
164
+ removed = hf_sync.delete_pdf(filename)
165
+ nodes_removed = rag.delete_by_filename(filename) if rag else 0
166
+ cache_synced = _sync_vector_cache_sync() if removed or nodes_removed > 0 else False
167
+ return removed, nodes_removed, cache_synced
168
+
169
+
170
+ @asynccontextmanager
171
+ async def lifespan(app: FastAPI): # noqa: ARG001
172
+ global rag, llm_client
173
+ TMP_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
174
+
175
+ if not HF_TOKEN:
176
+ logger.warning("HF_TOKEN is not set — chat & embeddings will fail until configured.")
177
+
178
+ cache_restored = False
179
+ remote_pdfs: List[str] = []
180
+ if hf_sync.is_configured():
181
+ try:
182
+ cache_restored, remote_pdfs = await asyncio.to_thread(_prepare_local_vector_cache_sync)
183
+ except Exception as exc: # noqa: BLE001
184
+ logger.exception("Vector cache restore failed: %s", exc)
185
+
186
+ rag = RagEngine()
187
+ llm_client = AsyncInferenceClient(model=LLM_MODEL, token=HF_TOKEN)
188
+
189
+ # Load personas (HF Dataset → local file → bundled defaults).
190
+ personas_store.load()
191
+
192
+ # Cold-start sync: pull all PDFs from the linked dataset and rebuild the index.
193
+ try:
194
+ if cache_restored:
195
+ logger.info("Cold-start: restored vector cache from dataset for %d PDF(s)", len(remote_pdfs))
196
+ elif hf_sync.is_configured():
197
+ await asyncio.to_thread(_cold_start_index_sync)
198
+ except Exception as exc: # noqa: BLE001
199
+ logger.exception("Cold-start sync failed: %s", exc)
200
+
201
+ yield
202
+
203
+
204
+ app = FastAPI(title="IamEarthDev RAG", lifespan=lifespan)
205
+
206
+
207
+ # ---------------- Models ----------------
208
+ class ChatMessage(BaseModel):
209
+ role: Literal["system", "user", "assistant"]
210
+ content: str
211
+
212
+
213
+ class ChatRequest(BaseModel):
214
+ messages: List[ChatMessage]
215
+ persona_prompt: str = Field(default="", description="Persona-specific system prompt")
216
+ temperature: float = Field(default=0.4, ge=0.0, le=1.5)
217
+ top_k: int = Field(default=4, ge=1, le=10)
218
+
219
+
220
+ class LoginRequest(BaseModel):
221
+ passcode: str
222
+
223
+
224
+ class DeleteRequest(BaseModel):
225
+ filename: str
226
+
227
+
228
+ # ---------------- Health & files ----------------
229
+ @app.get("/health")
230
+ async def health() -> dict:
231
+ return {
232
+ "ok": True,
233
+ "model": LLM_MODEL,
234
+ "chat_extra_body": CHAT_EXTRA_BODY,
235
+ "final_answer_extra_body": FINAL_ANSWER_EXTRA_BODY,
236
+ "show_reasoning": SHOW_LLM_REASONING,
237
+ }
238
+
239
+
240
+ @app.get("/files")
241
+ async def list_files(_: None = Depends(auth.require_admin)) -> dict:
242
+ return {"files": hf_sync.list_remote_pdfs()}
243
+
244
+
245
+ # ---------------- Admin auth ----------------
246
+ @app.post("/admin/login")
247
+ async def admin_login(body: LoginRequest, response: Response) -> dict:
248
+ if not auth.verify_passcode(body.passcode):
249
+ raise HTTPException(status_code=401, detail="Invalid passcode")
250
+ response.set_cookie(
251
+ key=auth.COOKIE_NAME,
252
+ value=auth.issue_token(),
253
+ httponly=True,
254
+ samesite="lax",
255
+ secure=False, # HF Spaces serves over https at the edge; cookie still works.
256
+ max_age=60 * 60 * 12,
257
+ path="/",
258
+ )
259
+ return {"ok": True}
260
+
261
+
262
+ @app.post("/admin/logout")
263
+ async def admin_logout(response: Response) -> dict:
264
+ response.delete_cookie(auth.COOKIE_NAME, path="/")
265
+ return {"ok": True}
266
+
267
+
268
+ @app.get("/admin/me")
269
+ async def admin_me(_: None = Depends(auth.require_admin)) -> dict:
270
+ return {"authenticated": True}
271
+
272
+
273
+ # ---------------- Personas ----------------
274
+ class PersonaItem(BaseModel):
275
+ id: str
276
+ name: str
277
+ description: str
278
+ prompt: str
279
+
280
+
281
+ @app.get("/personas")
282
+ async def get_personas() -> dict:
283
+ """Public — students and the chat UI need this."""
284
+ return {"personas": personas_store.get_all()}
285
+
286
+
287
+ @app.put("/personas")
288
+ async def put_personas(
289
+ personas: List[PersonaItem],
290
+ _: None = Depends(auth.require_admin),
291
+ ) -> dict:
292
+ saved = personas_store.save([p.model_dump() for p in personas])
293
+ return {"personas": saved}
294
+
295
+
296
+ @app.post("/personas/reset")
297
+ async def reset_personas(_: None = Depends(auth.require_admin)) -> dict:
298
+ restored = personas_store.reset()
299
+ return {"personas": restored}
300
+
301
+
302
+ # ---------------- Upload / Delete ----------------
303
+ @app.post("/upload")
304
+ async def upload(
305
+ file: UploadFile = File(...),
306
+ _: None = Depends(auth.require_admin),
307
+ ) -> dict:
308
+ if not file.filename or not file.filename.lower().endswith(".pdf"):
309
+ raise HTTPException(status_code=400, detail="Only .pdf files are accepted.")
310
+
311
+ safe_name = Path(file.filename).name # strip any path components
312
+ tmp_path = TMP_UPLOAD_DIR / f"{uuid.uuid4().hex}_{safe_name}"
313
+ try:
314
+ with tmp_path.open("wb") as out:
315
+ shutil.copyfileobj(file.file, out)
316
+
317
+ try:
318
+ nodes_added, vector_cache_synced = await asyncio.to_thread(
319
+ _upload_and_index_sync, tmp_path, safe_name
320
+ )
321
+ except RuntimeError as exc:
322
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
323
+ except Exception as exc: # noqa: BLE001
324
+ logger.exception("Indexing failed for %s", safe_name)
325
+ raise HTTPException(
326
+ status_code=500,
327
+ detail=f"Uploaded {safe_name} to the dataset, but indexing failed: {exc}",
328
+ ) from exc
329
+ finally:
330
+ tmp_path.unlink(missing_ok=True)
331
+
332
+ return {
333
+ "ok": True,
334
+ "filename": safe_name,
335
+ "nodes_added": nodes_added,
336
+ "vector_cache_synced": vector_cache_synced,
337
+ }
338
+
339
+
340
+ @app.post("/delete")
341
+ async def delete(body: DeleteRequest, _: None = Depends(auth.require_admin)) -> dict:
342
+ safe_name = Path(body.filename).name
343
+ try:
344
+ removed, nodes_removed, vector_cache_synced = await asyncio.to_thread(
345
+ _delete_and_sync_sync, safe_name
346
+ )
347
+ except RuntimeError as exc:
348
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
349
+ if not removed and nodes_removed == 0:
350
+ raise HTTPException(status_code=404, detail=f"{safe_name} not found")
351
+ return {
352
+ "ok": True,
353
+ "filename": safe_name,
354
+ "nodes_removed": nodes_removed,
355
+ "vector_cache_synced": vector_cache_synced,
356
+ }
357
+
358
+
359
+ # ---------------- Chat (streaming) ----------------
360
+ SYSTEM_BASE = (
361
+ "You are a helpful course assistant for students. Use the selected persona "
362
+ "instructions to decide the tone, teaching style, level of directness, and "
363
+ "structure of your reply. Use the provided course material excerpts as your "
364
+ "primary source of truth. If the material does not contain the answer, say so "
365
+ "honestly. Always cite source filenames in square brackets when you use them."
366
+ )
367
+
368
+
369
+ def _build_messages(req: ChatRequest, context: str) -> List[dict]:
370
+ persona = req.persona_prompt.strip()
371
+ sys_parts = [SYSTEM_BASE]
372
+ if persona:
373
+ sys_parts.append(
374
+ "Selected persona instructions. Follow these for the response style; "
375
+ "only the grounding and citation rules above take priority:\n"
376
+ + persona
377
+ )
378
+ if context:
379
+ sys_parts.append(
380
+ "Course material excerpts (use these as your primary source of truth):\n\n"
381
+ + context
382
+ )
383
+ msgs: List[dict] = [{"role": "system", "content": "\n\n".join(sys_parts)}]
384
+ for m in req.messages:
385
+ if m.role == "system":
386
+ continue # we control the system prompt server-side
387
+ msgs.append({"role": m.role, "content": m.content})
388
+ return msgs
389
+
390
+
391
+ @app.post("/chat")
392
+ async def chat(req: ChatRequest) -> StreamingResponse:
393
+ if rag is None or llm_client is None:
394
+ raise HTTPException(status_code=503, detail="Backend not ready.")
395
+ if not req.messages:
396
+ raise HTTPException(status_code=400, detail="messages must not be empty.")
397
+
398
+ last_user = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
399
+ nodes = await asyncio.to_thread(rag.retrieve, last_user, req.top_k) if last_user else []
400
+ context = rag.format_context(nodes)
401
+ sources = sorted({n.node.metadata.get("source_filename", "unknown") for n in nodes})
402
+ messages = _build_messages(req, context)
403
+ final_answer_messages = [
404
+ *messages,
405
+ {
406
+ "role": "user",
407
+ "content": (
408
+ "You have already reasoned about this request. Now produce the "
409
+ "student-facing reply in the selected persona style, based on the "
410
+ "same course material excerpts. Do not repeat your thinking process. "
411
+ "Do not produce a thinking process, analysis section, or hidden reasoning. "
412
+ "Answer immediately and cite source filenames in square brackets."
413
+ ),
414
+ },
415
+ ]
416
+
417
+ async def stream() -> AsyncIterator[bytes]:
418
+ # Emit sources first as a single SSE-style event so the UI can render them.
419
+ yield (json.dumps({"type": "sources", "sources": sources}) + "\n").encode()
420
+ saw_content = False
421
+ saw_reasoning = False
422
+ finish_reason = None
423
+ try:
424
+ primary_stream = await llm_client.chat_completion(
425
+ messages=messages,
426
+ max_tokens=LLM_MAX_TOKENS,
427
+ temperature=req.temperature,
428
+ stream=True,
429
+ extra_body=CHAT_EXTRA_BODY,
430
+ )
431
+ async for chunk in primary_stream:
432
+ delta = ""
433
+ reasoning = ""
434
+ try:
435
+ finish_reason = chunk.choices[0].finish_reason or finish_reason
436
+ delta = chunk.choices[0].delta.content or ""
437
+ reasoning = getattr(chunk.choices[0].delta, "reasoning", "") or ""
438
+ except (AttributeError, IndexError):
439
+ pass
440
+ if delta:
441
+ saw_content = True
442
+ yield (json.dumps({"type": "delta", "text": delta}) + "\n").encode()
443
+ if reasoning:
444
+ saw_reasoning = True
445
+ if SHOW_LLM_REASONING:
446
+ yield (json.dumps({"type": "reasoning", "text": reasoning}) + "\n").encode()
447
+ elif not saw_content:
448
+ logger.info(
449
+ "Model emitted hidden reasoning before visible content; "
450
+ "switching to final-answer pass early."
451
+ )
452
+ close_stream = getattr(primary_stream, "close", None)
453
+ if callable(close_stream):
454
+ close_stream()
455
+ break
456
+
457
+ if not saw_content and saw_reasoning:
458
+ yield (
459
+ json.dumps(
460
+ {
461
+ "type": "phase",
462
+ "value": "finalizing",
463
+ "message": "Thinking completed. Generating the final answer.",
464
+ }
465
+ )
466
+ + "\n"
467
+ ).encode()
468
+ final_stream = await llm_client.chat_completion(
469
+ messages=final_answer_messages,
470
+ max_tokens=LLM_FINAL_ANSWER_MAX_TOKENS,
471
+ temperature=req.temperature,
472
+ stream=True,
473
+ extra_body=FINAL_ANSWER_EXTRA_BODY,
474
+ )
475
+ async for chunk in final_stream:
476
+ delta = ""
477
+ try:
478
+ delta = chunk.choices[0].delta.content or ""
479
+ except (AttributeError, IndexError):
480
+ pass
481
+ if delta:
482
+ saw_content = True
483
+ yield (json.dumps({"type": "delta", "text": delta}) + "\n").encode()
484
+
485
+ if not saw_content and saw_reasoning:
486
+ detail = "The model finished its reasoning but did not emit a final answer."
487
+ if finish_reason == "length":
488
+ detail += (
489
+ " It likely exhausted the completion budget while thinking. "
490
+ "Increase LLM_MAX_TOKENS if you want a longer first-pass reasoning budget."
491
+ )
492
+ yield (json.dumps({"type": "error", "message": detail}) + "\n").encode()
493
+ yield (json.dumps({"type": "done"}) + "\n").encode()
494
+ except Exception as exc: # noqa: BLE001
495
+ logger.exception("LLM stream failed")
496
+ yield (json.dumps({"type": "error", "message": str(exc)}) + "\n").encode()
497
+
498
+ return StreamingResponse(
499
+ stream(),
500
+ media_type="application/x-ndjson",
501
+ headers={
502
+ "X-Accel-Buffering": "no",
503
+ "Cache-Control": "no-cache",
504
+ "Connection": "keep-alive",
505
+ },
506
+ )
backend/personas.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "socratic",
4
+ "name": "Socratic Tutor",
5
+ "description": "Asks guiding questions instead of giving answers directly.",
6
+ "prompt": "Adopt the Socratic method. Rarely give a final answer outright; instead, ask one focused, open-ended question at a time that nudges the student toward the insight. Praise good reasoning, gently surface flawed assumptions."
7
+ },
8
+ {
9
+ "id": "strict_grader",
10
+ "name": "Strict Grader",
11
+ "description": "Rigorous, terse, and demanding of evidence.",
12
+ "prompt": "You are a strict grader. Be concise and uncompromising. Demand evidence from the course material for every claim. Point out logical gaps, missing citations, and imprecise terminology. Award no partial credit silently."
13
+ },
14
+ {
15
+ "id": "explainer",
16
+ "name": "Direct Explainer",
17
+ "description": "Plain, structured explanations with examples.",
18
+ "prompt": "Explain directly and clearly. Use short paragraphs, concrete examples, and analogies when helpful. End with a one-line summary. Never withhold the answer behind a question."
19
+ },
20
+ {
21
+ "id": "study_buddy",
22
+ "name": "Study Buddy",
23
+ "description": "Casual, encouraging, learns alongside the student.",
24
+ "prompt": "Speak as a friendly peer who is also studying this material. Be warm and encouraging, share confusion when it is genuine, and propose next steps the student could try together with you."
25
+ }
26
+ ]
backend/personas_store.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process persona store.
2
+
3
+ Load priority on startup:
4
+ 1. HF Dataset (personas.json) — when HF_TOKEN + DATASET_ID are set
5
+ 2. backend/personas.json — bundled defaults / local edits
6
+
7
+ Saves go to the local file and, when configured, to the HF Dataset.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ from pathlib import Path
15
+ from threading import Lock
16
+ from typing import List
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _BUNDLED = Path(__file__).parent / "personas.json"
21
+ _DATASET_FILENAME = "personas.json"
22
+ _lock = Lock()
23
+ _personas: List[dict] = []
24
+
25
+
26
+ def _read_bundled() -> List[dict]:
27
+ try:
28
+ return json.loads(_BUNDLED.read_text("utf-8"))
29
+ except Exception as exc: # noqa: BLE001
30
+ logger.warning("Could not read bundled personas.json: %s", exc)
31
+ return []
32
+
33
+
34
+ def load() -> None:
35
+ """Called once at startup. Tries HF Dataset first, falls back to local file."""
36
+ global _personas
37
+
38
+ # Try HF Dataset
39
+ hf_token = os.environ.get("HF_TOKEN")
40
+ dataset_id = os.environ.get("DATASET_ID")
41
+ if hf_token and dataset_id:
42
+ try:
43
+ from huggingface_hub import hf_hub_download
44
+ from huggingface_hub.utils import EntryNotFoundError
45
+
46
+ local = hf_hub_download(
47
+ repo_id=dataset_id,
48
+ repo_type="dataset",
49
+ filename=_DATASET_FILENAME,
50
+ token=hf_token,
51
+ )
52
+ data = json.loads(Path(local).read_text("utf-8"))
53
+ with _lock:
54
+ _personas = data
55
+ logger.info("Personas loaded from HF Dataset (%d items)", len(data))
56
+ return
57
+ except Exception as exc: # noqa: BLE001
58
+ logger.info("No personas.json in dataset yet (%s), using bundled.", exc)
59
+
60
+ # Fall back to bundled file
61
+ data = _read_bundled()
62
+ with _lock:
63
+ _personas = data
64
+ logger.info("Personas loaded from bundled file (%d items)", len(data))
65
+
66
+
67
+ def get_all() -> List[dict]:
68
+ with _lock:
69
+ return list(_personas)
70
+
71
+
72
+ def save(new_personas: List[dict]) -> List[dict]:
73
+ """Persist a new list of personas. Returns the saved list."""
74
+ with _lock:
75
+ global _personas
76
+ _personas = new_personas
77
+
78
+ # Write local file
79
+ try:
80
+ _BUNDLED.write_text(json.dumps(new_personas, indent=2, ensure_ascii=False), "utf-8")
81
+ logger.info("Personas saved to local file")
82
+ except Exception as exc: # noqa: BLE001
83
+ logger.warning("Could not write local personas.json: %s", exc)
84
+
85
+ # Sync to HF Dataset
86
+ hf_token = os.environ.get("HF_TOKEN")
87
+ dataset_id = os.environ.get("DATASET_ID")
88
+ if hf_token and dataset_id:
89
+ try:
90
+ from huggingface_hub import HfApi
91
+
92
+ api = HfApi(token=hf_token)
93
+ api.upload_file(
94
+ path_or_fileobj=json.dumps(new_personas, indent=2, ensure_ascii=False).encode(),
95
+ path_in_repo=_DATASET_FILENAME,
96
+ repo_id=dataset_id,
97
+ repo_type="dataset",
98
+ commit_message="Update personas",
99
+ )
100
+ logger.info("Personas synced to HF Dataset")
101
+ except Exception as exc: # noqa: BLE001
102
+ logger.warning("Could not sync personas to HF Dataset: %s", exc)
103
+
104
+ return new_personas
105
+
106
+
107
+ def reset() -> List[dict]:
108
+ """Reset to the bundled defaults and persist."""
109
+ defaults = _read_bundled()
110
+ return save(defaults)
backend/pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "iam-earth-dev-backend"
3
+ version = "0.1.0"
4
+ description = "FastAPI + LlamaIndex RAG backend for the IamEarthDev cloneable HF Space template"
5
+ requires-python = ">=3.13,<3.14"
6
+ dependencies = [
7
+ "fastapi>=0.115.0",
8
+ "uvicorn[standard]>=0.32.0",
9
+ "python-multipart>=0.0.12",
10
+ "pydantic>=2.9.0",
11
+ "python-dotenv>=1.0.1",
12
+ # RAG
13
+ "llama-index-core>=0.12.0",
14
+ "llama-index-vector-stores-lancedb>=0.3.0",
15
+ "llama-index-embeddings-huggingface-api>=0.3.0",
16
+ "lancedb>=0.16.0",
17
+ "pyarrow>=17.0.0",
18
+ "pandas>=2.2.0",
19
+ "pymupdf4llm>=0.0.17",
20
+ # HF
21
+ "huggingface-hub>=0.26.0",
22
+ ]
23
+
24
+ [tool.uv]
25
+ package = false
backend/rag.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG engine: PyMuPDF4LLM -> chunks -> HF-API embeddings -> LanceDB.
2
+
3
+ Designed for HF Spaces Free CPU Basic:
4
+ * No local embedding/LLM weights are downloaded.
5
+ * LanceDB lives on ephemeral disk locally, but its files are mirrored into
6
+ the linked HF Dataset so cold starts can restore the vector cache instead
7
+ of rebuilding from PDFs every time.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ from pathlib import Path
14
+ from threading import Lock
15
+ from typing import Iterable, List, Optional
16
+
17
+ import pymupdf4llm
18
+ from llama_index.core import Document, StorageContext, VectorStoreIndex, Settings
19
+ from llama_index.core.node_parser import MarkdownNodeParser
20
+ from llama_index.core.schema import MetadataMode, NodeWithScore, TextNode
21
+ from llama_index.vector_stores.lancedb import LanceDBVectorStore
22
+
23
+ from hf_embedding import SyncHuggingFaceInferenceEmbedding
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _REPO_ROOT = Path(__file__).parent.parent
28
+ LANCEDB_PATH = os.environ.get("LANCEDB_PATH", str(_REPO_ROOT / ".lancedb"))
29
+ TABLE_NAME = "documents"
30
+ EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
31
+ HF_TOKEN = os.environ.get("HF_TOKEN")
32
+
33
+ # Filename is stored on every node so we can delete by source.
34
+ FILENAME_KEY = "source_filename"
35
+
36
+
37
+ class RagEngine:
38
+ """Thread-safe singleton wrapper around the LanceDB-backed index."""
39
+
40
+ def __init__(self) -> None:
41
+ self._lock = Lock()
42
+ self._index: Optional[VectorStoreIndex] = None
43
+ self._vector_store: Optional[LanceDBVectorStore] = None
44
+
45
+ if not HF_TOKEN:
46
+ logger.warning(
47
+ "HF_TOKEN is not set — embedding calls will fail. "
48
+ "Set it in .env before uploading or querying documents."
49
+ )
50
+ Settings.embed_model = None # type: ignore[assignment]
51
+ else:
52
+ Settings.embed_model = SyncHuggingFaceInferenceEmbedding(
53
+ model_name=EMBED_MODEL,
54
+ token=HF_TOKEN,
55
+ )
56
+ Settings.llm = None # We call the LLM ourselves in main.py for streaming.
57
+ Settings.node_parser = MarkdownNodeParser()
58
+
59
+ Path(LANCEDB_PATH).mkdir(parents=True, exist_ok=True)
60
+ self._vector_store = LanceDBVectorStore(
61
+ uri=LANCEDB_PATH,
62
+ table_name=TABLE_NAME,
63
+ mode="overwrite" if not self._table_exists() else "append",
64
+ )
65
+
66
+ # ---------- internals ----------
67
+ def _table_exists(self) -> bool:
68
+ import lancedb
69
+
70
+ try:
71
+ db = lancedb.connect(LANCEDB_PATH)
72
+ return TABLE_NAME in db.table_names()
73
+ except Exception: # noqa: BLE001
74
+ return False
75
+
76
+ def _ensure_index(self) -> VectorStoreIndex:
77
+ if self._index is None:
78
+ storage = StorageContext.from_defaults(vector_store=self._vector_store)
79
+ if self._table_exists():
80
+ self._index = VectorStoreIndex.from_vector_store(
81
+ vector_store=self._vector_store,
82
+ storage_context=storage,
83
+ )
84
+ else:
85
+ self._index = VectorStoreIndex.from_documents(
86
+ [], storage_context=storage
87
+ )
88
+ return self._index
89
+
90
+ @staticmethod
91
+ def _pdf_to_documents(pdf_path: Path, filename: str) -> List[Document]:
92
+ try:
93
+ md_text = pymupdf4llm.to_markdown(str(pdf_path))
94
+ except Exception as exc: # noqa: BLE001
95
+ logger.warning("Failed to parse %s: %s", filename, exc)
96
+ return []
97
+ if not md_text.strip():
98
+ return []
99
+ return [
100
+ Document(
101
+ text=md_text,
102
+ metadata={FILENAME_KEY: filename},
103
+ excluded_llm_metadata_keys=[FILENAME_KEY],
104
+ excluded_embed_metadata_keys=[FILENAME_KEY],
105
+ )
106
+ ]
107
+
108
+ # ---------- public API ----------
109
+ def index_pdf(self, pdf_path: Path, filename: str) -> int:
110
+ """Parse a single PDF and insert its chunks. Returns # nodes added."""
111
+ with self._lock:
112
+ # First, drop any existing nodes for this filename (re-uploads).
113
+ self.delete_by_filename(filename, _locked=True)
114
+ docs = self._pdf_to_documents(pdf_path, filename)
115
+ if not docs:
116
+ return 0
117
+ index = self._ensure_index()
118
+ nodes = Settings.node_parser.get_nodes_from_documents(docs)
119
+ for n in nodes:
120
+ n.metadata[FILENAME_KEY] = filename
121
+ index.insert_nodes(nodes)
122
+ return len(nodes)
123
+
124
+ def index_many(self, items: Iterable[tuple[Path, str]]) -> int:
125
+ total = 0
126
+ for path, name in items:
127
+ total += self.index_pdf(path, name)
128
+ return total
129
+
130
+ def delete_by_filename(self, filename: str, *, _locked: bool = False) -> int:
131
+ """Remove all nodes with metadata.source_filename == filename."""
132
+ def _do() -> int:
133
+ import lancedb
134
+
135
+ if not self._table_exists():
136
+ return 0
137
+ db = lancedb.connect(LANCEDB_PATH)
138
+ tbl = db.open_table(TABLE_NAME)
139
+ # LanceDB stores metadata as a struct column called "metadata".
140
+ # Filter accesses nested fields with dot syntax.
141
+ try:
142
+ before = tbl.count_rows()
143
+ tbl.delete(f"metadata.{FILENAME_KEY} = '{filename}'")
144
+ removed = before - tbl.count_rows()
145
+ except Exception as exc: # noqa: BLE001
146
+ logger.warning("delete_by_filename failed: %s", exc)
147
+ removed = 0
148
+ # Force the index to be rebuilt next query.
149
+ self._index = None
150
+ return removed
151
+
152
+ if _locked:
153
+ return _do()
154
+ with self._lock:
155
+ return _do()
156
+
157
+ def retrieve(self, query: str, top_k: int = 4) -> List[NodeWithScore]:
158
+ with self._lock:
159
+ if not self._table_exists():
160
+ return []
161
+ index = self._ensure_index()
162
+ retriever = index.as_retriever(similarity_top_k=top_k)
163
+ return retriever.retrieve(query)
164
+
165
+ @staticmethod
166
+ def format_context(nodes: List[NodeWithScore]) -> str:
167
+ if not nodes:
168
+ return ""
169
+ chunks = []
170
+ for i, n in enumerate(nodes, 1):
171
+ src = n.node.metadata.get(FILENAME_KEY, "unknown")
172
+ text = n.node.get_content(metadata_mode=MetadataMode.NONE).strip()
173
+ chunks.append(f"[{i}] (source: {src})\n{text}")
174
+ return "\n\n---\n\n".join(chunks)
backend/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
frontend/.eslintrc.js ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ module.exports = {
3
+ extends: ["next/core-web-vitals"],
4
+ };
frontend/app/admin/login/page.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { Lock, Loader2 } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9
+
10
+ export default function LoginPage() {
11
+ const router = useRouter();
12
+ const [passcode, setPasscode] = useState("");
13
+ const [busy, setBusy] = useState(false);
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ async function submit(e: React.FormEvent) {
17
+ e.preventDefault();
18
+ setBusy(true);
19
+ setError(null);
20
+ try {
21
+ const r = await fetch("/api/admin/login", {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({ passcode }),
25
+ });
26
+ if (!r.ok) {
27
+ setError(r.status === 401 ? "Incorrect passcode." : await r.text());
28
+ return;
29
+ }
30
+ router.replace("/admin");
31
+ } catch (e) {
32
+ setError((e as Error).message);
33
+ } finally {
34
+ setBusy(false);
35
+ }
36
+ }
37
+
38
+ return (
39
+ <main className="flex h-full items-center justify-center p-6">
40
+ <Card className="w-full max-w-sm">
41
+ <CardHeader className="items-center text-center">
42
+ <div className="mx-auto mb-2 rounded-full bg-muted p-3">
43
+ <Lock className="h-5 w-5" />
44
+ </div>
45
+ <CardTitle>Educator login</CardTitle>
46
+ </CardHeader>
47
+ <CardContent>
48
+ <form onSubmit={submit} className="space-y-4">
49
+ <div className="space-y-2">
50
+ <Label htmlFor="passcode">Passcode</Label>
51
+ <Input
52
+ id="passcode"
53
+ type="password"
54
+ value={passcode}
55
+ onChange={(e) => setPasscode(e.target.value)}
56
+ autoFocus
57
+ />
58
+ </div>
59
+ {error && <p className="text-sm text-destructive">{error}</p>}
60
+ <Button type="submit" className="w-full" disabled={busy || !passcode}>
61
+ {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Sign in"}
62
+ </Button>
63
+ </form>
64
+ </CardContent>
65
+ </Card>
66
+ </main>
67
+ );
68
+ }
frontend/app/admin/page.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import Link from "next/link";
5
+ import { Loader2, LogOut } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { FileUploader } from "@/components/file-uploader";
9
+ import { FileList } from "@/components/file-list";
10
+ import { PersonaEditor } from "@/components/persona-editor";
11
+
12
+ export default function AdminPage() {
13
+ const router = useRouter();
14
+ const [checking, setChecking] = useState(true);
15
+ const [refreshKey, setRefreshKey] = useState(0);
16
+
17
+ useEffect(() => {
18
+ (async () => {
19
+ const r = await fetch("/api/admin/me");
20
+ if (!r.ok) router.replace("/admin/login");
21
+ else setChecking(false);
22
+ })();
23
+ }, [router]);
24
+
25
+ async function logout() {
26
+ await fetch("/api/admin/logout", { method: "POST" });
27
+ router.replace("/admin/login");
28
+ }
29
+
30
+ if (checking) {
31
+ return (
32
+ <div className="flex h-full items-center justify-center">
33
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
34
+ </div>
35
+ );
36
+ }
37
+
38
+ return (
39
+ <main className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
40
+ <header className="flex items-center justify-between">
41
+ <div>
42
+ <h1 className="text-xl font-semibold tracking-tight">Educator Dashboard</h1>
43
+ <p className="text-sm text-muted-foreground">
44
+ Upload PDFs to your linked Hugging Face Dataset.
45
+ </p>
46
+ </div>
47
+ <div className="flex items-center gap-2">
48
+ <Link
49
+ href="/"
50
+ className="text-sm text-muted-foreground underline-offset-4 hover:underline"
51
+ >
52
+ ← Chat
53
+ </Link>
54
+ <Button variant="ghost" size="sm" onClick={logout}>
55
+ <LogOut className="mr-1 h-4 w-4" /> Logout
56
+ </Button>
57
+ </div>
58
+ </header>
59
+
60
+ <Card>
61
+ <CardHeader>
62
+ <CardTitle>Upload PDFs</CardTitle>
63
+ </CardHeader>
64
+ <CardContent>
65
+ <FileUploader onUploaded={() => setRefreshKey((k) => k + 1)} />
66
+ </CardContent>
67
+ </Card>
68
+
69
+ <Card>
70
+ <CardContent className="pt-6">
71
+ <FileList refreshKey={refreshKey} />
72
+ </CardContent>
73
+ </Card>
74
+
75
+ <Card>
76
+ <CardContent className="pt-6">
77
+ <PersonaEditor />
78
+ </CardContent>
79
+ </Card>
80
+ </main>
81
+ );
82
+ }
frontend/app/api/chat/route.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest, NextResponse } from "next/server";
2
+
3
+ export const dynamic = "force-dynamic";
4
+ export const runtime = "nodejs";
5
+
6
+ export async function POST(request: NextRequest) {
7
+ const backend =
8
+ process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000";
9
+
10
+ let response: Response;
11
+ try {
12
+ response = await fetch(`${backend}/chat`, {
13
+ method: "POST",
14
+ body: request.body,
15
+ duplex: "half",
16
+ headers: {
17
+ "content-type": request.headers.get("content-type") ?? "application/json",
18
+ },
19
+ } as RequestInit & { duplex: "half" });
20
+ } catch {
21
+ return NextResponse.json(
22
+ { detail: "Could not reach the backend chat service." },
23
+ { status: 502 }
24
+ );
25
+ }
26
+
27
+ const headers = new Headers();
28
+ headers.set(
29
+ "content-type",
30
+ response.headers.get("content-type") ?? "application/x-ndjson"
31
+ );
32
+ headers.set("cache-control", "no-cache, no-transform");
33
+
34
+ return new Response(response.body, {
35
+ status: response.status,
36
+ statusText: response.statusText,
37
+ headers,
38
+ });
39
+ }
frontend/app/api/upload/route.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type NextRequest, NextResponse } from "next/server";
2
+
3
+ // This route handler replaces the rewrite proxy for /api/upload so that
4
+ // large PDF files (>10 MB) are forwarded to FastAPI without Next.js buffering
5
+ // the entire body before the 10 MB rewrite middleware limit kicks in.
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export async function POST(request: NextRequest) {
10
+ const backend =
11
+ process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000";
12
+
13
+ const formData = await request.formData();
14
+ const cookie = request.headers.get("cookie") ?? "";
15
+
16
+ let response: Response;
17
+ try {
18
+ response = await fetch(`${backend}/upload`, {
19
+ method: "POST",
20
+ body: formData,
21
+ headers: { cookie },
22
+ });
23
+ } catch {
24
+ return NextResponse.json(
25
+ { detail: "Could not reach the backend upload service." },
26
+ { status: 502 }
27
+ );
28
+ }
29
+
30
+ const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
31
+ const body = await response.text();
32
+
33
+ return new NextResponse(body, {
34
+ status: response.status,
35
+ headers: { "content-type": contentType },
36
+ });
37
+ }
frontend/app/globals.css ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 222 47% 11%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 222 47% 11%;
11
+ --primary: 222 47% 11%;
12
+ --primary-foreground: 210 40% 98%;
13
+ --muted: 210 40% 96%;
14
+ --muted-foreground: 215 16% 47%;
15
+ --accent: 210 40% 96%;
16
+ --accent-foreground: 222 47% 11%;
17
+ --destructive: 0 84% 60%;
18
+ --destructive-foreground: 210 40% 98%;
19
+ --border: 214 32% 91%;
20
+ --input: 214 32% 91%;
21
+ --ring: 222 84% 4%;
22
+ --radius: 0.6rem;
23
+ }
24
+
25
+ .dark {
26
+ --background: 222 47% 6%;
27
+ --foreground: 210 40% 98%;
28
+ --card: 222 47% 8%;
29
+ --card-foreground: 210 40% 98%;
30
+ --primary: 210 40% 98%;
31
+ --primary-foreground: 222 47% 11%;
32
+ --muted: 217 33% 17%;
33
+ --muted-foreground: 215 20% 65%;
34
+ --accent: 217 33% 17%;
35
+ --accent-foreground: 210 40% 98%;
36
+ --destructive: 0 63% 31%;
37
+ --destructive-foreground: 210 40% 98%;
38
+ --border: 217 33% 17%;
39
+ --input: 217 33% 17%;
40
+ --ring: 212 27% 84%;
41
+ }
42
+ }
43
+
44
+ @layer base {
45
+ * { @apply border-border; }
46
+ body { @apply bg-background text-foreground; }
47
+ }
frontend/app/layout.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./globals.css";
2
+ import type { Metadata } from "next";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "IamEarthDev — Course Assistant",
6
+ description: "RAG chatbot for course materials, hosted on Hugging Face Spaces.",
7
+ };
8
+
9
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
10
+ return (
11
+ <html lang="en" className="h-full">
12
+ <body className="h-full antialiased">{children}</body>
13
+ </html>
14
+ );
15
+ }
frontend/app/page.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import { ChatInterface } from "@/components/chat-interface";
3
+
4
+ export default function Page() {
5
+ return (
6
+ <main className="mx-auto flex h-full max-w-6xl flex-col gap-4 p-4 sm:p-6">
7
+ <header className="flex items-center justify-between">
8
+ <div>
9
+ <h1 className="text-xl font-semibold tracking-tight">Course Assistant</h1>
10
+ <p className="text-sm text-muted-foreground">
11
+ Ask questions grounded in the uploaded course materials.
12
+ </p>
13
+ </div>
14
+ <Link
15
+ href="/admin"
16
+ className="text-sm text-muted-foreground underline-offset-4 hover:underline"
17
+ >
18
+ Educator dashboard →
19
+ </Link>
20
+ </header>
21
+ <div className="min-h-0 flex-1">
22
+ <ChatInterface />
23
+ </div>
24
+ </main>
25
+ );
26
+ }
frontend/components/chat-interface.tsx ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useRef, useEffect } from "react";
3
+ import { Send, Loader2, BookOpen } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Card, CardContent } from "@/components/ui/card";
7
+ import { PersonaSelector } from "@/components/persona-selector";
8
+ import { TemperatureSlider } from "@/components/temperature-slider";
9
+ import { DEFAULT_PERSONAS, DEFAULT_PERSONA_ID, fetchPersonas, type Persona } from "@/lib/personas";
10
+
11
+ type Msg = {
12
+ role: "user" | "assistant";
13
+ content: string;
14
+ reasoning?: string;
15
+ sources?: string[];
16
+ status?: string;
17
+ };
18
+
19
+ function latestThinkingPreview(reasoning: string) {
20
+ const text = reasoning.trim();
21
+ if (!text) return "";
22
+ return text.length > 420 ? `...${text.slice(-420)}` : text;
23
+ }
24
+
25
+ export function ChatInterface() {
26
+ const [personas, setPersonas] = useState<Persona[]>(DEFAULT_PERSONAS);
27
+ const [persona, setPersona] = useState<Persona>(
28
+ DEFAULT_PERSONAS.find((p) => p.id === DEFAULT_PERSONA_ID) ?? DEFAULT_PERSONAS[0]
29
+ );
30
+ const [temperature, setTemperature] = useState(0.4);
31
+ const [messages, setMessages] = useState<Msg[]>([]);
32
+ const [input, setInput] = useState("");
33
+ const [busy, setBusy] = useState(false);
34
+ const scrollRef = useRef<HTMLDivElement>(null);
35
+
36
+ // Load live personas on mount
37
+ useEffect(() => {
38
+ fetchPersonas().then((list) => {
39
+ setPersonas(list);
40
+ // Re-select same id if it still exists, otherwise pick default
41
+ setPersona((prev) => list.find((p) => p.id === prev.id) ?? list[0] ?? prev);
42
+ });
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
47
+ }, [messages, busy]);
48
+
49
+ async function send() {
50
+ const text = input.trim();
51
+ if (!text || busy) return;
52
+ setInput("");
53
+ const next: Msg[] = [
54
+ ...messages,
55
+ { role: "user", content: text },
56
+ { role: "assistant", content: "" },
57
+ ];
58
+ setMessages(next);
59
+ setBusy(true);
60
+
61
+ try {
62
+ const resp = await fetch("/api/chat", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ messages: next
67
+ .slice(0, -1)
68
+ .map(({ role, content }) => ({ role, content })),
69
+ persona_prompt: persona.prompt,
70
+ temperature,
71
+ }),
72
+ });
73
+
74
+ if (!resp.ok) {
75
+ const message = (await resp.text()).trim() || `HTTP ${resp.status}`;
76
+ throw new Error(message);
77
+ }
78
+
79
+ if (!resp.body) throw new Error("No response stream");
80
+ const reader = resp.body.getReader();
81
+ const decoder = new TextDecoder();
82
+ let buf = "";
83
+ let acc = "";
84
+ let reasoning = "";
85
+ let sources: string[] | undefined;
86
+ let status: string | undefined;
87
+
88
+ while (true) {
89
+ const { done, value } = await reader.read();
90
+ if (done) break;
91
+ buf += decoder.decode(value, { stream: true });
92
+ const lines = buf.split("\n");
93
+ buf = lines.pop() ?? "";
94
+ for (const line of lines) {
95
+ if (!line.trim()) continue;
96
+ try {
97
+ const evt = JSON.parse(line);
98
+ if (evt.type === "delta") {
99
+ acc += evt.text;
100
+ status = undefined;
101
+ } else if (evt.type === "reasoning") {
102
+ reasoning += evt.text;
103
+ } else if (evt.type === "phase") {
104
+ status = evt.message || evt.value;
105
+ } else if (evt.type === "sources") {
106
+ sources = evt.sources;
107
+ } else if (evt.type === "error") {
108
+ acc += `\n\n*[error: ${evt.message}]*`;
109
+ }
110
+ setMessages((prev) => {
111
+ const copy = [...prev];
112
+ copy[copy.length - 1] = {
113
+ role: "assistant",
114
+ content: acc,
115
+ reasoning,
116
+ sources,
117
+ status,
118
+ };
119
+ return copy;
120
+ });
121
+ } catch {
122
+ // partial JSON, ignore
123
+ }
124
+ }
125
+ }
126
+ } catch (e) {
127
+ setMessages((prev) => {
128
+ const copy = [...prev];
129
+ copy[copy.length - 1] = {
130
+ role: "assistant",
131
+ content: `*Failed to reach the model: ${(e as Error).message}*`,
132
+ };
133
+ return copy;
134
+ });
135
+ } finally {
136
+ setBusy(false);
137
+ }
138
+ }
139
+
140
+ return (
141
+ <div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
142
+ <Card className="flex flex-col overflow-hidden">
143
+ <div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-6">
144
+ {messages.length === 0 && (
145
+ <div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground">
146
+ <BookOpen className="mb-3 h-10 w-10 opacity-50" />
147
+ <p className="text-sm">
148
+ Ask a question about your course material to get started.
149
+ </p>
150
+ </div>
151
+ )}
152
+ {messages.map((m, i) => {
153
+ const isStreamingAssistant =
154
+ busy && i === messages.length - 1 && m.role === "assistant";
155
+ const thinkingPreview =
156
+ isStreamingAssistant && !m.content && m.reasoning
157
+ ? latestThinkingPreview(m.reasoning)
158
+ : "";
159
+
160
+ return (
161
+ <div
162
+ key={i}
163
+ className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}
164
+ >
165
+ <div
166
+ className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
167
+ m.role === "user"
168
+ ? "bg-primary text-primary-foreground"
169
+ : "bg-muted text-foreground"
170
+ }`}
171
+ >
172
+ <div className="whitespace-pre-wrap">
173
+ {m.content ||
174
+ thinkingPreview ||
175
+ (isStreamingAssistant ? "Thinking…" : "")}
176
+ </div>
177
+ {thinkingPreview && (
178
+ <div className="mt-2 text-xs italic opacity-70">
179
+ Thinking stream
180
+ </div>
181
+ )}
182
+ {m.reasoning && (
183
+ <details className="mt-3 rounded-md border border-border/60 bg-background/60 p-3 text-xs text-muted-foreground">
184
+ <summary className="cursor-pointer select-none font-medium text-foreground">
185
+ Thinking
186
+ </summary>
187
+ <div className="mt-2 whitespace-pre-wrap leading-relaxed">
188
+ {m.reasoning}
189
+ </div>
190
+ </details>
191
+ )}
192
+ {m.status && (
193
+ <div className="mt-2 text-xs italic opacity-80">{m.status}</div>
194
+ )}
195
+ {m.sources && m.sources.length > 0 && (
196
+ <div className="mt-2 border-t border-border/50 pt-2 text-xs opacity-80">
197
+ <span className="font-medium">Sources: </span>
198
+ {m.sources.join(", ")}
199
+ </div>
200
+ )}
201
+ </div>
202
+ </div>
203
+ );
204
+ })}
205
+ </div>
206
+ <div className="border-t p-4">
207
+ <form
208
+ className="flex gap-2"
209
+ onSubmit={(e) => {
210
+ e.preventDefault();
211
+ send();
212
+ }}
213
+ >
214
+ <Input
215
+ placeholder="Ask about the course material…"
216
+ value={input}
217
+ onChange={(e) => setInput(e.target.value)}
218
+ disabled={busy}
219
+ />
220
+ <Button type="submit" disabled={busy || !input.trim()} size="icon">
221
+ {busy ? (
222
+ <Loader2 className="h-4 w-4 animate-spin" />
223
+ ) : (
224
+ <Send className="h-4 w-4" />
225
+ )}
226
+ </Button>
227
+ </form>
228
+ </div>
229
+ </Card>
230
+
231
+ <Card>
232
+ <CardContent className="space-y-6 p-6">
233
+ <div className="space-y-2">
234
+ <div className="text-sm font-medium">Persona</div>
235
+ <PersonaSelector personas={personas} value={persona.id} onChange={setPersona} />
236
+ <p className="text-xs text-muted-foreground">{persona.description}</p>
237
+ </div>
238
+ <TemperatureSlider value={temperature} onChange={setTemperature} />
239
+ </CardContent>
240
+ </Card>
241
+ </div>
242
+ );
243
+ }
frontend/components/file-list.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState, useCallback } from "react";
3
+ import { Trash2, FileText, RefreshCw } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ export function FileList({ refreshKey }: { refreshKey: number }) {
7
+ const [files, setFiles] = useState<string[] | null>(null);
8
+ const [error, setError] = useState<string | null>(null);
9
+ const [deleting, setDeleting] = useState<string | null>(null);
10
+
11
+ const load = useCallback(async () => {
12
+ setError(null);
13
+ try {
14
+ const r = await fetch("/api/files");
15
+ if (!r.ok) throw new Error(await r.text());
16
+ const data = await r.json();
17
+ setFiles(data.files ?? []);
18
+ } catch (e) {
19
+ setError((e as Error).message);
20
+ }
21
+ }, []);
22
+
23
+ useEffect(() => {
24
+ load();
25
+ }, [load, refreshKey]);
26
+
27
+ async function remove(name: string) {
28
+ if (!confirm(`Delete ${name}? This removes it from the dataset and the index.`))
29
+ return;
30
+ setDeleting(name);
31
+ try {
32
+ const r = await fetch("/api/delete", {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ filename: name }),
36
+ });
37
+ if (!r.ok) throw new Error(await r.text());
38
+ await load();
39
+ } catch (e) {
40
+ setError((e as Error).message);
41
+ } finally {
42
+ setDeleting(null);
43
+ }
44
+ }
45
+
46
+ return (
47
+ <div className="space-y-3">
48
+ <div className="flex items-center justify-between">
49
+ <h2 className="text-sm font-semibold">Course materials</h2>
50
+ <Button variant="ghost" size="sm" onClick={load}>
51
+ <RefreshCw className="mr-1 h-3.5 w-3.5" /> Refresh
52
+ </Button>
53
+ </div>
54
+ {error && <p className="text-sm text-destructive">{error}</p>}
55
+ {files === null ? (
56
+ <p className="text-sm text-muted-foreground">Loading…</p>
57
+ ) : files.length === 0 ? (
58
+ <p className="text-sm text-muted-foreground">No files uploaded yet.</p>
59
+ ) : (
60
+ <ul className="divide-y rounded-lg border">
61
+ {files.map((name) => (
62
+ <li
63
+ key={name}
64
+ className="flex items-center justify-between gap-3 px-4 py-2.5"
65
+ >
66
+ <div className="flex min-w-0 items-center gap-2">
67
+ <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
68
+ <span className="truncate text-sm">{name}</span>
69
+ </div>
70
+ <Button
71
+ variant="ghost"
72
+ size="sm"
73
+ onClick={() => remove(name)}
74
+ disabled={deleting === name}
75
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
76
+ >
77
+ <Trash2 className="h-4 w-4" />
78
+ </Button>
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ )}
83
+ </div>
84
+ );
85
+ }
frontend/components/file-uploader.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useCallback, useState } from "react";
3
+ import { useDropzone } from "react-dropzone";
4
+ import { UploadCloud, Loader2 } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export function FileUploader({ onUploaded }: { onUploaded: () => void }) {
8
+ const [busy, setBusy] = useState(false);
9
+ const [error, setError] = useState<string | null>(null);
10
+ const [progress, setProgress] = useState<string | null>(null);
11
+
12
+ const upload = useCallback(
13
+ async (files: File[]) => {
14
+ setError(null);
15
+ setBusy(true);
16
+ try {
17
+ for (let i = 0; i < files.length; i++) {
18
+ const f = files[i];
19
+ setProgress(`Uploading ${i + 1}/${files.length}: ${f.name}`);
20
+ const fd = new FormData();
21
+ fd.append("file", f);
22
+ const r = await fetch("/api/upload", { method: "POST", body: fd });
23
+ if (!r.ok) {
24
+ const contentType = r.headers.get("content-type") ?? "";
25
+ let message = "Upload failed";
26
+
27
+ if (contentType.includes("application/json")) {
28
+ const payload = (await r.json().catch(() => null)) as
29
+ | { detail?: string; error?: string }
30
+ | null;
31
+ message = payload?.detail ?? payload?.error ?? message;
32
+ } else {
33
+ message = (await r.text()).trim() || message;
34
+ }
35
+
36
+ throw new Error(`${f.name}: ${message}`);
37
+ }
38
+ }
39
+ onUploaded();
40
+ } catch (e) {
41
+ setError((e as Error).message);
42
+ } finally {
43
+ setBusy(false);
44
+ setProgress(null);
45
+ }
46
+ },
47
+ [onUploaded]
48
+ );
49
+
50
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
51
+ accept: { "application/pdf": [".pdf"] },
52
+ multiple: true,
53
+ disabled: busy,
54
+ onDrop: upload,
55
+ });
56
+
57
+ return (
58
+ <div className="space-y-2">
59
+ <div
60
+ {...getRootProps()}
61
+ className={cn(
62
+ "flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 text-center transition-colors",
63
+ isDragActive ? "border-primary bg-accent" : "border-border hover:bg-accent/50",
64
+ busy && "pointer-events-none opacity-60"
65
+ )}
66
+ >
67
+ <input {...getInputProps()} />
68
+ {busy ? (
69
+ <Loader2 className="mb-2 h-8 w-8 animate-spin text-muted-foreground" />
70
+ ) : (
71
+ <UploadCloud className="mb-2 h-8 w-8 text-muted-foreground" />
72
+ )}
73
+ <p className="text-sm font-medium">
74
+ {busy
75
+ ? progress ?? "Uploading…"
76
+ : isDragActive
77
+ ? "Drop the PDFs here"
78
+ : "Drag & drop PDFs here, or click to select"}
79
+ </p>
80
+ <p className="mt-1 text-xs text-muted-foreground">
81
+ Files are pushed to your linked Hugging Face Dataset and indexed automatically.
82
+ </p>
83
+ </div>
84
+ {error && <p className="text-sm text-destructive">{error}</p>}
85
+ </div>
86
+ );
87
+ }
frontend/components/persona-editor.tsx ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState, useRef } from "react";
3
+ import { Plus, Trash2, RotateCcw, Save, ChevronDown, ChevronUp, Pencil } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from "@/components/ui/dialog";
16
+ import { type Persona } from "@/lib/personas";
17
+
18
+ function uid() {
19
+ return Math.random().toString(36).slice(2, 10);
20
+ }
21
+
22
+ // ── Confirm dialog ────────────────────────────────────────────────────────────
23
+ function ConfirmDialog({
24
+ trigger,
25
+ title,
26
+ description,
27
+ confirmLabel = "Confirm",
28
+ destructive = false,
29
+ onConfirm,
30
+ }: {
31
+ trigger: React.ReactNode;
32
+ title: string;
33
+ description: string;
34
+ confirmLabel?: string;
35
+ destructive?: boolean;
36
+ onConfirm: () => void;
37
+ }) {
38
+ const [open, setOpen] = useState(false);
39
+ return (
40
+ <Dialog open={open} onOpenChange={setOpen}>
41
+ <DialogTrigger asChild>{trigger}</DialogTrigger>
42
+ <DialogContent className="max-w-sm">
43
+ <DialogHeader>
44
+ <DialogTitle>{title}</DialogTitle>
45
+ <DialogDescription>{description}</DialogDescription>
46
+ </DialogHeader>
47
+ <DialogFooter className="gap-2">
48
+ <Button variant="outline" size="sm" onClick={() => setOpen(false)}>
49
+ Cancel
50
+ </Button>
51
+ <Button
52
+ variant={destructive ? "destructive" : "default"}
53
+ size="sm"
54
+ onClick={() => {
55
+ setOpen(false);
56
+ onConfirm();
57
+ }}
58
+ >
59
+ {confirmLabel}
60
+ </Button>
61
+ </DialogFooter>
62
+ </DialogContent>
63
+ </Dialog>
64
+ );
65
+ }
66
+
67
+ // ── Per-persona card ──────────────────────────────────────────────────────────
68
+ type SaveState = "idle" | "editing" | "saving" | "saved" | "error";
69
+
70
+ function PersonaCard({
71
+ persona,
72
+ onSave,
73
+ onDelete,
74
+ }: {
75
+ persona: Persona;
76
+ onSave: (p: Persona) => Promise<void>;
77
+ onDelete: () => void;
78
+ }) {
79
+ const [open, setOpen] = useState(false);
80
+ const [draft, setDraft] = useState<Persona>(persona);
81
+ const [state, setState] = useState<SaveState>("idle");
82
+ const [errorMsg, setErrorMsg] = useState<string | null>(null);
83
+
84
+ // Sync when parent updates (e.g. after reset)
85
+ const prevId = useRef(persona.id);
86
+ useEffect(() => {
87
+ if (persona.id !== prevId.current || state === "idle") {
88
+ setDraft(persona);
89
+ prevId.current = persona.id;
90
+ }
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [persona]);
93
+
94
+ const isDirty = JSON.stringify(draft) !== JSON.stringify(persona);
95
+
96
+ function change(field: keyof Persona, value: string) {
97
+ setDraft((d) => ({ ...d, [field]: value }));
98
+ setState("editing");
99
+ }
100
+
101
+ async function save() {
102
+ setState("saving");
103
+ setErrorMsg(null);
104
+ try {
105
+ await onSave(draft);
106
+ setState("saved");
107
+ setTimeout(() => setState("idle"), 2500);
108
+ } catch (e) {
109
+ setErrorMsg((e as Error).message);
110
+ setState("error");
111
+ }
112
+ }
113
+
114
+ return (
115
+ <div className="rounded-lg border bg-card">
116
+ {/* Header row */}
117
+ <div className="flex items-center gap-2 px-4 py-3">
118
+ <button
119
+ type="button"
120
+ className="flex flex-1 items-center justify-between text-left text-sm font-medium"
121
+ onClick={() => setOpen((o) => !o)}
122
+ >
123
+ <span className={draft.name ? "" : "italic text-muted-foreground"}>
124
+ {draft.name || "Unnamed"}
125
+ </span>
126
+ {open ? (
127
+ <ChevronUp className="h-4 w-4 shrink-0" />
128
+ ) : (
129
+ <ChevronDown className="h-4 w-4 shrink-0" />
130
+ )}
131
+ </button>
132
+
133
+ <ConfirmDialog
134
+ trigger={
135
+ <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive">
136
+ <Trash2 className="h-3.5 w-3.5" />
137
+ </Button>
138
+ }
139
+ title="Delete persona"
140
+ description={`Remove "${draft.name || "this persona"}" permanently? Students will no longer see it.`}
141
+ confirmLabel="Delete"
142
+ destructive
143
+ onConfirm={onDelete}
144
+ />
145
+ </div>
146
+
147
+ {/* Expandable fields */}
148
+ {open && (
149
+ <div className="space-y-3 border-t px-4 py-4">
150
+ <div className="space-y-1">
151
+ <Label>Name</Label>
152
+ <Input
153
+ value={draft.name}
154
+ onChange={(e) => change("name", e.target.value)}
155
+ placeholder="e.g. Socratic Tutor"
156
+ />
157
+ </div>
158
+ <div className="space-y-1">
159
+ <Label>Short description</Label>
160
+ <Input
161
+ value={draft.description}
162
+ onChange={(e) => change("description", e.target.value)}
163
+ placeholder="One sentence shown in the dropdown"
164
+ />
165
+ </div>
166
+ <div className="space-y-1">
167
+ <Label>System prompt</Label>
168
+ <textarea
169
+ className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
170
+ value={draft.prompt}
171
+ onChange={(e) => change("prompt", e.target.value)}
172
+ placeholder="Instructions appended to the system message…"
173
+ />
174
+ </div>
175
+
176
+ {errorMsg && <p className="text-xs text-destructive">{errorMsg}</p>}
177
+
178
+ <Button
179
+ size="sm"
180
+ className="w-full"
181
+ onClick={save}
182
+ disabled={!isDirty || state === "saving"}
183
+ >
184
+ <Save className="mr-1 h-3.5 w-3.5" />
185
+ {state === "saving"
186
+ ? "Saving…"
187
+ : state === "saved"
188
+ ? "Saved!"
189
+ : isDirty
190
+ ? "Save changes"
191
+ : "No changes"}
192
+ </Button>
193
+ </div>
194
+ )}
195
+ </div>
196
+ );
197
+ }
198
+
199
+ // ── Main editor ───────────────────────────────────────────────────────────────
200
+ export function PersonaEditor() {
201
+ const [personas, setPersonas] = useState<Persona[]>([]);
202
+
203
+ async function load() {
204
+ try {
205
+ const r = await fetch("/api/personas");
206
+ const d = await r.json();
207
+ setPersonas(d.personas ?? []);
208
+ } catch {}
209
+ }
210
+
211
+ useEffect(() => { load(); }, []);
212
+
213
+ // Save a single updated persona
214
+ async function saveOne(updated: Persona) {
215
+ const next = personas.map((p) => (p.id === updated.id ? updated : p));
216
+ const r = await fetch("/api/personas", {
217
+ method: "PUT",
218
+ headers: { "Content-Type": "application/json" },
219
+ body: JSON.stringify(next),
220
+ });
221
+ if (!r.ok) throw new Error(await r.text());
222
+ const data = await r.json();
223
+ setPersonas(data.personas);
224
+ }
225
+
226
+ function remove(id: string) {
227
+ const next = personas.filter((p) => p.id !== id);
228
+ fetch("/api/personas", {
229
+ method: "PUT",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify(next),
232
+ })
233
+ .then((r) => r.json())
234
+ .then((d) => setPersonas(d.personas ?? next))
235
+ .catch(() => setPersonas(next));
236
+ }
237
+
238
+ function addNew() {
239
+ setPersonas((prev) => [
240
+ ...prev,
241
+ { id: uid(), name: "", description: "", prompt: "" },
242
+ ]);
243
+ }
244
+
245
+ async function reset() {
246
+ const r = await fetch("/api/personas/reset", { method: "POST" });
247
+ if (!r.ok) throw new Error(await r.text());
248
+ const data = await r.json();
249
+ setPersonas(data.personas);
250
+ }
251
+
252
+ return (
253
+ <div className="space-y-3">
254
+ <div className="flex items-center justify-between">
255
+ <h2 className="text-sm font-semibold">Personas</h2>
256
+ <ConfirmDialog
257
+ trigger={
258
+ <Button variant="ghost" size="sm" type="button">
259
+ <RotateCcw className="mr-1 h-3.5 w-3.5" /> Reset to defaults
260
+ </Button>
261
+ }
262
+ title="Reset personas?"
263
+ description="All custom personas will be replaced with the bundled defaults. This cannot be undone."
264
+ confirmLabel="Reset"
265
+ destructive
266
+ onConfirm={reset}
267
+ />
268
+ </div>
269
+ <p className="text-xs text-muted-foreground">
270
+ Each card saves individually. Changes are written to{" "}
271
+ <code className="rounded bg-muted px-1">personas.json</code> and synced
272
+ to your HF Dataset.
273
+ </p>
274
+
275
+ <div className="space-y-2">
276
+ {personas.map((p) => (
277
+ <PersonaCard
278
+ key={p.id}
279
+ persona={p}
280
+ onSave={saveOne}
281
+ onDelete={() => remove(p.id)}
282
+ />
283
+ ))}
284
+ </div>
285
+
286
+ <Button
287
+ variant="outline"
288
+ size="sm"
289
+ type="button"
290
+ className="w-full"
291
+ onClick={addNew}
292
+ >
293
+ <Plus className="mr-1 h-4 w-4" /> Add persona
294
+ </Button>
295
+ </div>
296
+ );
297
+ }
frontend/components/persona-selector.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+ } from "@/components/ui/select";
9
+ import { type Persona } from "@/lib/personas";
10
+
11
+ export function PersonaSelector({
12
+ personas,
13
+ value,
14
+ onChange,
15
+ }: {
16
+ personas: Persona[];
17
+ value: string;
18
+ onChange: (p: Persona) => void;
19
+ }) {
20
+ return (
21
+ <Select
22
+ value={value}
23
+ onValueChange={(id) => {
24
+ const p = personas.find((x) => x.id === id);
25
+ if (p) onChange(p);
26
+ }}
27
+ >
28
+ <SelectTrigger className="w-full">
29
+ <SelectValue placeholder="Choose a persona" />
30
+ </SelectTrigger>
31
+ <SelectContent>
32
+ {personas.map((p) => (
33
+ <SelectItem key={p.id} value={p.id}>
34
+ <div className="flex flex-col">
35
+ <span className="font-medium">{p.name}</span>
36
+ <span className="text-xs text-muted-foreground">{p.description}</span>
37
+ </div>
38
+ </SelectItem>
39
+ ))}
40
+ </SelectContent>
41
+ </Select>
42
+ );
43
+ }
frontend/components/temperature-slider.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { Slider } from "@/components/ui/slider";
3
+ import { Label } from "@/components/ui/label";
4
+
5
+ export function TemperatureSlider({
6
+ value,
7
+ onChange,
8
+ }: {
9
+ value: number;
10
+ onChange: (n: number) => void;
11
+ }) {
12
+ return (
13
+ <div className="space-y-2">
14
+ <div className="flex items-center justify-between">
15
+ <Label>Creativity</Label>
16
+ <span className="text-sm tabular-nums text-muted-foreground">
17
+ {value.toFixed(2)}
18
+ </span>
19
+ </div>
20
+ <Slider
21
+ min={0}
22
+ max={1.2}
23
+ step={0.05}
24
+ value={[value]}
25
+ onValueChange={(v) => onChange(v[0] ?? 0)}
26
+ />
27
+ <p className="text-xs text-muted-foreground">
28
+ Lower = more focused on the source material. Higher = more creative.
29
+ </p>
30
+ </div>
31
+ );
32
+ }
frontend/components/ui/button.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
12
+ destructive:
13
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
14
+ outline:
15
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
16
+ ghost: "hover:bg-accent hover:text-accent-foreground",
17
+ link: "text-primary underline-offset-4 hover:underline",
18
+ },
19
+ size: {
20
+ default: "h-10 px-4 py-2",
21
+ sm: "h-9 rounded-md px-3",
22
+ lg: "h-11 rounded-md px-6",
23
+ icon: "h-10 w-10",
24
+ },
25
+ },
26
+ defaultVariants: { variant: "default", size: "default" },
27
+ }
28
+ );
29
+
30
+ export interface ButtonProps
31
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
32
+ VariantProps<typeof buttonVariants> {
33
+ asChild?: boolean;
34
+ }
35
+
36
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
37
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
38
+ const Comp = asChild ? Slot : "button";
39
+ return (
40
+ <Comp
41
+ className={cn(buttonVariants({ variant, size, className }))}
42
+ ref={ref}
43
+ {...props}
44
+ />
45
+ );
46
+ }
47
+ );
48
+ Button.displayName = "Button";
frontend/components/ui/card.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export const Card = React.forwardRef<
5
+ HTMLDivElement,
6
+ React.HTMLAttributes<HTMLDivElement>
7
+ >(({ className, ...props }, ref) => (
8
+ <div
9
+ ref={ref}
10
+ className={cn(
11
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ ));
17
+ Card.displayName = "Card";
18
+
19
+ export const CardHeader = React.forwardRef<
20
+ HTMLDivElement,
21
+ React.HTMLAttributes<HTMLDivElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
24
+ ));
25
+ CardHeader.displayName = "CardHeader";
26
+
27
+ export const CardTitle = React.forwardRef<
28
+ HTMLHeadingElement,
29
+ React.HTMLAttributes<HTMLHeadingElement>
30
+ >(({ className, ...props }, ref) => (
31
+ <h3
32
+ ref={ref}
33
+ className={cn("text-lg font-semibold leading-none tracking-tight", className)}
34
+ {...props}
35
+ />
36
+ ));
37
+ CardTitle.displayName = "CardTitle";
38
+
39
+ export const CardDescription = React.forwardRef<
40
+ HTMLParagraphElement,
41
+ React.HTMLAttributes<HTMLParagraphElement>
42
+ >(({ className, ...props }, ref) => (
43
+ <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
44
+ ));
45
+ CardDescription.displayName = "CardDescription";
46
+
47
+ export const CardContent = React.forwardRef<
48
+ HTMLDivElement,
49
+ React.HTMLAttributes<HTMLDivElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
52
+ ));
53
+ CardContent.displayName = "CardContent";
54
+
55
+ export const CardFooter = React.forwardRef<
56
+ HTMLDivElement,
57
+ React.HTMLAttributes<HTMLDivElement>
58
+ >(({ className, ...props }, ref) => (
59
+ <div
60
+ ref={ref}
61
+ className={cn("flex items-center p-6 pt-0", className)}
62
+ {...props}
63
+ />
64
+ ));
65
+ CardFooter.displayName = "CardFooter";
frontend/components/ui/dialog.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { X } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const Dialog = DialogPrimitive.Root;
9
+ const DialogTrigger = DialogPrimitive.Trigger;
10
+ const DialogPortal = DialogPrimitive.Portal;
11
+ const DialogClose = DialogPrimitive.Close;
12
+
13
+ const DialogOverlay = React.forwardRef<
14
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
15
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
16
+ >(({ className, ...props }, ref) => (
17
+ <DialogPrimitive.Overlay
18
+ ref={ref}
19
+ className={cn(
20
+ "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ ));
26
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
27
+
28
+ const DialogContent = React.forwardRef<
29
+ React.ElementRef<typeof DialogPrimitive.Content>,
30
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
31
+ >(({ className, children, ...props }, ref) => (
32
+ <DialogPortal>
33
+ <DialogOverlay />
34
+ <DialogPrimitive.Content
35
+ ref={ref}
36
+ className={cn(
37
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
38
+ className
39
+ )}
40
+ {...props}
41
+ >
42
+ {children}
43
+ <DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
44
+ <X className="h-4 w-4" />
45
+ <span className="sr-only">Close</span>
46
+ </DialogClose>
47
+ </DialogPrimitive.Content>
48
+ </DialogPortal>
49
+ ));
50
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
51
+
52
+ const DialogHeader = ({
53
+ className,
54
+ ...props
55
+ }: React.HTMLAttributes<HTMLDivElement>) => (
56
+ <div
57
+ className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
58
+ {...props}
59
+ />
60
+ );
61
+ DialogHeader.displayName = "DialogHeader";
62
+
63
+ const DialogFooter = ({
64
+ className,
65
+ ...props
66
+ }: React.HTMLAttributes<HTMLDivElement>) => (
67
+ <div
68
+ className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
69
+ {...props}
70
+ />
71
+ );
72
+ DialogFooter.displayName = "DialogFooter";
73
+
74
+ const DialogTitle = React.forwardRef<
75
+ React.ElementRef<typeof DialogPrimitive.Title>,
76
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
77
+ >(({ className, ...props }, ref) => (
78
+ <DialogPrimitive.Title
79
+ ref={ref}
80
+ className={cn("text-lg font-semibold leading-none tracking-tight", className)}
81
+ {...props}
82
+ />
83
+ ));
84
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
85
+
86
+ const DialogDescription = React.forwardRef<
87
+ React.ElementRef<typeof DialogPrimitive.Description>,
88
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
89
+ >(({ className, ...props }, ref) => (
90
+ <DialogPrimitive.Description
91
+ ref={ref}
92
+ className={cn("text-sm text-muted-foreground", className)}
93
+ {...props}
94
+ />
95
+ ));
96
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
97
+
98
+ export {
99
+ Dialog,
100
+ DialogPortal,
101
+ DialogOverlay,
102
+ DialogClose,
103
+ DialogTrigger,
104
+ DialogContent,
105
+ DialogHeader,
106
+ DialogFooter,
107
+ DialogTitle,
108
+ DialogDescription,
109
+ };
frontend/components/ui/input.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
5
+
6
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
7
+ ({ className, type, ...props }, ref) => (
8
+ <input
9
+ type={type}
10
+ ref={ref}
11
+ className={cn(
12
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ );
19
+ Input.displayName = "Input";
frontend/components/ui/label.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import * as React from "react";
3
+ import * as LabelPrimitive from "@radix-ui/react-label";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export const Label = React.forwardRef<
7
+ React.ElementRef<typeof LabelPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <LabelPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ ));
19
+ Label.displayName = "Label";
frontend/components/ui/select.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import * as React from "react";
3
+ import * as SelectPrimitive from "@radix-ui/react-select";
4
+ import { Check, ChevronDown } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export const Select = SelectPrimitive.Root;
8
+ export const SelectValue = SelectPrimitive.Value;
9
+ export const SelectGroup = SelectPrimitive.Group;
10
+
11
+ export const SelectTrigger = React.forwardRef<
12
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
13
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
14
+ >(({ className, children, ...props }, ref) => (
15
+ <SelectPrimitive.Trigger
16
+ ref={ref}
17
+ className={cn(
18
+ "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
19
+ className
20
+ )}
21
+ {...props}
22
+ >
23
+ {children}
24
+ <SelectPrimitive.Icon asChild>
25
+ <ChevronDown className="h-4 w-4 opacity-50" />
26
+ </SelectPrimitive.Icon>
27
+ </SelectPrimitive.Trigger>
28
+ ));
29
+ SelectTrigger.displayName = "SelectTrigger";
30
+
31
+ export const SelectContent = React.forwardRef<
32
+ React.ElementRef<typeof SelectPrimitive.Content>,
33
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
34
+ >(({ className, children, position = "popper", ...props }, ref) => (
35
+ <SelectPrimitive.Portal>
36
+ <SelectPrimitive.Content
37
+ ref={ref}
38
+ position={position}
39
+ className={cn(
40
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-card text-card-foreground shadow-md",
41
+ position === "popper" &&
42
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
43
+ className
44
+ )}
45
+ {...props}
46
+ >
47
+ <SelectPrimitive.Viewport
48
+ className={cn(
49
+ "p-1",
50
+ position === "popper" &&
51
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
52
+ )}
53
+ >
54
+ {children}
55
+ </SelectPrimitive.Viewport>
56
+ </SelectPrimitive.Content>
57
+ </SelectPrimitive.Portal>
58
+ ));
59
+ SelectContent.displayName = "SelectContent";
60
+
61
+ export const SelectItem = React.forwardRef<
62
+ React.ElementRef<typeof SelectPrimitive.Item>,
63
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
64
+ >(({ className, children, ...props }, ref) => (
65
+ <SelectPrimitive.Item
66
+ ref={ref}
67
+ className={cn(
68
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
69
+ className
70
+ )}
71
+ {...props}
72
+ >
73
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
74
+ <SelectPrimitive.ItemIndicator>
75
+ <Check className="h-4 w-4" />
76
+ </SelectPrimitive.ItemIndicator>
77
+ </span>
78
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
79
+ </SelectPrimitive.Item>
80
+ ));
81
+ SelectItem.displayName = "SelectItem";
frontend/components/ui/slider.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import * as React from "react";
3
+ import * as SliderPrimitive from "@radix-ui/react-slider";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export const Slider = React.forwardRef<
7
+ React.ElementRef<typeof SliderPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <SliderPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ "relative flex w-full touch-none select-none items-center",
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
19
+ <SliderPrimitive.Range className="absolute h-full bg-primary" />
20
+ </SliderPrimitive.Track>
21
+ <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
22
+ </SliderPrimitive.Root>
23
+ ));
24
+ Slider.displayName = "Slider";
frontend/lib/personas.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Persona = {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ prompt: string;
6
+ };
7
+
8
+ // Bundled fallback — shown before the API responds.
9
+ // The canonical list lives on the server (backend/personas.json → HF Dataset).
10
+ export const DEFAULT_PERSONAS: Persona[] = [
11
+ {
12
+ id: "socratic",
13
+ name: "Socratic Tutor",
14
+ description: "Asks guiding questions instead of giving answers directly.",
15
+ prompt:
16
+ "Adopt the Socratic method. Rarely give a final answer outright; instead, " +
17
+ "ask one focused, open-ended question at a time that nudges the student " +
18
+ "toward the insight. Praise good reasoning, gently surface flawed assumptions.",
19
+ },
20
+ {
21
+ id: "strict_grader",
22
+ name: "Strict Grader",
23
+ description: "Rigorous, terse, and demanding of evidence.",
24
+ prompt:
25
+ "You are a strict grader. Be concise and uncompromising. Demand evidence " +
26
+ "from the course material for every claim. Point out logical gaps, missing " +
27
+ "citations, and imprecise terminology. Award no partial credit silently.",
28
+ },
29
+ {
30
+ id: "explainer",
31
+ name: "Direct Explainer",
32
+ description: "Plain, structured explanations with examples.",
33
+ prompt:
34
+ "Explain directly and clearly. Use short paragraphs, concrete examples, " +
35
+ "and analogies when helpful. End with a one-line summary. Never withhold " +
36
+ "the answer behind a question.",
37
+ },
38
+ {
39
+ id: "study_buddy",
40
+ name: "Study Buddy",
41
+ description: "Casual, encouraging, learns alongside the student.",
42
+ prompt:
43
+ "Speak as a friendly peer who is also studying this material. Be warm and " +
44
+ "encouraging, share confusion when it's genuine, and propose next steps " +
45
+ "the student could try together with you.",
46
+ },
47
+ ];
48
+
49
+ export const DEFAULT_PERSONA_ID = "explainer";
50
+
51
+ /** Fetch the live persona list from the backend. Falls back to DEFAULT_PERSONAS. */
52
+ export async function fetchPersonas(): Promise<Persona[]> {
53
+ try {
54
+ const r = await fetch("/api/personas");
55
+ if (!r.ok) throw new Error("non-ok");
56
+ const data = await r.json();
57
+ if (Array.isArray(data.personas) && data.personas.length > 0) return data.personas;
58
+ } catch {
59
+ // swallow — use bundled fallback
60
+ }
61
+ return DEFAULT_PERSONAS;
62
+ }
63
+
frontend/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
frontend/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
frontend/next.config.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ // All /api/* requests from the browser are transparently proxied to the
3
+ // FastAPI process on 127.0.0.1:8000. Only port 7860 is exposed by the HF Space.
4
+ const nextConfig = {
5
+ reactStrictMode: true,
6
+ output: "standalone" === process.env.NEXT_OUTPUT ? "standalone" : undefined,
7
+ // Allow large PDF uploads through the upload route handler
8
+ experimental: {
9
+ serverActions: { bodySizeLimit: "100mb" },
10
+ },
11
+ async rewrites() {
12
+ const backend = process.env.BACKEND_INTERNAL_URL || "http://127.0.0.1:8000";
13
+ return [
14
+ {
15
+ source: "/api/:path*",
16
+ destination: `${backend}/:path*`,
17
+ },
18
+ ];
19
+ },
20
+ };
21
+
22
+ module.exports = nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "iam-earth-dev-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev -p 7860 -H 0.0.0.0",
7
+ "build": "next build",
8
+ "start": "next start -p 7860 -H 0.0.0.0",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "@radix-ui/react-dialog": "^1.1.15",
13
+ "@radix-ui/react-label": "^2.1.0",
14
+ "@radix-ui/react-select": "^2.1.2",
15
+ "@radix-ui/react-slider": "^1.2.1",
16
+ "@radix-ui/react-slot": "^1.1.0",
17
+ "class-variance-authority": "^0.7.0",
18
+ "clsx": "^2.1.1",
19
+ "lucide-react": "^0.460.0",
20
+ "next": "latest",
21
+ "react": "latest",
22
+ "react-dom": "latest",
23
+ "react-dropzone": "^14.3.5",
24
+ "tailwind-merge": "^2.5.4"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.9.0",
28
+ "@types/react": "^18.3.12",
29
+ "@types/react-dom": "^18.3.1",
30
+ "autoprefixer": "^10.4.20",
31
+ "eslint": "^9.14.0",
32
+ "eslint-config-next": "latest",
33
+ "postcss": "^8.4.49",
34
+ "tailwindcss": "^3.4.14",
35
+ "tailwindcss-animate": "^1.0.7",
36
+ "typescript": "^5.6.3"
37
+ }
38
+ }
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
frontend/tailwind.config.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ darkMode: ["class"],
5
+ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
6
+ theme: {
7
+ container: { center: true, padding: "1.5rem" },
8
+ extend: {
9
+ colors: {
10
+ border: "hsl(var(--border))",
11
+ input: "hsl(var(--input))",
12
+ ring: "hsl(var(--ring))",
13
+ background: "hsl(var(--background))",
14
+ foreground: "hsl(var(--foreground))",
15
+ primary: {
16
+ DEFAULT: "hsl(var(--primary))",
17
+ foreground: "hsl(var(--primary-foreground))",
18
+ },
19
+ muted: {
20
+ DEFAULT: "hsl(var(--muted))",
21
+ foreground: "hsl(var(--muted-foreground))",
22
+ },
23
+ accent: {
24
+ DEFAULT: "hsl(var(--accent))",
25
+ foreground: "hsl(var(--accent-foreground))",
26
+ },
27
+ destructive: {
28
+ DEFAULT: "hsl(var(--destructive))",
29
+ foreground: "hsl(var(--destructive-foreground))",
30
+ },
31
+ card: {
32
+ DEFAULT: "hsl(var(--card))",
33
+ foreground: "hsl(var(--card-foreground))",
34
+ },
35
+ },
36
+ borderRadius: {
37
+ lg: "var(--radius)",
38
+ md: "calc(var(--radius) - 2px)",
39
+ sm: "calc(var(--radius) - 4px)",
40
+ },
41
+ },
42
+ },
43
+ plugins: [require("tailwindcss-animate")],
44
+ };
45
+
46
+ export default config;
frontend/tsconfig.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "esnext"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "esModuleInterop": true,
14
+ "module": "esnext",
15
+ "moduleResolution": "bundler",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "jsx": "react-jsx",
19
+ "incremental": true,
20
+ "ignoreDeprecations": "5.0",
21
+ "plugins": [
22
+ {
23
+ "name": "next"
24
+ }
25
+ ],
26
+ "baseUrl": ".",
27
+ "paths": {
28
+ "@/*": [
29
+ "./*"
30
+ ]
31
+ }
32
+ },
33
+ "include": [
34
+ "next-env.d.ts",
35
+ "**/*.ts",
36
+ "**/*.tsx",
37
+ ".next/types/**/*.ts",
38
+ ".next/dev/types/**/*.ts"
39
+ ],
40
+ "exclude": [
41
+ "node_modules"
42
+ ]
43
+ }
frontend/tsconfig.tsbuildinfo ADDED
The diff for this file is too large to render. See raw diff
 
start.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Launch FastAPI (internal :8000) and Next.js (public :7860) together.
3
+ # HF Spaces sends SIGTERM on shutdown; we trap it and forward to children.
4
+ set -euo pipefail
5
+
6
+ BACKEND_PORT="${BACKEND_PORT:-8000}"
7
+ FRONTEND_PORT="${FRONTEND_PORT:-7860}"
8
+
9
+ cleanup() {
10
+ echo "[start.sh] caught signal, shutting down…"
11
+ [[ -n "${BACKEND_PID:-}" ]] && kill -TERM "$BACKEND_PID" 2>/dev/null || true
12
+ [[ -n "${FRONTEND_PID:-}" ]] && kill -TERM "$FRONTEND_PID" 2>/dev/null || true
13
+ wait || true
14
+ exit 0
15
+ }
16
+ trap cleanup SIGINT SIGTERM
17
+
18
+ echo "[start.sh] launching FastAPI on :$BACKEND_PORT"
19
+ cd /app/backend
20
+ uv run uvicorn main:app --host 127.0.0.1 --port "$BACKEND_PORT" --workers 1 &
21
+ BACKEND_PID=$!
22
+
23
+ echo "[start.sh] launching Next.js on :$FRONTEND_PORT"
24
+ cd /app/frontend
25
+ HOSTNAME=0.0.0.0 PORT="$FRONTEND_PORT" npx next start -p "$FRONTEND_PORT" -H 0.0.0.0 &
26
+ FRONTEND_PID=$!
27
+
28
+ # If either process dies, exit so HF restarts the Space.
29
+ wait -n "$BACKEND_PID" "$FRONTEND_PID"
30
+ EXIT_CODE=$?
31
+ echo "[start.sh] a child exited (code=$EXIT_CODE), shutting down sibling"
32
+ cleanup
test_stream.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import httpx
2
+ import time
3
+
4
+ for _ in range(3):
5
+ print("tick")
6
+ time.sleep(1)
test_uvicorn_log.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.responses import StreamingResponse
3
+ import time
4
+
5
+ app = FastAPI()
6
+
7
+ def stream():
8
+ for i in range(3):
9
+ time.sleep(1)
10
+ yield f"chunk {i}\n"
11
+
12
+ @app.get("/")
13
+ def get_stream():
14
+ return StreamingResponse(stream())