senlinyy commited on
Commit
6be14c8
·
1 Parent(s): 0bfaad4

feat: add default env vars

Browse files
.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
- # 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
 
@@ -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. **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.
@@ -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, DATASET_ID, ADMIN_PASSCODE
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
- ADMIN_PASSCODE = os.environ.get("ADMIN_PASSCODE", "")
 
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
- _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
@@ -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
- 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:
@@ -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
- 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
  )
 
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
- 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
  }
 
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 = process.env.BACKEND_INTERNAL_URL || "http://127.0.0.1:8000";
 
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 &