diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d516282 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# 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 + +# Upload limits — enforced by the backend at /api/upload +MAX_UPLOAD_SIZE_MB=50 +MAX_PAGES=500 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/backend/api/files/router.py b/backend/api/files/router.py index 92a2d21..6dd0f4f 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", "500")) + 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 29a7864..b239356 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,9 @@ 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:-} + MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-50} + MAX_PAGES: ${MAX_PAGES:-500} ports: - "8001:8001" depends_on: 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/components/UploadPage.tsx b/frontend/components/UploadPage.tsx index aa1c15f..f38f9f8 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,8 @@ 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/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"), diff --git a/infra/container-app.yaml b/infra/container-app.yaml index 99a82f5..694d341 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,12 @@ properties: secretRef: storage-connection-string - name: AZURE_STORAGE_CONTAINER_NAME value: summarization-uploads + - name: ALLOWED_EMAILS + secretRef: allowed-emails + - name: MAX_UPLOAD_SIZE_MB + value: "50" + - name: MAX_PAGES + value: "500" probes: - type: liveness httpGet: @@ -95,6 +103,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