From 90faac4719cbc01bf3ac454c22c1cc6ef1307b1b Mon Sep 17 00:00:00 2001 From: Jordan Leis Date: Fri, 10 Jul 2026 14:55:53 +0000 Subject: [PATCH 1/5] feat: default to gpt-5.4-nano on Chat and Study Selection pages Adds gpt-5.4-nano as the top-priority model in the shared MODEL_PRIORITY ranking, and switches the Study Selection page to reuse pickBestFromList() instead of its own hardcoded Gemini 2.5 Flash Lite default, so both pages agree on the org default. --- .../components/BatchStudySelectionPage.tsx | 31 ++++++------------- frontend/utils/modelSelection.ts | 5 +++ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/frontend/components/BatchStudySelectionPage.tsx b/frontend/components/BatchStudySelectionPage.tsx index e3f620f..a0aadcc 100644 --- a/frontend/components/BatchStudySelectionPage.tsx +++ b/frontend/components/BatchStudySelectionPage.tsx @@ -53,6 +53,7 @@ import { DocumentData } from "../App"; import { loadStudyTypeTemplate } from "./TemplateLoader"; import { TemplatePicker, ResolvedTemplate } from "./TemplatePicker"; import { settingsManager, ModelConfig } from "./SettingsManager"; +import { pickBestFromList } from "../utils/modelSelection"; import { Input } from "./ui/input"; import { Textarea } from "./ui/textarea"; import { Label } from "./ui/label"; @@ -114,30 +115,16 @@ export function BatchStudySelectionPage({ const models = await settingsManager.getAvailableModelsAsync(); setAvailableModels(models); - // Auto-select Gemini 2.5 Flash Lite by default only if nothing selected yet - // (including from restored session data) + // Auto-select the org default model only if nothing selected yet + // (including from restored session data). Uses the same priority + // ranking as the Chat page so both pages agree on the default. if (selectedModels.length === 0 && !documentData.selectedModels?.length) { - const gemini25FlashLite = models.find( - (m) => - m.id.toLowerCase().includes("gemini") && - m.id.toLowerCase().includes("2.5") && - (m.id.toLowerCase().includes("flash") || - m.id.toLowerCase().includes("lite")) - ); - + const best = pickBestFromList(models); const defaultModels: string[] = []; - if (gemini25FlashLite) { - defaultModels.push(gemini25FlashLite.id); - } else { - // Fallback: select first Gemini model or first model overall - const anyGemini = models.find((m) => - m.id.toLowerCase().includes("gemini") - ); - if (anyGemini) { - defaultModels.push(anyGemini.id); - } else if (models.length > 0) { - defaultModels.push(models[0].id); - } + if (best) { + defaultModels.push(best.modelId); + } else if (models.length > 0) { + defaultModels.push(models[0].id); } setSelectedModels(defaultModels); diff --git a/frontend/utils/modelSelection.ts b/frontend/utils/modelSelection.ts index 747b824..edb2cf2 100644 --- a/frontend/utils/modelSelection.ts +++ b/frontend/utils/modelSelection.ts @@ -16,6 +16,11 @@ const MODEL_PRIORITY: Array<{ match: (m: ModelConfig) => boolean; modelType: string; }> = [ + // Tier 0 — org default + { + match: (m) => m.provider === "Azure" && m.name === "gpt-5.4-nano", + modelType: "azure", + }, // Tier 1 — frontier reasoning (cost-efficient first) { match: (m) => m.id?.includes("gemini-3-pro"), From f5bae19131609754b9bbf1cc161c2cd320177a9d Mon Sep 17 00:00:00 2001 From: Jordan Leis Date: Fri, 10 Jul 2026 15:20:27 +0000 Subject: [PATCH 2/5] fix: consolidate ALLOWED_EMAILS into a single shared Container App secret ALLOWED_EMAILS was previously set as two independent plain env vars (one on the backend container, one on auth-sidecar), which drifted out of sync in production and locked out a real user. Both containers now reference one Container-App-level secret via secretRef, so a single `az containerapp secret set` updates access for both. --- .env.example | 31 +++++++++++++++++++++++++++++++ .gitignore | 4 ++++ docker-compose.yml | 2 ++ infra/container-app.yaml | 6 ++++++ infra/provision.sh | 1 + 5 files changed, 44 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1bb6b22 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Copy this file to .env and fill in real values. +# .env is gitignored — never commit real credentials. + +# Postgres (Docker Compose defaults work for local dev) +POSTGRES_USER=summarization +POSTGRES_PASSWORD=localdev +POSTGRES_DB=summarization + +# Better Auth — generate with: openssl rand -hex 32 +BETTER_AUTH_SECRET= + +# Login allowlist — comma-separated emails, no spaces. Shared by both the +# backend and the auth sidecar (single source of truth). Empty = allow all (dev mode). +ALLOWED_EMAILS= + +# GitHub OAuth App (optional — email/password auth works without it) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Azure Document Intelligence +# Azure Portal → your Cognitive Services resource → Keys and Endpoint +AZURE_DOC_INTELLIGENCE_ENDPOINT= +AZURE_DOC_INTELLIGENCE_KEY= + +# Azure OpenAI (AI Foundry) +# Azure Portal → your AI Foundry project → Deployments +AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_KEY= +AZURE_OPENAI_DEPLOYMENT= +AZURE_OPENAI_MODEL_NAME= +AZURE_OPENAI_API_VERSION=2025-04-01-preview diff --git a/.gitignore b/.gitignore index 42763c5..5da631c 100644 --- a/.gitignore +++ b/.gitignore @@ -86,5 +86,9 @@ htmlcov/ # Local docker-compose overrides (never commit) docker-compose.override.yml +# Operational docs with live credentials — never commit +docs/deployment-runbook.md +docs/session-transcript.md + # External repo checkout — separate repository, not part of this project VLLM-Service/ diff --git a/docker-compose.yml b/docker-compose.yml index 29a7864..1cff42d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: PORT: "3001" GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} ports: - "3001:3001" depends_on: @@ -70,6 +71,7 @@ services: # Azure Document Intelligence — sourced from .env AZURE_DOC_INTELLIGENCE_ENDPOINT: ${AZURE_DOC_INTELLIGENCE_ENDPOINT:-} AZURE_DOC_INTELLIGENCE_KEY: ${AZURE_DOC_INTELLIGENCE_KEY:-} + ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} ports: - "8001:8001" depends_on: diff --git a/infra/container-app.yaml b/infra/container-app.yaml index 99a82f5..90ff503 100644 --- a/infra/container-app.yaml +++ b/infra/container-app.yaml @@ -31,6 +31,8 @@ properties: value: "${AZURE_STORAGE_CONNECTION_STRING}" - name: acr-password value: "${ACR_PASSWORD}" + - name: allowed-emails + value: "${ALLOWED_EMAILS}" registries: - server: ${ACR_NAME}.azurecr.io username: ${ACR_USERNAME} @@ -58,6 +60,8 @@ properties: secretRef: storage-connection-string - name: AZURE_STORAGE_CONTAINER_NAME value: summarization-uploads + - name: ALLOWED_EMAILS + secretRef: allowed-emails probes: - type: liveness httpGet: @@ -95,6 +99,8 @@ properties: secretRef: github-client-secret - name: PORT value: "3001" + - name: ALLOWED_EMAILS + secretRef: allowed-emails probes: - type: liveness httpGet: diff --git a/infra/provision.sh b/infra/provision.sh index d5382ea..18ae858 100755 --- a/infra/provision.sh +++ b/infra/provision.sh @@ -34,6 +34,7 @@ set -euo pipefail : "${GITHUB_CLIENT_SECRET:?Need GITHUB_CLIENT_SECRET}" : "${BETTER_AUTH_SECRET:?Need BETTER_AUTH_SECRET}" : "${AZURE_STORAGE_CONNECTION_STRING:?Need AZURE_STORAGE_CONNECTION_STRING}" +: "${ALLOWED_EMAILS:?Need ALLOWED_EMAILS}" # comma-separated, no spaces — shared by backend + auth-sidecar # --------------------------------------------------------------------------- # Substitute placeholders → generate ephemeral YAML, never written to disk From c2d78f71d4fe9886e98f070a6e8e72f667ba2d3c Mon Sep 17 00:00:00 2001 From: Jordan Leis Date: Fri, 10 Jul 2026 15:40:29 +0000 Subject: [PATCH 3/5] feat: unify upload limits to 50MB and add a 100-page cap The upload size cap was previously three different, inconsistent hardcoded values (nginx 50MB, backend header-check 25MB, backend post-read check 20MB), and the frontend claimed a 20MB limit without enforcing it client-side. Both backend checks now use one env-driven value (MAX_UPLOAD_SIZE_MB, default 50), matching nginx, and the frontend enforces + displays the same limit. Also adds a page-count cap (MAX_PAGES, default 100), enforced at upload time via PyMuPDF (already a backend dependency) since page count is otherwise only known after full, slower document processing. --- .env.example | 4 ++++ backend/api/files/router.py | 33 ++++++++++++++++++++++++++---- docker-compose.yml | 2 ++ frontend/components/UploadPage.tsx | 19 ++++++++++++----- infra/container-app.yaml | 4 ++++ 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 1bb6b22..23d6956 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,7 @@ AZURE_OPENAI_KEY= AZURE_OPENAI_DEPLOYMENT= AZURE_OPENAI_MODEL_NAME= AZURE_OPENAI_API_VERSION=2025-04-01-preview + +# Upload limits — enforced by the backend at /api/upload +MAX_UPLOAD_SIZE_MB=50 +MAX_PAGES=100 diff --git a/backend/api/files/router.py b/backend/api/files/router.py index 92a2d21..fec4e3f 100644 --- a/backend/api/files/router.py +++ b/backend/api/files/router.py @@ -1,5 +1,8 @@ """File management API endpoints with organized storage and deduplication""" +import os + +import fitz from fastapi import APIRouter, File, UploadFile, HTTPException, Request, Depends from fastapi.responses import JSONResponse, Response from typing import Optional @@ -48,10 +51,13 @@ async def upload_file( """ print(f"[UPLOAD] Request headers: {request.headers}") + max_upload_mb = int(os.environ.get("MAX_UPLOAD_SIZE_MB", "50")) + max_upload_bytes = max_upload_mb * 1024 * 1024 + # Check content-length header if "content-length" in request.headers: content_length = int(request.headers["content-length"]) - if content_length > 25 * 1024 * 1024: # 25MB + if content_length > max_upload_bytes: raise HTTPException( status_code=413, detail=( @@ -86,9 +92,28 @@ async def upload_file( detail="File is not a valid PDF. The file content does not match PDF format.", ) - # Validate file size (20MB limit) - if file_size > 20 * 1024 * 1024: - raise HTTPException(status_code=400, detail="File size exceeds 20MB limit") + # Validate file size + if file_size > max_upload_bytes: + raise HTTPException( + status_code=400, detail=f"File size exceeds {max_upload_mb}MB limit" + ) + + # Validate page count + max_pages = int(os.environ.get("MAX_PAGES", "100")) + try: + pdf = fitz.open(stream=content, filetype="pdf") + page_count = pdf.page_count + pdf.close() + except Exception: + raise HTTPException( + status_code=400, + detail="File is not a valid PDF. The file content does not match PDF format.", + ) + if page_count > max_pages: + raise HTTPException( + status_code=400, + detail=f"PDF has {page_count} pages, exceeds the {max_pages} page limit", + ) # Get user ID if authenticated user_id = current_user["id"] if current_user else None diff --git a/docker-compose.yml b/docker-compose.yml index 1cff42d..2afc016 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,8 @@ services: AZURE_DOC_INTELLIGENCE_ENDPOINT: ${AZURE_DOC_INTELLIGENCE_ENDPOINT:-} AZURE_DOC_INTELLIGENCE_KEY: ${AZURE_DOC_INTELLIGENCE_KEY:-} ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} + MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-50} + MAX_PAGES: ${MAX_PAGES:-100} ports: - "8001:8001" depends_on: diff --git a/frontend/components/UploadPage.tsx b/frontend/components/UploadPage.tsx index aa1c15f..1d71267 100644 --- a/frontend/components/UploadPage.tsx +++ b/frontend/components/UploadPage.tsx @@ -96,6 +96,7 @@ export function UploadPage({ }, [processingFiles.size, uploadingFiles.size]); const MAX_FILES = 10; + const MAX_FILE_SIZE_MB = 50; const allParsers = [ { @@ -157,12 +158,20 @@ export function UploadPage({ file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); - if (isPDF) { - console.log("File accepted:", file.name); - newFiles.push(file); - } else { + if (!isPDF) { console.log("File rejected - not a PDF:", file.name, file.type); + continue; } + + if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { + toast.warning( + `File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.` + ); + continue; + } + + console.log("File accepted:", file.name); + newFiles.push(file); } if (newFiles.length > 0) { @@ -602,7 +611,7 @@ export function UploadPage({

- Supports PDF files up to 20MB. Max {MAX_FILES} files. + Supports PDF files up to {MAX_FILE_SIZE_MB}MB. Max {MAX_FILES} files.

diff --git a/infra/container-app.yaml b/infra/container-app.yaml index 90ff503..636bb79 100644 --- a/infra/container-app.yaml +++ b/infra/container-app.yaml @@ -62,6 +62,10 @@ properties: value: summarization-uploads - name: ALLOWED_EMAILS secretRef: allowed-emails + - name: MAX_UPLOAD_SIZE_MB + value: "50" + - name: MAX_PAGES + value: "100" probes: - type: liveness httpGet: From 96aa363fc487379fb9aff5b3b06229f2112e11f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Jul 2026 17:51:37 +0000 Subject: [PATCH 4/5] style: auto-format with Prettier --- frontend/components/UploadPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/components/UploadPage.tsx b/frontend/components/UploadPage.tsx index 1d71267..f38f9f8 100644 --- a/frontend/components/UploadPage.tsx +++ b/frontend/components/UploadPage.tsx @@ -611,7 +611,8 @@ export function UploadPage({

- Supports PDF files up to {MAX_FILE_SIZE_MB}MB. Max {MAX_FILES} files. + Supports PDF files up to {MAX_FILE_SIZE_MB}MB. Max {MAX_FILES}{" "} + files.

From 38d83d6133bbd23dd55ef1439e53d2e175156066 Mon Sep 17 00:00:00 2001 From: Jordan Leis Date: Fri, 10 Jul 2026 19:00:51 +0000 Subject: [PATCH 5/5] fix: raise default MAX_PAGES from 100 to 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per feedback from evaluators (via Spencer/boss) — 100 pages was too restrictive for real studies (300+ page documents already showing good results), and 500 pairs reasonably with the existing 50MB size cap (~0.1MB/page). Still fully overridable via the MAX_PAGES env var, no code changes needed to tune it further. --- .env.example | 2 +- backend/api/files/router.py | 2 +- docker-compose.yml | 2 +- infra/container-app.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 23d6956..d516282 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,4 @@ AZURE_OPENAI_API_VERSION=2025-04-01-preview # Upload limits — enforced by the backend at /api/upload MAX_UPLOAD_SIZE_MB=50 -MAX_PAGES=100 +MAX_PAGES=500 diff --git a/backend/api/files/router.py b/backend/api/files/router.py index fec4e3f..6dd0f4f 100644 --- a/backend/api/files/router.py +++ b/backend/api/files/router.py @@ -99,7 +99,7 @@ async def upload_file( ) # Validate page count - max_pages = int(os.environ.get("MAX_PAGES", "100")) + max_pages = int(os.environ.get("MAX_PAGES", "500")) try: pdf = fitz.open(stream=content, filetype="pdf") page_count = pdf.page_count diff --git a/docker-compose.yml b/docker-compose.yml index 2afc016..b239356 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,7 +73,7 @@ services: AZURE_DOC_INTELLIGENCE_KEY: ${AZURE_DOC_INTELLIGENCE_KEY:-} ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-50} - MAX_PAGES: ${MAX_PAGES:-100} + MAX_PAGES: ${MAX_PAGES:-500} ports: - "8001:8001" depends_on: diff --git a/infra/container-app.yaml b/infra/container-app.yaml index 636bb79..694d341 100644 --- a/infra/container-app.yaml +++ b/infra/container-app.yaml @@ -65,7 +65,7 @@ properties: - name: MAX_UPLOAD_SIZE_MB value: "50" - name: MAX_PAGES - value: "100" + value: "500" probes: - type: liveness httpGet: