Spaces:
Sleeping
Sleeping
feat: add default env vars
Browse files- .env.example +7 -9
- Dockerfile +1 -0
- README.md +6 -6
- backend/auth.py +2 -2
- backend/config.py +58 -0
- backend/hf_sync.py +2 -3
- backend/main.py +16 -17
- backend/personas_store.py +8 -11
- backend/rag.py +1 -5
- frontend/app/api/chat/route.ts +35 -15
- frontend/app/api/upload/route.ts +2 -2
- frontend/lib/backend-url.ts +5 -0
- frontend/next.config.js +2 -1
- start.sh +20 -0
.env.example
CHANGED
|
@@ -2,12 +2,13 @@
|
|
| 2 |
# A read+write token from https://huggingface.co/settings/tokens
|
| 3 |
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
#
|
| 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 |
|
|
@@ -15,10 +16,7 @@ EMBED_MODEL=BAAI/bge-small-en-v1.5
|
|
| 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
|
|
|
|
| 2 |
# A read+write token from https://huggingface.co/settings/tokens
|
| 3 |
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 4 |
|
| 5 |
+
# ---- Optional overrides ----
|
| 6 |
+
# Defaults to "<SPACE_ID>-corpus" on Hugging Face Spaces, or "iam-earth-dev-corpus" locally.
|
| 7 |
+
# DATASET_ID=your-username/iam-earth-dev-corpus
|
| 8 |
+
|
| 9 |
+
# Default admin passcode is "password"; override this for real deployments.
|
| 10 |
+
# ADMIN_PASSCODE=change-me
|
| 11 |
|
|
|
|
|
|
|
| 12 |
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
|
| 13 |
EMBED_MODEL=BAAI/bge-small-en-v1.5
|
| 14 |
|
|
|
|
| 16 |
# that expose a separate thinking/reasoning stream.
|
| 17 |
SHOW_LLM_REASONING=false
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# ---- Internal (defaults are fine for local dev; Docker sets these via Dockerfile ENV) ----
|
| 20 |
BACKEND_PORT=8000
|
| 21 |
+
BACKEND_INTERNAL_URL=http://127.0.0.1:8000
|
| 22 |
FRONTEND_PORT=7860
|
Dockerfile
CHANGED
|
@@ -52,6 +52,7 @@ 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
|
|
|
|
| 52 |
LANCEDB_PATH=/app/.lancedb \
|
| 53 |
TMP_UPLOAD_DIR=/app/.tmp_uploads \
|
| 54 |
BACKEND_PORT=8000 \
|
| 55 |
+
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
|
| 56 |
FRONTEND_PORT=7860 \
|
| 57 |
NEXT_TELEMETRY_DISABLED=1 \
|
| 58 |
PYTHONUNBUFFERED=1
|
README.md
CHANGED
|
@@ -11,11 +11,11 @@ pinned: false
|
|
| 11 |
## Quick start (clone for your own course)
|
| 12 |
|
| 13 |
1. **Duplicate this Space** on Hugging Face.
|
| 14 |
-
2.
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
- `DATASET_ID` — `
|
| 18 |
-
- `ADMIN_PASSCODE` —
|
| 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.
|
|
@@ -26,7 +26,7 @@ pinned: false
|
|
| 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
|
| 30 |
|
| 31 |
# Backend
|
| 32 |
cd backend && uv sync && uv run uvicorn main:app --port 8000 --reload
|
|
|
|
| 11 |
## Quick start (clone for your own course)
|
| 12 |
|
| 13 |
1. **Duplicate this Space** on Hugging Face.
|
| 14 |
+
2. In your Space → **Settings → Variables and secrets**, add:
|
| 15 |
+
- `HF_TOKEN` — required; use a token with write access so the app can create/sync the dataset.
|
| 16 |
+
3. Optional overrides:
|
| 17 |
+
- `DATASET_ID` — defaults to `<SPACE_ID>-corpus` on Spaces and is created automatically when needed.
|
| 18 |
+
- `ADMIN_PASSCODE` — defaults to `password`; set this for real deployments.
|
| 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.
|
|
|
|
| 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; other values have defaults
|
| 30 |
|
| 31 |
# Backend
|
| 32 |
cd backend && uv sync && uv run uvicorn main:app --port 8000 --reload
|
backend/auth.py
CHANGED
|
@@ -5,12 +5,12 @@ 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 |
-
|
|
|
|
| 14 |
COOKIE_NAME = "admin_session"
|
| 15 |
|
| 16 |
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
import secrets
|
| 9 |
|
| 10 |
from fastapi import Cookie, HTTPException, status
|
| 11 |
|
| 12 |
+
from config import ADMIN_PASSCODE
|
| 13 |
+
|
| 14 |
COOKIE_NAME = "admin_session"
|
| 15 |
|
| 16 |
|
backend/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime configuration defaults.
|
| 2 |
+
|
| 3 |
+
Only HF_TOKEN is intentionally left without a default because chat, embeddings,
|
| 4 |
+
and dataset writes need a Hugging Face token in deployed Spaces.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
REPO_ROOT = Path(__file__).parent.parent
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _default_dataset_id() -> str:
|
| 16 |
+
space_id = os.environ.get("SPACE_ID") or os.environ.get("HF_SPACE_ID")
|
| 17 |
+
if space_id and "/" in space_id:
|
| 18 |
+
return f"{space_id}-corpus"
|
| 19 |
+
return "iam-earth-dev-corpus"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _env_bool(name: str, default: bool = False) -> bool:
|
| 23 |
+
raw = os.environ.get(name)
|
| 24 |
+
if raw is None:
|
| 25 |
+
return default
|
| 26 |
+
return raw.lower() in {"1", "true", "yes", "on"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 30 |
+
DATASET_ID = os.environ.get("DATASET_ID") or _default_dataset_id()
|
| 31 |
+
ADMIN_PASSCODE = os.environ.get("ADMIN_PASSCODE", "password")
|
| 32 |
+
|
| 33 |
+
LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 34 |
+
LLM_MODEL_LOWER = LLM_MODEL.lower()
|
| 35 |
+
EMBED_MODEL = os.environ.get("EMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
| 36 |
+
LLM_EXTRA_BODY_JSON = os.environ.get("LLM_EXTRA_BODY_JSON", "").strip()
|
| 37 |
+
LLM_FINAL_ANSWER_EXTRA_BODY_JSON = os.environ.get(
|
| 38 |
+
"LLM_FINAL_ANSWER_EXTRA_BODY_JSON",
|
| 39 |
+
"",
|
| 40 |
+
).strip()
|
| 41 |
+
SHOW_LLM_REASONING = _env_bool("SHOW_LLM_REASONING", False)
|
| 42 |
+
|
| 43 |
+
LLM_MAX_TOKENS = int(
|
| 44 |
+
os.environ.get(
|
| 45 |
+
"LLM_MAX_TOKENS",
|
| 46 |
+
"2048" if "qwen/qwen3" in LLM_MODEL_LOWER or "qwen3" in LLM_MODEL_LOWER else "1024",
|
| 47 |
+
)
|
| 48 |
+
)
|
| 49 |
+
LLM_FINAL_ANSWER_MAX_TOKENS = int(os.environ.get("LLM_FINAL_ANSWER_MAX_TOKENS", "768"))
|
| 50 |
+
|
| 51 |
+
BACKEND_PORT = os.environ.get("BACKEND_PORT", "8000")
|
| 52 |
+
FRONTEND_PORT = os.environ.get("FRONTEND_PORT", "7860")
|
| 53 |
+
BACKEND_INTERNAL_URL = os.environ.get("BACKEND_INTERNAL_URL", f"http://127.0.0.1:{BACKEND_PORT}")
|
| 54 |
+
|
| 55 |
+
TMP_UPLOAD_DIR = Path(os.environ.get("TMP_UPLOAD_DIR", str(REPO_ROOT / ".tmp_uploads")))
|
| 56 |
+
LANCEDB_DIR = Path(os.environ.get("LANCEDB_PATH", str(REPO_ROOT / ".lancedb")))
|
| 57 |
+
LANCEDB_PATH = str(LANCEDB_DIR)
|
| 58 |
+
|
backend/hf_sync.py
CHANGED
|
@@ -13,7 +13,6 @@ 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
|
|
@@ -22,10 +21,10 @@ from typing import List
|
|
| 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/"
|
|
|
|
| 13 |
|
| 14 |
import json
|
| 15 |
import logging
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
import shutil
|
| 18 |
from tempfile import TemporaryDirectory
|
|
|
|
| 21 |
from huggingface_hub import HfApi, hf_hub_download
|
| 22 |
from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
|
| 23 |
|
| 24 |
+
from config import DATASET_ID, HF_TOKEN
|
| 25 |
+
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
|
|
|
|
|
|
| 28 |
PDF_PREFIX = "pdfs/"
|
| 29 |
VECTOR_PREFIX = "vector_cache/"
|
| 30 |
VECTOR_INDEX_PREFIX = f"{VECTOR_PREFIX}index/"
|
backend/main.py
CHANGED
|
@@ -27,6 +27,7 @@ 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
|
|
@@ -34,23 +35,17 @@ from rag import RagEngine
|
|
| 34 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 35 |
logger = logging.getLogger("backend")
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 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
|
|
@@ -231,6 +226,10 @@ 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,
|
|
|
|
| 27 |
|
| 28 |
# Local (imported AFTER load_dotenv so their module-level env reads are correct)
|
| 29 |
import auth
|
| 30 |
+
import config
|
| 31 |
import hf_sync
|
| 32 |
import personas_store
|
| 33 |
from rag import RagEngine
|
|
|
|
| 35 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 36 |
logger = logging.getLogger("backend")
|
| 37 |
|
| 38 |
+
TMP_UPLOAD_DIR = config.TMP_UPLOAD_DIR
|
| 39 |
+
LANCEDB_DIR = config.LANCEDB_DIR
|
| 40 |
+
LLM_MODEL = config.LLM_MODEL
|
| 41 |
+
LLM_MODEL_LOWER = config.LLM_MODEL_LOWER
|
| 42 |
+
EMBED_MODEL = config.EMBED_MODEL
|
| 43 |
+
HF_TOKEN = config.HF_TOKEN
|
| 44 |
+
LLM_EXTRA_BODY_JSON = config.LLM_EXTRA_BODY_JSON
|
| 45 |
+
LLM_FINAL_ANSWER_EXTRA_BODY_JSON = config.LLM_FINAL_ANSWER_EXTRA_BODY_JSON
|
| 46 |
+
SHOW_LLM_REASONING = config.SHOW_LLM_REASONING
|
| 47 |
+
LLM_MAX_TOKENS = config.LLM_MAX_TOKENS
|
| 48 |
+
LLM_FINAL_ANSWER_MAX_TOKENS = config.LLM_FINAL_ANSWER_MAX_TOKENS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# Globals populated in lifespan.
|
| 51 |
rag: Optional[RagEngine] = None
|
|
|
|
| 226 |
return {
|
| 227 |
"ok": True,
|
| 228 |
"model": LLM_MODEL,
|
| 229 |
+
"embed_model": EMBED_MODEL,
|
| 230 |
+
"dataset_id": config.DATASET_ID,
|
| 231 |
+
"admin_passcode_defaulted": config.ADMIN_PASSCODE == "password" and "ADMIN_PASSCODE" not in os.environ,
|
| 232 |
+
"hf_token_configured": bool(HF_TOKEN),
|
| 233 |
"chat_extra_body": CHAT_EXTRA_BODY,
|
| 234 |
"final_answer_extra_body": FINAL_ANSWER_EXTRA_BODY,
|
| 235 |
"show_reasoning": SHOW_LLM_REASONING,
|
backend/personas_store.py
CHANGED
|
@@ -10,11 +10,12 @@ 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"
|
|
@@ -36,18 +37,16 @@ def load() -> None:
|
|
| 36 |
global _personas
|
| 37 |
|
| 38 |
# Try HF Dataset
|
| 39 |
-
|
| 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=
|
| 48 |
repo_type="dataset",
|
| 49 |
filename=_DATASET_FILENAME,
|
| 50 |
-
token=
|
| 51 |
)
|
| 52 |
data = json.loads(Path(local).read_text("utf-8"))
|
| 53 |
with _lock:
|
|
@@ -83,17 +82,15 @@ def save(new_personas: List[dict]) -> List[dict]:
|
|
| 83 |
logger.warning("Could not write local personas.json: %s", exc)
|
| 84 |
|
| 85 |
# Sync to HF Dataset
|
| 86 |
-
|
| 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=
|
| 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=
|
| 97 |
repo_type="dataset",
|
| 98 |
commit_message="Update personas",
|
| 99 |
)
|
|
|
|
| 10 |
|
| 11 |
import json
|
| 12 |
import logging
|
|
|
|
| 13 |
from pathlib import Path
|
| 14 |
from threading import Lock
|
| 15 |
from typing import List
|
| 16 |
|
| 17 |
+
from config import DATASET_ID, HF_TOKEN
|
| 18 |
+
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
_BUNDLED = Path(__file__).parent / "personas.json"
|
|
|
|
| 37 |
global _personas
|
| 38 |
|
| 39 |
# Try HF Dataset
|
| 40 |
+
if HF_TOKEN and DATASET_ID:
|
|
|
|
|
|
|
| 41 |
try:
|
| 42 |
from huggingface_hub import hf_hub_download
|
| 43 |
from huggingface_hub.utils import EntryNotFoundError
|
| 44 |
|
| 45 |
local = hf_hub_download(
|
| 46 |
+
repo_id=DATASET_ID,
|
| 47 |
repo_type="dataset",
|
| 48 |
filename=_DATASET_FILENAME,
|
| 49 |
+
token=HF_TOKEN,
|
| 50 |
)
|
| 51 |
data = json.loads(Path(local).read_text("utf-8"))
|
| 52 |
with _lock:
|
|
|
|
| 82 |
logger.warning("Could not write local personas.json: %s", exc)
|
| 83 |
|
| 84 |
# Sync to HF Dataset
|
| 85 |
+
if HF_TOKEN and DATASET_ID:
|
|
|
|
|
|
|
| 86 |
try:
|
| 87 |
from huggingface_hub import HfApi
|
| 88 |
|
| 89 |
+
api = HfApi(token=HF_TOKEN)
|
| 90 |
api.upload_file(
|
| 91 |
path_or_fileobj=json.dumps(new_personas, indent=2, ensure_ascii=False).encode(),
|
| 92 |
path_in_repo=_DATASET_FILENAME,
|
| 93 |
+
repo_id=DATASET_ID,
|
| 94 |
repo_type="dataset",
|
| 95 |
commit_message="Update personas",
|
| 96 |
)
|
backend/rag.py
CHANGED
|
@@ -9,7 +9,6 @@ Designed for HF Spaces Free CPU Basic:
|
|
| 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
|
|
@@ -20,15 +19,12 @@ 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"
|
|
|
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import logging
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
from threading import Lock
|
| 14 |
from typing import Iterable, List, Optional
|
|
|
|
| 19 |
from llama_index.core.schema import MetadataMode, NodeWithScore, TextNode
|
| 20 |
from llama_index.vector_stores.lancedb import LanceDBVectorStore
|
| 21 |
|
| 22 |
+
from config import EMBED_MODEL, HF_TOKEN, LANCEDB_PATH
|
| 23 |
from hf_embedding import SyncHuggingFaceInferenceEmbedding
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
|
|
|
|
|
|
| 27 |
TABLE_NAME = "documents"
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# Filename is stored on every node so we can delete by source.
|
| 30 |
FILENAME_KEY = "source_filename"
|
frontend/app/api/chat/route.ts
CHANGED
|
@@ -1,25 +1,45 @@
|
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
let response: Response;
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return NextResponse.json(
|
| 22 |
-
{ detail:
|
| 23 |
{ status: 502 }
|
| 24 |
);
|
| 25 |
}
|
|
|
|
| 1 |
import { type NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { backendUrl } from "@/lib/backend-url";
|
| 3 |
|
| 4 |
export const dynamic = "force-dynamic";
|
| 5 |
export const runtime = "nodejs";
|
| 6 |
|
| 7 |
+
const BACKEND_RETRY_ATTEMPTS = 20;
|
| 8 |
+
const BACKEND_RETRY_DELAY_MS = 500;
|
| 9 |
+
|
| 10 |
+
function delay(ms: number) {
|
| 11 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
export async function POST(request: NextRequest) {
|
| 15 |
+
const backend = backendUrl();
|
| 16 |
+
const body = await request.text();
|
| 17 |
+
|
| 18 |
+
let response: Response | undefined;
|
| 19 |
+
for (let attempt = 1; ; attempt += 1) {
|
| 20 |
+
try {
|
| 21 |
+
response = await fetch(`${backend}/chat`, {
|
| 22 |
+
method: "POST",
|
| 23 |
+
body,
|
| 24 |
+
headers: {
|
| 25 |
+
"content-type": request.headers.get("content-type") ?? "application/json",
|
| 26 |
+
},
|
| 27 |
+
});
|
| 28 |
+
break;
|
| 29 |
+
} catch {
|
| 30 |
+
if (attempt >= BACKEND_RETRY_ATTEMPTS) {
|
| 31 |
+
return NextResponse.json(
|
| 32 |
+
{ detail: `Could not reach the backend chat service at ${backend}.` },
|
| 33 |
+
{ status: 502 }
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
await delay(BACKEND_RETRY_DELAY_MS);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (!response) {
|
| 41 |
return NextResponse.json(
|
| 42 |
+
{ detail: `Could not reach the backend chat service at ${backend}.` },
|
| 43 |
{ status: 502 }
|
| 44 |
);
|
| 45 |
}
|
frontend/app/api/upload/route.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 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
|
|
@@ -7,8 +8,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
|
| 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") ?? "";
|
|
|
|
| 1 |
import { type NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { backendUrl } from "@/lib/backend-url";
|
| 3 |
|
| 4 |
// This route handler replaces the rewrite proxy for /api/upload so that
|
| 5 |
// large PDF files (>10 MB) are forwarded to FastAPI without Next.js buffering
|
|
|
|
| 8 |
export const dynamic = "force-dynamic";
|
| 9 |
|
| 10 |
export async function POST(request: NextRequest) {
|
| 11 |
+
const backend = backendUrl();
|
|
|
|
| 12 |
|
| 13 |
const formData = await request.formData();
|
| 14 |
const cookie = request.headers.get("cookie") ?? "";
|
frontend/lib/backend-url.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function backendUrl() {
|
| 2 |
+
const port = process.env.BACKEND_PORT ?? "8000";
|
| 3 |
+
return (process.env.BACKEND_INTERNAL_URL ?? `http://127.0.0.1:${port}`).replace(/\/$/, "");
|
| 4 |
+
}
|
| 5 |
+
|
frontend/next.config.js
CHANGED
|
@@ -9,7 +9,8 @@ const nextConfig = {
|
|
| 9 |
serverActions: { bodySizeLimit: "100mb" },
|
| 10 |
},
|
| 11 |
async rewrites() {
|
| 12 |
-
const backend =
|
|
|
|
| 13 |
return [
|
| 14 |
{
|
| 15 |
source: "/api/:path*",
|
|
|
|
| 9 |
serverActions: { bodySizeLimit: "100mb" },
|
| 10 |
},
|
| 11 |
async rewrites() {
|
| 12 |
+
const backend =
|
| 13 |
+
process.env.BACKEND_INTERNAL_URL || `http://127.0.0.1:${process.env.BACKEND_PORT || "8000"}`;
|
| 14 |
return [
|
| 15 |
{
|
| 16 |
source: "/api/:path*",
|
start.sh
CHANGED
|
@@ -5,6 +5,8 @@ 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…"
|
|
@@ -20,6 +22,24 @@ 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 &
|
|
|
|
| 5 |
|
| 6 |
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
| 7 |
FRONTEND_PORT="${FRONTEND_PORT:-7860}"
|
| 8 |
+
BACKEND_INTERNAL_URL="${BACKEND_INTERNAL_URL:-http://127.0.0.1:${BACKEND_PORT}}"
|
| 9 |
+
export BACKEND_PORT FRONTEND_PORT BACKEND_INTERNAL_URL
|
| 10 |
|
| 11 |
cleanup() {
|
| 12 |
echo "[start.sh] caught signal, shutting down…"
|
|
|
|
| 22 |
uv run uvicorn main:app --host 127.0.0.1 --port "$BACKEND_PORT" --workers 1 &
|
| 23 |
BACKEND_PID=$!
|
| 24 |
|
| 25 |
+
echo "[start.sh] waiting for FastAPI health check at $BACKEND_INTERNAL_URL/health"
|
| 26 |
+
for _ in $(seq 1 120); do
|
| 27 |
+
if curl -fsS "$BACKEND_INTERNAL_URL/health" >/dev/null 2>&1; then
|
| 28 |
+
echo "[start.sh] FastAPI is ready"
|
| 29 |
+
break
|
| 30 |
+
fi
|
| 31 |
+
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
| 32 |
+
wait "$BACKEND_PID" || true
|
| 33 |
+
echo "[start.sh] FastAPI exited before becoming ready"
|
| 34 |
+
cleanup
|
| 35 |
+
fi
|
| 36 |
+
sleep 1
|
| 37 |
+
done
|
| 38 |
+
if ! curl -fsS "$BACKEND_INTERNAL_URL/health" >/dev/null 2>&1; then
|
| 39 |
+
echo "[start.sh] FastAPI did not become ready in time"
|
| 40 |
+
cleanup
|
| 41 |
+
fi
|
| 42 |
+
|
| 43 |
echo "[start.sh] launching Next.js on :$FRONTEND_PORT"
|
| 44 |
cd /app/frontend
|
| 45 |
HOSTNAME=0.0.0.0 PORT="$FRONTEND_PORT" npx next start -p "$FRONTEND_PORT" -H 0.0.0.0 &
|