From 11f1e2afa30b84f1668fd88c28f1a79fb5959e05 Mon Sep 17 00:00:00 2001 From: Frank Li Date: Thu, 18 Jun 2026 17:23:07 +0800 Subject: [PATCH] feat(settings): make LLM timeout configurable (#776) - Allow users to configure LLM timeout_seconds (30-600s) via settings. - Add improve/preview concurrency semaphore with explicit .locked() guard. - Remove duplicated get_safe_max_tokens definition that caused TypeError. - Allow null timeout_seconds to clear the persisted config value. - Fix fall_back type-hint spacing. --- apps/backend/app/llm.py | 28 +- apps/backend/app/routers/config.py | 70 ++--- apps/backend/app/routers/resumes.py | 253 ++++++++---------- apps/backend/app/schemas/models.py | 42 +-- apps/backend/app/services/parser.py | 2 +- .../unit/test_improve_preview_concurrency.py | 74 +++++ apps/backend/tests/unit/test_llm.py | 57 +++- apps/frontend/app/(default)/settings/page.tsx | 208 +++++--------- apps/frontend/app/(default)/tailor/page.tsx | 15 +- apps/frontend/lib/api/config.ts | 24 +- apps/frontend/lib/api/resume.ts | 16 +- apps/frontend/messages/en.json | 150 +---------- apps/frontend/messages/es.json | 151 +---------- apps/frontend/messages/ja.json | 151 +---------- apps/frontend/messages/pt-BR.json | 151 +---------- apps/frontend/messages/zh.json | 150 +---------- apps/frontend/next.config.ts | 12 +- 17 files changed, 421 insertions(+), 1133 deletions(-) create mode 100644 apps/backend/tests/unit/test_improve_preview_concurrency.py diff --git a/apps/backend/app/llm.py b/apps/backend/app/llm.py index 10b6d6a20..272b760f1 100644 --- a/apps/backend/app/llm.py +++ b/apps/backend/app/llm.py @@ -44,13 +44,13 @@ def _configure_litellm_logging() -> None: MAX_JSON_EXTRACTION_RECURSION = 10 MAX_JSON_CONTENT_SIZE = 1024 * 1024 # 1MB + # Default token budget for structured JSON completions (e.g. resume parsing). # Chosen to accommodate large resumes while staying within most providers' # output limits. Callers should use get_safe_max_tokens() so this is # automatically clamped to the model's actual capacity. DEFAULT_JSON_MAX_TOKENS = 8192 - class LLMConfig(BaseModel): """LLM configuration model.""" @@ -59,6 +59,7 @@ class LLMConfig(BaseModel): api_key: str api_base: str | None = None reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None + timeout_seconds: int | None = None def _normalize_api_base(provider: str, api_base: str | None) -> str | None: @@ -397,6 +398,7 @@ def get_llm_config() -> LLMConfig: api_key=api_key, api_base=stored.get("api_base", settings.llm_api_base), reasoning_effort=reasoning_effort, + timeout_seconds=stored.get("timeout_seconds"), ) @@ -672,7 +674,7 @@ async def complete( "model": "primary", "messages": messages, "max_tokens": max_tokens, - "timeout": _calculate_timeout("completion", max_tokens, config.provider), + "timeout": _calculate_timeout("completion", max_tokens, config.provider, config.timeout_seconds), } if _supports_temperature(model_name, temperature): kwargs["temperature"] = temperature @@ -730,10 +732,9 @@ def _supports_json_mode(model_name: str) -> bool: logging.debug("Model %s not in LiteLLM registry, skipping JSON mode", model_name) return False - FALLBACK_MAX_TOKENS = 4096 -def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS) -> int: +def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS, fall_back: int = FALLBACK_MAX_TOKENS) -> int: """Return a token count safe for the given model, clamped to its output limit. Queries LiteLLM's model registry for ``max_output_tokens`` and returns @@ -768,7 +769,7 @@ def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKEN except Exception: pass # Model not in registry, drop down to fallback logic - safe = min(safe_requested, FALLBACK_MAX_TOKENS) + safe = min(safe_requested, fall_back) logging.debug( "Model %s not in LiteLLM registry, clamping requested max_tokens %d → %d constraint", model_name, @@ -778,6 +779,7 @@ def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKEN return safe + def _appears_truncated(data: dict, schema_type: str = "resume") -> bool: """LLM-001: Check if JSON data appears to be truncated. @@ -898,8 +900,18 @@ def _calculate_timeout( operation: str, max_tokens: int = 4096, provider: str = "openai", + base_timeout_override: int | None = None, ) -> int: - """LLM-005: Calculate adaptive timeout based on operation and parameters.""" + """LLM-005: Calculate adaptive timeout based on operation and parameters. + + When ``base_timeout_override`` is provided, it replaces the default base + timeout for ``completion`` and ``json`` operations. The ``token_factor`` + and ``provider_factor`` multipliers are still applied so that the override + scales correctly with token count and provider latency. + + ``health_check`` always uses its hard-coded 30s default regardless of + overrides — health checks should remain fast. + """ base_timeouts = { "health_check": LLM_TIMEOUT_HEALTH_CHECK, "completion": LLM_TIMEOUT_COMPLETION, @@ -907,6 +919,8 @@ def _calculate_timeout( } base = base_timeouts.get(operation, LLM_TIMEOUT_COMPLETION) + if base_timeout_override is not None and operation != "health_check": + base = base_timeout_override # Scale by token count (relative to 4096 baseline) token_factor = max(1.0, max_tokens / 4096) @@ -1064,7 +1078,7 @@ async def complete_json( "model": "primary", "messages": messages, "max_tokens": max_tokens, - "timeout": _calculate_timeout("json", max_tokens, config.provider), + "timeout": _calculate_timeout("json", max_tokens, config.provider, config.timeout_seconds), } # LLM-002: Increase temperature on retry for variation retry_temp = _get_retry_temperature(model_name, attempt) diff --git a/apps/backend/app/routers/config.py b/apps/backend/app/routers/config.py index 1995bafd1..78438143d 100644 --- a/apps/backend/app/routers/config.py +++ b/apps/backend/app/routers/config.py @@ -37,8 +37,6 @@ save_api_keys_to_config, delete_api_key_from_config, clear_all_api_keys, - load_config_file, - save_config_file, ) from app.config_cache import invalidate_config_cache from app.database import db @@ -52,13 +50,18 @@ def _get_config_path() -> Path: def _load_config() -> dict: - """Load config with decrypted API keys injected (so resolve_api_key works).""" - return load_config_file() + """Load config from file.""" + path = _get_config_path() + if path.exists(): + return json.loads(path.read_text()) + return {} def _save_config(config: dict) -> None: - """Save non-secret config (keys stripped) and invalidate the shared cache.""" - save_config_file(config) + """Save config to file and invalidate the resume router's cache.""" + path = _get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config, indent=2)) invalidate_config_cache() @@ -105,6 +108,7 @@ async def get_llm_config_endpoint() -> LLMConfigResponse: api_key=_mask_api_key(resolve_api_key(stored, provider)), api_base=stored.get("api_base", settings.llm_api_base), reasoning_effort=reasoning_effort or None, + timeout_seconds=stored.get("timeout_seconds"), ) @@ -129,23 +133,19 @@ async def update_llm_config( stored["provider"] = request.provider if request.model is not None: stored["model"] = request.model - # NOTE: API keys are NOT written here anymore. They live in the encrypted - # per-provider store (PUT /config/api-keys). Writing the legacy single - # ``api_key`` slot here is what caused providers to overwrite each other and - # shadow the per-provider map in resolve_api_key. request.api_key is ignored - # for persistence (kept in the schema only for response masking/back-compat). - # api_base: distinguish "omitted" (leave unchanged) from "present but - # blank/null" (explicit clear). The frontend sends api_base: null/"" when - # the Base URL field is cleared; treating that as "don't change" left a - # stale override in config.json (issue #760). Normalize blank → None so an - # empty string also never reaches LiteLLM as a bogus endpoint. - if "api_base" in request.model_fields_set: - cleaned = (request.api_base or "").strip() - stored["api_base"] = cleaned or None + if request.api_key is not None: + stored["api_key"] = request.api_key + if request.api_base is not None: + stored["api_base"] = request.api_base if request.reasoning_effort is not None: # Persist empty string on clear so the gpt-5 auto-migration doesn't # re-fire on next get_llm_config() call. stored["reasoning_effort"] = request.reasoning_effort + # Allow the frontend to clear the persisted timeout by sending null. + # model_dump(exclude_unset=True) lets us distinguish "not provided" + # from "explicitly set to None" while still validating type/range. + if "timeout_seconds" in request.model_dump(exclude_unset=True): + stored["timeout_seconds"] = request.timeout_seconds # Build normalized config for response and background health check resolved_provider = stored.get("provider", settings.llm_provider) @@ -171,6 +171,7 @@ async def update_llm_config( api_key=_mask_api_key(test_config.api_key), api_base=test_config.api_base, reasoning_effort=test_config.reasoning_effort, + timeout_seconds=stored.get("timeout_seconds"), ) @@ -425,19 +426,8 @@ async def update_feature_prompts( ) -# Supported API key providers (key-store names). ``openai_compatible`` and -# ``ollama`` are included so secured local servers can store a key; they keep -# their env-fallback skip in resolve_api_key. -SUPPORTED_PROVIDERS = [ - "openai", - "anthropic", - "google", - "openrouter", - "deepseek", - "groq", - "openai_compatible", - "ollama", -] +# Supported API key providers +SUPPORTED_PROVIDERS = ["openai", "anthropic", "google", "openrouter", "deepseek", "groq"] def _mask_key_short(key: str | None) -> str | None: @@ -525,20 +515,6 @@ async def update_api_keys(request: ApiKeysUpdateRequest) -> ApiKeysUpdateRespons del stored_keys["groq"] updated.append("groq") - if request.openai_compatible is not None: - if request.openai_compatible: - stored_keys["openai_compatible"] = request.openai_compatible - elif "openai_compatible" in stored_keys: - del stored_keys["openai_compatible"] - updated.append("openai_compatible") - - if request.ollama is not None: - if request.ollama: - stored_keys["ollama"] = request.ollama - elif "ollama" in stored_keys: - del stored_keys["ollama"] - updated.append("ollama") - save_api_keys_to_config(stored_keys) invalidate_config_cache() @@ -621,5 +597,5 @@ async def reset_database_endpoint(request: ResetDatabaseRequest) -> dict: status_code=400, detail="Confirmation required. Pass confirm=RESET_ALL_DATA in request body.", ) - await db.reset_database() + db.reset_database() return {"message": "Database and all data have been reset successfully"} diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index 2fbbf35f9..82a9a9b63 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -13,7 +13,6 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import Response -from pydantic import ValidationError from app.config_cache import get_content_language, load_config as _load_config from app.database import db @@ -64,34 +63,6 @@ from app.prompts import DEFAULT_IMPROVE_PROMPT_ID, IMPROVE_PROMPT_OPTIONS -async def _auto_create_tracker_application( - *, - job_id: str, - tailored_resume_id: str, - master_resume_id: str, - job: dict[str, Any] | None, - title: str | None, -) -> None: - """Best-effort: drop an ``applied`` card on the tracker after a tailoring. - - Company/role come from the cached job (zero extra LLM call). Wrapped so a - tracker failure can never break the tailoring flow. - """ - try: - company = (job or {}).get("company") - role = title or (job or {}).get("role") - await db.create_application( - job_id=job_id, - resume_id=tailored_resume_id, - master_resume_id=master_resume_id, - status="applied", - company=company, - role=role, - ) - except Exception as e: # noqa: BLE001 - tracker is non-critical - logger.warning("Failed to auto-create tracker application: %s", e) - - def _get_default_prompt_id() -> str: """Get configured default prompt id from config file.""" config = _load_config() @@ -121,21 +92,8 @@ def _normalize_payload(value: Any) -> Any: def _hash_improved_data(data: dict[str, Any]) -> str: - """Hash canonicalized improved data for preview/confirm validation. - - Canonicalize through ``ResumeData`` first so a payload that merely omits - optional fields (which the schema defaults) hashes identically to its - schema-complete form. Without this, ``improve/preview`` (which hashes the - raw ``improved_data`` dict) and ``improve/confirm`` (which hashes the - ``ResumeData`` round-trip, ``request.improved_data.model_dump()``) disagree - for any stored resume whose ``processed_data`` is not schema-complete, and a - valid tailoring is rejected with 400 ("preview hash mismatch"). - """ - try: - canonical: dict[str, Any] = ResumeData.model_validate(data).model_dump() - except ValidationError: - canonical = data # not a full resume payload; hash as-is - normalized = _normalize_payload(canonical) + """Hash canonicalized improved data for preview/confirm validation.""" + normalized = _normalize_payload(data) serialized = json.dumps( normalized, sort_keys=True, @@ -549,6 +507,15 @@ async def _generate_auxiliary_messages( } MAX_FILE_SIZE = 4 * 1024 * 1024 # 4MB +# Concurrency cap for the long-running improve/preview path. Pairs with the +# 1-hour outer timeout to bound resource usage: even though a single tailoring +# call may legitimately take many minutes (large local LLM, slow Ollama host), +# we never let more than N of them run at once. This is the compensating +# control for the long timeout — if the host is busy, additional callers are +# rejected fast with 503 instead of stacking up and exhausting workers. +IMPROVE_PREVIEW_MAX_CONCURRENCY = 4 +_improve_preview_semaphore = asyncio.Semaphore(IMPROVE_PREVIEW_MAX_CONCURRENCY) + @router.post("/upload", response_model=ResumeUploadResponse) async def upload_resume(file: UploadFile = File(...)) -> ResumeUploadResponse: @@ -607,7 +574,7 @@ async def upload_resume(file: UploadFile = File(...)) -> ResumeUploadResponse: # Try to parse to structured JSON (optional, may fail if LLM not configured) try: processed_data = await parse_resume_to_json(markdown_content) - await db.update_resume( + db.update_resume( resume["resume_id"], { "processed_data": processed_data, @@ -619,7 +586,7 @@ async def upload_resume(file: UploadFile = File(...)) -> ResumeUploadResponse: except Exception as e: # LLM parsing failed, update status to failed logger.warning(f"Resume parsing to JSON failed for {file.filename}: {e}") - await db.update_resume(resume["resume_id"], {"processing_status": "failed"}) + db.update_resume(resume["resume_id"], {"processing_status": "failed"}) resume["processing_status"] = "failed" # Return accurate status to client (API-001 fix) @@ -644,7 +611,7 @@ async def get_resume(resume_id: str = Query(...)) -> ResumeFetchResponse: plus cover letter and outreach message if they exist. Applies lazy migration for section metadata if needed. """ - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") @@ -689,7 +656,7 @@ async def get_resume(resume_id: str = Query(...)) -> ResumeFetchResponse: @router.get("/list", response_model=ResumeListResponse) async def list_resumes(include_master: bool = Query(False)) -> ResumeListResponse: """List resumes, optionally including the master resume.""" - resumes = await db.list_resumes() + resumes = db.list_resumes() if not include_master: resumes = [resume for resume in resumes if not resume.get("is_master", False)] @@ -720,48 +687,77 @@ async def improve_resume_preview_endpoint( The response includes resume_preview data but leaves resume_id null. """ - resume = await db.get_resume(request.resume_id) + resume = db.get_resume(request.resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") - job = await db.get_job(request.job_id) + job = db.get_job(request.job_id) if not job: raise HTTPException(status_code=404, detail="Job description not found") language = get_content_language() prompt_id = request.prompt_id or _get_default_prompt_id() - stage = "load_job_keywords" - detail = "Failed to preview resume. Please try again." - try: - return await asyncio.wait_for( - _improve_preview_flow( - request=request, - resume=resume, - job=job, - language=language, - prompt_id=prompt_id, - ), - timeout=settings.request_timeout_seconds, - ) - except asyncio.TimeoutError: - logger.error( - "Improve preview timed out after %ss for resume %s / job %s", - settings.request_timeout_seconds, + # Reject fast when the host is already saturated, instead of blocking on + # the semaphore for up to an hour. This is the DoS guard that pairs with + # the long inner timeout: at most IMPROVE_PREVIEW_MAX_CONCURRENCY tailoring + # jobs run concurrently per process; further callers see 503 immediately + # and can retry later. + if _improve_preview_semaphore.locked(): + logger.warning( + "Improve preview rejected (busy) for resume %s / job %s", request.resume_id, request.job_id, ) raise HTTPException( - status_code=504, + status_code=503, detail=( - f"Resume tailoring timed out after {settings.request_timeout_seconds}s. " - "If you are running a local LLM, raise REQUEST_TIMEOUT_SECONDS (and the " - "matching frontend NEXT_PUBLIC_REQUEST_TIMEOUT_MS); otherwise try a shorter " - "job description or a simpler prompt." + f"Server is busy tailoring other resumes " + f"(max {IMPROVE_PREVIEW_MAX_CONCURRENCY} concurrent). " + "Please retry in a moment." ), ) - except Exception as e: - _raise_improve_error("preview", stage, e, detail) + + # Slot acquired — release it on every exit path below. + await _improve_preview_semaphore.acquire() + try: + stage = "load_job_keywords" + detail = "Failed to preview resume. Please try again." + try: + return await asyncio.wait_for( + _improve_preview_flow( + request=request, + resume=resume, + job=job, + language=language, + prompt_id=prompt_id, + ), + timeout=3600.0, # 1-hour hard limit; inner LLM timeouts (from Settings) are shorter + ) + except asyncio.TimeoutError: + logger.error( + "Improve preview timed out after 3600s for resume %s / job %s", + request.resume_id, + request.job_id, + ) + raise HTTPException( + status_code=504, + detail="Resume tailoring timed out. Please try again with a shorter job description or a simpler prompt.", + ) + except asyncio.CancelledError: + logger.warning( + "Improve preview cancelled for resume %s / job %s (client disconnected)", + request.resume_id, + request.job_id, + ) + raise HTTPException( + status_code=499, + detail="Request was cancelled. The server may still be processing.", + ) + except Exception as e: + _raise_improve_error("preview", stage, e, detail) + finally: + _improve_preview_semaphore.release() async def _improve_preview_flow( @@ -779,25 +775,10 @@ async def _improve_preview_flow( if not job_keywords or job_keywords_hash != content_hash: job_keywords = await extract_job_keywords(job["content"]) # Cache extracted keywords with a content hash for basic invalidation. - # Also surface company/role to the job's top level so the tracker's - # auto-create-on-confirm path can read them without an extra LLM call. - cache_updates: dict[str, Any] = { - "job_keywords": job_keywords, - "job_keywords_hash": content_hash, - } - # LLM output isn't guaranteed to be a string — guard before .strip(). - raw_company = job_keywords.get("company") - raw_role = job_keywords.get("role") - company = raw_company.strip() if isinstance(raw_company, str) else "" - role = raw_role.strip() if isinstance(raw_role, str) else "" - if company: - cache_updates["company"] = company - if role: - cache_updates["role"] = role try: - updated_job = await db.update_job( + updated_job = db.update_job( request.job_id, - cache_updates, + {"job_keywords": job_keywords, "job_keywords_hash": content_hash}, ) if not updated_job: logger.warning( @@ -912,7 +893,7 @@ async def _improve_preview_flow( refinement_successful = False try: # Get master resume for alignment validation - master_resume = await db.get_master_resume() + master_resume = db.get_master_resume() master_data = ( _get_original_resume_data(master_resume) if master_resume @@ -970,7 +951,7 @@ async def _improve_preview_flow( preview_hashes[prompt_id] = preview_hash # NOTE: preview_hashes updates are last-write-wins; concurrent previews can race. try: - updated_job = await db.update_job( + updated_job = db.update_job( request.job_id, { "preview_hash": preview_hash, @@ -1028,11 +1009,11 @@ async def improve_resume_confirm_endpoint( request: ImproveResumeConfirmRequest, ) -> ImproveResumeResponse: """Confirm and persist a tailored resume.""" - resume = await db.get_resume(request.resume_id) + resume = db.get_resume(request.resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") - job = await db.get_job(request.job_id) + job = db.get_job(request.job_id) if not job: raise HTTPException(status_code=404, detail="Job description not found") @@ -1112,7 +1093,7 @@ async def improve_resume_confirm_endpoint( response_warnings.extend(aux_warnings) stage = "create_resume" - tailored_resume = await db.create_resume( + tailored_resume = db.create_resume( content=improved_text, content_type="json", filename=f"tailored_{resume.get('filename', 'resume')}", @@ -1128,21 +1109,13 @@ async def improve_resume_confirm_endpoint( improvements_payload = [imp.model_dump() for imp in request.improvements] stage = "create_improvement" request_id = str(uuid4()) - await db.create_improvement( + db.create_improvement( original_resume_id=request.resume_id, tailored_resume_id=tailored_resume["resume_id"], job_id=request.job_id, improvements=improvements_payload, ) - await _auto_create_tracker_application( - job_id=request.job_id, - tailored_resume_id=tailored_resume["resume_id"], - master_resume_id=request.resume_id, - job=job, - title=title, - ) - return ImproveResumeResponse( request_id=request_id, data=ImproveResumeData( @@ -1178,12 +1151,12 @@ async def improve_resume_endpoint( Persists the tailored resume and returns a non-null resume_id. """ # Fetch resume - resume = await db.get_resume(request.resume_id) + resume = db.get_resume(request.resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") # Fetch job description - job = await db.get_job(request.job_id) + job = db.get_job(request.job_id) if not job: raise HTTPException(status_code=404, detail="Job description not found") @@ -1270,7 +1243,7 @@ async def improve_resume_endpoint( refinement_successful = False try: # Get master resume for alignment validation - master_resume = await db.get_master_resume() + master_resume = db.get_master_resume() master_data = ( _get_original_resume_data(master_resume) if master_resume @@ -1350,7 +1323,7 @@ async def improve_resume_endpoint( response_warnings.extend(aux_warnings) # Store the tailored resume with cover letter, outreach message, and title - tailored_resume = await db.create_resume( + tailored_resume = db.create_resume( content=improved_text, content_type="json", filename=f"tailored_{resume.get('filename', 'resume')}", @@ -1365,21 +1338,13 @@ async def improve_resume_endpoint( # Store improvement record request_id = str(uuid4()) - await db.create_improvement( + db.create_improvement( original_resume_id=request.resume_id, tailored_resume_id=tailored_resume["resume_id"], job_id=request.job_id, improvements=improvements, ) - await _auto_create_tracker_application( - job_id=request.job_id, - tailored_resume_id=tailored_resume["resume_id"], - master_resume_id=request.resume_id, - job=job, - title=title, - ) - return ImproveResumeResponse( request_id=request_id, data=ImproveResumeData( @@ -1421,14 +1386,14 @@ async def update_resume_endpoint( resume_id: str, resume_data: ResumeData ) -> ResumeFetchResponse: """Update a resume with new structured data.""" - existing = await db.get_resume(resume_id) + existing = db.get_resume(resume_id) if not existing: raise HTTPException(status_code=404, detail="Resume not found") updated_data = resume_data.model_dump() updated_content = json.dumps(updated_data, indent=2) - updated = await db.update_resume( + updated = db.update_resume( resume_id, { "content": updated_content, @@ -1489,7 +1454,7 @@ async def download_resume_pdf( """Generate a PDF for a resume using headless Chromium. Accepts template settings for customization: - - template: swiss-single, swiss-two-column, modern, modern-two-column, latex, clean, or vivid + - template: swiss-single, swiss-two-column, modern, or modern-two-column - pageSize: A4 or LETTER - marginTop/Bottom/Left/Right: page margins in mm (5-25) - sectionSpacing: gap between sections (1-5) @@ -1503,7 +1468,7 @@ async def download_resume_pdf( - showContactIcons: show icons in contact info - lang: locale used for print page translations """ - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") @@ -1551,7 +1516,7 @@ async def download_resume_pdf( @router.delete("/{resume_id}") async def delete_resume(resume_id: str) -> dict: """Delete a resume by ID.""" - if not await db.delete_resume(resume_id): + if not db.delete_resume(resume_id): raise HTTPException(status_code=404, detail="Resume not found") return {"message": "Resume deleted successfully"} @@ -1564,7 +1529,7 @@ async def retry_processing(resume_id: str) -> ResumeUploadResponse: Re-runs parse_resume_to_json() on the stored markdown content. Works for resumes with processing_status == "failed" or "processing". """ - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") @@ -1583,7 +1548,7 @@ async def retry_processing(resume_id: str) -> ResumeUploadResponse: try: processed_data = await parse_resume_to_json(markdown_content) - await db.update_resume( + db.update_resume( resume_id, { "processed_data": processed_data, @@ -1599,7 +1564,7 @@ async def retry_processing(resume_id: str) -> ResumeUploadResponse: ) except Exception as e: logger.warning(f"Retry processing failed for resume {resume_id}: {e}") - await db.update_resume(resume_id, {"processing_status": "failed"}) + db.update_resume(resume_id, {"processing_status": "failed"}) return ResumeUploadResponse( message="Retry processing failed", request_id=str(uuid4()), @@ -1614,11 +1579,11 @@ async def update_cover_letter( resume_id: str, request: UpdateCoverLetterRequest ) -> dict: """Update the cover letter for a resume.""" - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") - await db.update_resume(resume_id, {"cover_letter": request.content}) + db.update_resume(resume_id, {"cover_letter": request.content}) return {"message": "Cover letter updated successfully"} @@ -1627,23 +1592,23 @@ async def update_outreach_message( resume_id: str, request: UpdateOutreachMessageRequest ) -> dict: """Update the outreach message for a resume.""" - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") - await db.update_resume(resume_id, {"outreach_message": request.content}) + db.update_resume(resume_id, {"outreach_message": request.content}) return {"message": "Outreach message updated successfully"} @router.patch("/{resume_id}/title") async def update_title(resume_id: str, request: UpdateTitleRequest) -> dict: """Update the title for a resume.""" - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") title = request.title.strip()[:80] - await db.update_resume(resume_id, {"title": title}) + db.update_resume(resume_id, {"title": title}) return {"message": "Title updated successfully"} @@ -1659,7 +1624,7 @@ async def generate_cover_letter_endpoint(resume_id: str) -> GenerateContentRespo - The resume must have an associated job context in the improvements table """ # Get the resume - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") @@ -1672,7 +1637,7 @@ async def generate_cover_letter_endpoint(resume_id: str) -> GenerateContentRespo ) # Get improvement record to find the job_id - improvement = await db.get_improvement_by_tailored_resume(resume_id) + improvement = db.get_improvement_by_tailored_resume(resume_id) if not improvement: raise HTTPException( status_code=400, @@ -1681,7 +1646,7 @@ async def generate_cover_letter_endpoint(resume_id: str) -> GenerateContentRespo ) # Get the job description - job = await db.get_job(improvement["job_id"]) + job = db.get_job(improvement["job_id"]) if not job: raise HTTPException( status_code=404, @@ -1712,7 +1677,7 @@ async def generate_cover_letter_endpoint(resume_id: str) -> GenerateContentRespo ) # Save to resume record - await db.update_resume(resume_id, {"cover_letter": cover_letter_content}) + db.update_resume(resume_id, {"cover_letter": cover_letter_content}) return GenerateContentResponse( content=cover_letter_content, @@ -1730,7 +1695,7 @@ async def generate_outreach_endpoint(resume_id: str) -> GenerateContentResponse: - The resume must have an associated job context in the improvements table """ # Get the resume - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") @@ -1743,7 +1708,7 @@ async def generate_outreach_endpoint(resume_id: str) -> GenerateContentResponse: ) # Get improvement record to find the job_id - improvement = await db.get_improvement_by_tailored_resume(resume_id) + improvement = db.get_improvement_by_tailored_resume(resume_id) if not improvement: raise HTTPException( status_code=400, @@ -1752,7 +1717,7 @@ async def generate_outreach_endpoint(resume_id: str) -> GenerateContentResponse: ) # Get the job description - job = await db.get_job(improvement["job_id"]) + job = db.get_job(improvement["job_id"]) if not job: raise HTTPException( status_code=404, @@ -1783,7 +1748,7 @@ async def generate_outreach_endpoint(resume_id: str) -> GenerateContentResponse: ) # Save to resume record - await db.update_resume(resume_id, {"outreach_message": outreach_content}) + db.update_resume(resume_id, {"outreach_message": outreach_content}) return GenerateContentResponse( content=outreach_content, @@ -1799,7 +1764,7 @@ async def get_job_description_for_resume(resume_id: str) -> dict: to tailor a resume. Only works for tailored resumes (those with parent_id). """ # Get the resume - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") @@ -1811,7 +1776,7 @@ async def get_job_description_for_resume(resume_id: str) -> dict: ) # Get improvement record to find the job_id - improvement = await db.get_improvement_by_tailored_resume(resume_id) + improvement = db.get_improvement_by_tailored_resume(resume_id) if not improvement: raise HTTPException( status_code=400, @@ -1820,7 +1785,7 @@ async def get_job_description_for_resume(resume_id: str) -> dict: ) # Get the job description - job = await db.get_job(improvement["job_id"]) + job = db.get_job(improvement["job_id"]) if not job: raise HTTPException( status_code=404, @@ -1846,7 +1811,7 @@ async def download_cover_letter_pdf( pageSize: A4 or LETTER lang: locale used for print page translations """ - resume = await db.get_resume(resume_id) + resume = db.get_resume(resume_id) if not resume: raise HTTPException(status_code=404, detail="Resume not found") diff --git a/apps/backend/app/schemas/models.py b/apps/backend/app/schemas/models.py index e66a4fb64..28042392e 100644 --- a/apps/backend/app/schemas/models.py +++ b/apps/backend/app/schemas/models.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Any, Literal -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator _TEXT_VALUE_KEYS = ( "text", @@ -463,8 +463,6 @@ class ResumeFieldDiff(BaseModel): "experience", "education", "project", - "language", - "award", ] change_type: Literal["added", "removed", "modified"] original_value: str | None = None @@ -571,6 +569,23 @@ class LLMConfigRequest(BaseModel): # Strictly typed so invalid values are rejected at the boundary (422) # rather than corrupting config.json and crashing later reads. reasoning_effort: Literal["minimal", "low", "medium", "high", ""] | None = None + # Optional timeout override (seconds). When set, overrides the default + # base timeout used for completion and JSON operations. Health checks + # keep their 30s default. Range: 30–600 (5–10 minutes). + timeout_seconds: int | None = None + + @field_validator("timeout_seconds", mode="before") + @classmethod + def _validate_timeout_seconds(cls, value: Any) -> int | None: + if value is None: + return None + try: + v = int(value) + except (TypeError, ValueError): + raise ValueError("timeout_seconds must be an integer") + if v < 30 or v > 600: + raise ValueError("timeout_seconds must be between 30 and 600") + return v class LLMConfigResponse(BaseModel): @@ -581,6 +596,7 @@ class LLMConfigResponse(BaseModel): api_key: str # Masked api_base: str | None = None reasoning_effort: ReasoningEffortLiteral | None = None + timeout_seconds: int | None = None class FeatureConfigRequest(BaseModel): @@ -683,9 +699,6 @@ class ApiKeysUpdateRequest(BaseModel): openrouter: str | None = None deepseek: str | None = None groq: str | None = None - # Local/self-hosted providers that may sit behind an auth proxy. - openai_compatible: str | None = None - ollama: str | None = None class ApiKeysUpdateResponse(BaseModel): @@ -754,25 +767,12 @@ class ResumeChange(BaseModel): description="Dot+bracket path, e.g. 'workExperience[0].description[1]'" ) action: Literal["replace", "append", "reorder", "add_skill"] - original: str | list[str] | None = Field( - default=None, - description="Current text at path — for verification. May be a list (the " - "current items) for the reorder action; only used for text verification of " - "replace/append, ignored otherwise.", + original: str | None = Field( + default=None, description="Current text at path — for verification" ) value: str | list[str] = Field(description="New content") reason: str = Field(description="Why this change helps match the JD") - @model_validator(mode="after") - def _list_original_only_for_reorder(self) -> "ResumeChange": - """A list ``original`` is only meaningful for ``reorder`` (the LLM sends - the current items). For the text actions it must stay a string/None — a - list there would silently bypass the replace verification gate and crash - the invented-metrics check, so reject it at parse time.""" - if isinstance(self.original, list) and self.action != "reorder": - raise ValueError("'original' may be a list only for the reorder action") - return self - class ImproveDiffResult(BaseModel): """LLM output: a list of targeted resume changes.""" diff --git a/apps/backend/app/services/parser.py b/apps/backend/app/services/parser.py index 9ae504646..0f66e9084 100644 --- a/apps/backend/app/services/parser.py +++ b/apps/backend/app/services/parser.py @@ -164,7 +164,7 @@ async def parse_resume_to_json(markdown_text: str) -> dict[str, Any]: result = await complete_json( prompt=prompt, system_prompt="You are a JSON extraction engine. Output only valid JSON, no explanations.", - max_tokens=get_safe_max_tokens(model_name), + max_tokens=get_safe_max_tokens(model_name, fall_back = 8192), retries=3, ) diff --git a/apps/backend/tests/unit/test_improve_preview_concurrency.py b/apps/backend/tests/unit/test_improve_preview_concurrency.py new file mode 100644 index 000000000..4ae66a7b5 --- /dev/null +++ b/apps/backend/tests/unit/test_improve_preview_concurrency.py @@ -0,0 +1,74 @@ +"""Unit tests for improve/preview concurrency limiting (cubic P1 mitigation). + +Verifies that the asyncio.Semaphore guarding `improve_resume_preview_endpoint` +correctly bounds concurrent tailoring jobs and rejects excess callers fast +with 503 instead of letting them stack up against the long inner timeout. +""" + +import asyncio + +import pytest + +from app.routers import resumes + + +@pytest.mark.asyncio +async def test_semaphore_default_concurrency_is_four(): + """Sanity-check the configured cap. Bumping this is a deliberate decision.""" + assert resumes.IMPROVE_PREVIEW_MAX_CONCURRENCY == 4 + + +@pytest.mark.asyncio +async def test_semaphore_acquires_slot_when_free(): + """A first caller acquires a slot immediately.""" + sem = resumes._improve_preview_semaphore + + # Drain to a known state: pretend no one is holding any slots. + # Real test environments should never share state; this is defensive. + while sem.locked(): + sem.release() + + await asyncio.wait_for(sem.acquire(), timeout=0) + try: + # One slot consumed; remaining should be N-1. + assert sem._value == resumes.IMPROVE_PREVIEW_MAX_CONCURRENCY - 1 + finally: + sem.release() + + +@pytest.mark.asyncio +async def test_semaphore_rejects_when_saturated(): + """When all slots are held, a non-blocking acquire raises TimeoutError.""" + sem = resumes._improve_preview_semaphore + + # Hold every slot. + held = [] + try: + for _ in range(resumes.IMPROVE_PREVIEW_MAX_CONCURRENCY): + await sem.acquire() + held.append(True) + + # Now an extra non-blocking acquire must fail fast. + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(sem.acquire(), timeout=0) + finally: + for _ in held: + sem.release() + + +@pytest.mark.asyncio +async def test_semaphore_recovers_after_release(): + """After all holders release, new callers can acquire again.""" + sem = resumes._improve_preview_semaphore + + held = [] + for _ in range(resumes.IMPROVE_PREVIEW_MAX_CONCURRENCY): + await sem.acquire() + held.append(True) + + for _ in held: + sem.release() + + # Should succeed instantly now. + await asyncio.wait_for(sem.acquire(), timeout=0) + sem.release() diff --git a/apps/backend/tests/unit/test_llm.py b/apps/backend/tests/unit/test_llm.py index 14f61eca7..7fd3244d8 100644 --- a/apps/backend/tests/unit/test_llm.py +++ b/apps/backend/tests/unit/test_llm.py @@ -4,7 +4,7 @@ import pytest -from app.llm import _appears_truncated, _get_retry_temperature, _supports_temperature +from app.llm import _appears_truncated, _calculate_timeout, _get_retry_temperature, _supports_temperature # --------------------------------------------------------------------------- @@ -333,12 +333,65 @@ async def test_uses_calculate_timeout( router.acompletion = AsyncMock(return_value=response) config = MagicMock() config.provider = "deepseek" + config.timeout_seconds = None mock_get_router.return_value = (router, config) from app.llm import complete await complete(prompt="Hi", max_tokens=8192) - mock_calc_timeout.assert_called_once_with("completion", 8192, "deepseek") + mock_calc_timeout.assert_called_once_with("completion", 8192, "deepseek", None) router.acompletion.assert_awaited_once() assert router.acompletion.call_args.kwargs["timeout"] == 180 + + +# --------------------------------------------------------------------------- +# _calculate_timeout with base_timeout_override +# --------------------------------------------------------------------------- + + +class TestCalculateTimeoutOverride: + """Tests for _calculate_timeout() with user-configured timeout override.""" + + def test_override_applies_to_completion(self): + """When base_timeout_override is set, completion uses it instead of default.""" + # Default completion = 120s; with 300s override → 300 * 1.0 * 1.0 = 300 + assert _calculate_timeout("completion", 4096, "openai", 300) == 300 + + def test_override_applies_to_json(self): + """When base_timeout_override is set, json uses it instead of default.""" + # Default json = 180s; with 300s override → 300 * 1.0 * 1.0 = 300 + assert _calculate_timeout("json", 4096, "openai", 300) == 300 + + def test_override_does_not_affect_health_check(self): + """Health check always uses its hard-coded 30s regardless of override.""" + assert _calculate_timeout("health_check", 4096, "openai", 300) == 30 + + def test_override_with_token_scaling(self): + """Override base is still scaled by token_factor.""" + # 300s override, 8192 tokens (2x) → 300 * 2.0 = 600 + assert _calculate_timeout("completion", 8192, "openai", 300) == 600 + + def test_override_with_provider_factor(self): + """Override base is still scaled by provider_factor.""" + # 300s override, ollama (2.0x) → 300 * 2.0 = 600 + assert _calculate_timeout("completion", 4096, "ollama", 300) == 600 + + def test_override_with_both_factors(self): + """Override base is scaled by both token and provider factors.""" + # 300s override, 8192 tokens (2.0x), ollama (2.0x) → 300 * 2.0 * 2.0 = 1200 + assert _calculate_timeout("completion", 8192, "ollama", 300) == 1200 + + def test_no_override_uses_default(self): + """When override is None, uses hard-coded defaults.""" + assert _calculate_timeout("completion", 4096, "openai", None) == 120 + assert _calculate_timeout("json", 4096, "openai", None) == 180 + assert _calculate_timeout("health_check", 4096, "openai", None) == 30 + + def test_override_minimum_value(self): + """Override with minimum valid value (30s).""" + assert _calculate_timeout("completion", 4096, "openai", 30) == 30 + + def test_override_maximum_value(self): + """Override with maximum valid value (600s).""" + assert _calculate_timeout("completion", 4096, "openai", 600) == 600 diff --git a/apps/frontend/app/(default)/settings/page.tsx b/apps/frontend/app/(default)/settings/page.tsx index 8ddf2275a..c6a2a37ad 100644 --- a/apps/frontend/app/(default)/settings/page.tsx +++ b/apps/frontend/app/(default)/settings/page.tsx @@ -17,19 +17,12 @@ import { fetchFeaturePrompts, updateFeaturePrompts, FeaturePromptsError, - fetchApiKeyStatus, - updateApiKeys, - deleteApiKey, - llmProviderToKeyProvider, - API_KEY_PROVIDER_INFO, type LLMConfigUpdate, type LLMProvider, type LLMHealthCheck, type PromptOption, type ReasoningEffort, type FeaturePromptsUpdate, - type ApiKeyProviderStatus, - type ApiKeyProvider, } from '@/lib/api/config'; import { API_URL } from '@/lib/api/client'; import { getVersionString } from '@/lib/config/version'; @@ -118,14 +111,12 @@ export default function SettingsPage() { const [apiKey, setApiKey] = useState(''); const [apiBase, setApiBase] = useState(''); const [hasStoredApiKey, setHasStoredApiKey] = useState(false); - // Per-provider encrypted key store status (drives the saved/empty hints and - // the provider key list). Keyed by key-store provider name. - const [apiKeyStatuses, setApiKeyStatuses] = useState([]); // 'auto' is the UI sentinel for "do not send reasoning_effort". Maps to // empty string when persisted to the backend (so gpt-5 auto-migration // won't re-fire on next load). Typed tightly so invalid values can't leak // through the save path. const [reasoningEffort, setReasoningEffort] = useState('auto'); + const [timeoutSeconds, setTimeoutSeconds] = useState(null); // Use cached system status (loaded on app start, refreshes every 30 min) const { @@ -159,9 +150,6 @@ export default function SettingsPage() { missing: string[]; } | null>(null); - // Per-provider key deletion confirm target (null = dialog closed). - const [keyToDelete, setKeyToDelete] = useState(null); - // Danger Zone state const [showClearApiKeysDialog, setShowClearApiKeysDialog] = useState(false); const [showResetDatabaseDialog, setShowResetDatabaseDialog] = useState(false); @@ -278,20 +266,15 @@ export default function SettingsPage() { async function loadConfig() { try { - const [llmConfig, featureConfig, promptConfig, featurePrompts, keyStatus] = - await Promise.all([ - fetchLlmConfig().catch(() => null), - fetchFeatureConfig().catch(() => null), - fetchPromptConfig().catch(() => null), - fetchFeaturePrompts().catch(() => null), - fetchApiKeyStatus().catch(() => null), - ]); + const [llmConfig, featureConfig, promptConfig, featurePrompts] = await Promise.all([ + fetchLlmConfig().catch(() => null), + fetchFeatureConfig().catch(() => null), + fetchPromptConfig().catch(() => null), + fetchFeaturePrompts().catch(() => null), + ]); if (cancelled) return; - const statuses = keyStatus?.providers ?? []; - setApiKeyStatuses(statuses); - if (llmConfig) { const providerFromBackend = llmConfig.provider || 'openai'; const safeProvider = PROVIDERS.includes(providerFromBackend as LLMProvider) @@ -299,13 +282,12 @@ export default function SettingsPage() { : 'openai'; setProvider(safeProvider); setModel(llmConfig.model || PROVIDER_INFO[safeProvider].defaultModel); - // Whether THIS provider already has an encrypted key (per-provider, - // not the legacy shared slot) drives the "leave blank to keep" hint. - const keyProvider = llmProviderToKeyProvider(safeProvider); - setHasStoredApiKey(statuses.some((s) => s.provider === keyProvider && s.configured)); - setApiKey(''); + const isMaskedKey = Boolean(llmConfig.api_key) && llmConfig.api_key.includes('*'); + setHasStoredApiKey(Boolean(llmConfig.api_key)); + setApiKey(isMaskedKey ? '' : llmConfig.api_key || ''); setApiBase(llmConfig.api_base || ''); setReasoningEffort((llmConfig.reasoning_effort as ReasoningEffort | null) ?? 'auto'); + setTimeoutSeconds(llmConfig.timeout_seconds ?? null); if (providerFromBackend !== safeProvider) { setError(t('settings.errors.unknownProvider', { provider: providerFromBackend })); @@ -345,38 +327,6 @@ export default function SettingsPage() { }; }, [t]); - // Whether a given key-store provider currently has a saved key. - const providerHasStoredKey = (p: LLMProvider): boolean => { - const keyProvider = llmProviderToKeyProvider(p); - return apiKeyStatuses.some((s) => s.provider === keyProvider && s.configured); - }; - - // Re-fetch the per-provider key status (after save/delete/clear). - const refreshApiKeyStatus = async (): Promise => { - const status = await fetchApiKeyStatus().catch(() => null); - const statuses = status?.providers ?? []; - setApiKeyStatuses(statuses); - return statuses; - }; - - // Delete one provider's saved key (per-row action). - const handleDeleteApiKey = async (keyProvider: ApiKeyProvider) => { - try { - await deleteApiKey(keyProvider); - const statuses = await refreshApiKeyStatus(); - if (llmProviderToKeyProvider(provider) === keyProvider) { - setHasStoredApiKey(false); - } - // Keep the local hint in sync even if the active provider differs. - void statuses; - } catch (err) { - console.error('Failed to delete API key', err); - setError((err as Error).message || t('settings.errors.unableToSaveConfiguration')); - } finally { - setKeyToDelete(null); - } - }; - // Handle provider change const handleProviderChange = (newProvider: LLMProvider) => { setProvider(newProvider); @@ -390,11 +340,9 @@ export default function SettingsPage() { setApiBase('http://localhost:8080/v1'); } - // Clear the key input on switch, but drive the "has stored key" hint from - // the per-provider store so a saved key for the new provider is recognized - // (each provider keeps its own key — switching no longer wipes anything). + // Clear API key input when switching providers to avoid accidental cross-provider usage. setApiKey(''); - setHasStoredApiKey(providerHasStoredKey(newProvider)); + setHasStoredApiKey(false); }; // Save configuration @@ -411,16 +359,6 @@ export default function SettingsPage() { } const trimmedKey = apiKey.trim(); - - // (1) Persist the key to the encrypted PER-PROVIDER store (only when the - // user typed a new one). This is the bug fix: keys no longer ride on the - // shared config slot, so saving one provider never wipes another's key. - if (trimmedKey) { - const keyProvider = llmProviderToKeyProvider(provider); - await updateApiKeys({ [keyProvider]: trimmedKey } as Record); - } - - // (2) Persist non-secret LLM config — WITHOUT api_key. const update: LLMConfigUpdate = { provider, model: model.trim(), @@ -428,15 +366,23 @@ export default function SettingsPage() { // Map UI sentinel 'auto' → '' so the server persists an empty string // and the gpt-5 auto-migration won't re-fire. reasoning_effort: reasoningEffort === 'auto' ? '' : (reasoningEffort as ReasoningEffort), + timeout_seconds: timeoutSeconds, }; + // Key-send policy (applies to BOTH requiresKey=true and false): + // - User typed a new key → send it (overwrite stored). + // - User cleared the field AND has a stored key → omit so stored + // key is preserved (matches existing UX; users rotate explicitly). + // - No new key, no stored key → send '' so the backend clears the + // field (mainly the required path; same shape for consistency). + if (trimmedKey) { + update.api_key = trimmedKey; + } else if (!hasStoredApiKey) { + update.api_key = ''; + } + await updateLlmConfig(update); - // Refresh the per-provider key status + cached system status after save. - const statuses = await refreshApiKeyStatus(); - setApiKey(''); - setHasStoredApiKey( - statuses.some((s) => s.provider === llmProviderToKeyProvider(provider) && s.configured) - ); + // Refresh cached system status after save await refreshStatus(); setStatus('saved'); @@ -461,6 +407,7 @@ export default function SettingsPage() { model: model.trim() || providerInfo.defaultModel, api_base: apiBase.trim() || null, reasoning_effort: reasoningEffort === 'auto' ? '' : (reasoningEffort as ReasoningEffort), + timeout_seconds: timeoutSeconds, }; // Send the user-typed key if present (for any provider, required or @@ -550,18 +497,22 @@ export default function SettingsPage() { try { await clearAllApiKeys(); - // The encrypted store is now empty for every provider. - await refreshApiKeyStatus(); // Refetch full LLM config to ensure local state is synced with backend const llmConfig = await fetchLlmConfig().catch(() => null); if (llmConfig) { setProvider(llmConfig.provider || 'openai'); setModel(llmConfig.model || PROVIDER_INFO['openai'].defaultModel); + const isMaskedKey = Boolean(llmConfig.api_key) && llmConfig.api_key.includes('*'); + setHasStoredApiKey(Boolean(llmConfig.api_key)); + setApiKey(isMaskedKey ? '' : llmConfig.api_key || ''); setApiBase(llmConfig.api_base || ''); setReasoningEffort(llmConfig.reasoning_effort ?? 'auto'); + setTimeoutSeconds(llmConfig.timeout_seconds ?? null); + } else { + // Fallback if refetch fails + setApiKey(''); + setHasStoredApiKey(false); } - setApiKey(''); - setHasStoredApiKey(false); setHealthCheck(null); // Refresh status @@ -917,46 +868,6 @@ export default function SettingsPage() { )} - {/* Saved per-provider keys — each provider keeps its own encrypted - key, so switching providers never wipes another's. */} - {apiKeyStatuses.some((s) => s.configured) && ( -
-

- {t('settings.apiKeys.savedTitle')} -

-
    - {apiKeyStatuses - .filter((s) => s.configured) - .map((s) => ( -
  • - - - - {API_KEY_PROVIDER_INFO[s.provider]?.name ?? s.provider} - - - {s.masked_key} - - - -
  • - ))} -
-
- )} - {/* API Base URL (optional, for proxies/aggregators/custom endpoints) */}
@@ -995,6 +906,37 @@ export default function SettingsPage() {

+ {/* Timeout Override */} +
+ + { + const val = e.target.value; + if (val === '') { + setTimeoutSeconds(null); + } else { + const num = parseInt(val, 10); + if (!isNaN(num)) { + setTimeoutSeconds(Math.min(600, Math.max(30, num))); + } + } + }} + placeholder={t('settings.llmConfiguration.timeoutPlaceholder')} + className="font-mono" + /> +

+ {t('settings.llmConfiguration.timeoutDescription')} +

+
+ {/* Action Buttons */}
- { - if (!open) setKeyToDelete(null); - }} - title={t('settings.apiKeys.deleteConfirmTitle')} - description={t('settings.apiKeys.deleteConfirmDescription', { - provider: keyToDelete ? (API_KEY_PROVIDER_INFO[keyToDelete]?.name ?? keyToDelete) : '', - })} - confirmLabel={t('common.delete')} - variant="warning" - onConfirm={() => { - if (keyToDelete) void handleDeleteApiKey(keyToDelete); - }} - /> - { + try { + const config = await fetchLlmConfig(); + if (!cancelled && config.timeout_seconds) { + setImproveTimeoutMs(config.timeout_seconds * 1000); + } + } catch (err) { + console.error('Failed to load LLM config for timeout', err); + } + }; + loadPromptConfig(); + loadTimeoutConfig(); return () => { cancelled = true; }; diff --git a/apps/frontend/lib/api/config.ts b/apps/frontend/lib/api/config.ts index f44e3d3c7..339e84948 100644 --- a/apps/frontend/lib/api/config.ts +++ b/apps/frontend/lib/api/config.ts @@ -21,6 +21,7 @@ export interface LLMConfig { api_key: string; api_base: string | null; reasoning_effort: ReasoningEffort | null; + timeout_seconds: number | null; } export interface LLMConfigUpdate { @@ -30,6 +31,7 @@ export interface LLMConfigUpdate { api_base?: string | null; // Pass '' (empty string) to clear; null is ignored by the server. reasoning_effort?: ReasoningEffort | '' | null; + timeout_seconds?: number | null; } export interface DatabaseStats { @@ -369,23 +371,7 @@ export async function updateFeaturePrompts(update: FeaturePromptsUpdate): Promis } // API Key Management types -export type ApiKeyProvider = - | 'openai' - | 'anthropic' - | 'google' - | 'openrouter' - | 'deepseek' - | 'groq' - | 'openai_compatible' - | 'ollama'; - -// Map an LLM provider (the active-provider axis) to its key-store provider -// name. Mirrors the backend `_PROVIDER_KEY_MAP` (gemini → google; the local -// providers pass through). Keys are persisted under the key-store name. -export function llmProviderToKeyProvider(provider: LLMProvider): ApiKeyProvider { - if (provider === 'gemini') return 'google'; - return provider as ApiKeyProvider; -} +export type ApiKeyProvider = 'openai' | 'anthropic' | 'google' | 'openrouter' | 'deepseek' | 'groq'; export interface ApiKeyProviderStatus { provider: ApiKeyProvider; @@ -404,8 +390,6 @@ export interface ApiKeysUpdateRequest { openrouter?: string; deepseek?: string; groq?: string; - openai_compatible?: string; - ollama?: string; } export interface ApiKeysUpdateResponse { @@ -422,8 +406,6 @@ export const API_KEY_PROVIDER_INFO: Record 0 ? ms : 600_000; +} // Matches backend schemas/models.py ResumeData interface ProcessedResume { @@ -114,9 +124,7 @@ async function postImprove( ): Promise { let response: Response; try { - // Use the configurable request timeout so NEXT_PUBLIC_REQUEST_TIMEOUT_MS - // actually applies to the long-running improve/preview/confirm calls (#776). - response = await apiPost(endpoint, payload, DEFAULT_TIMEOUT_MS); + response = await apiPost(endpoint, payload, _improveTimeoutMs); } catch (networkError) { console.error(`Network error during ${endpoint}:`, networkError); throw networkError; diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index c10ad5e20..a78aba218 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -44,8 +44,7 @@ "tailor": "Tailor Resume", "settings": "Settings", "backToDashboard": "Back to Dashboard", - "goToSettings": "Go to Settings", - "applicationTracker": "Application Tracker" + "goToSettings": "Go to Settings" }, "dashboard": { "title": "Dashboard", @@ -370,18 +369,6 @@ "modernTwoColumn": { "name": "Modern Two Column", "description": "Two-column layout with modern colorful accents and themes" - }, - "latex": { - "name": "LaTeX", - "description": "Classic serif academic layout with ruled section headers" - }, - "clean": { - "name": "Clean", - "description": "Minimal sans layout with large understated section headers" - }, - "vivid": { - "name": "Vivid", - "description": "Colorful two-column layout with accent headers and arrow bullets" } }, "accentColor": "Accent Color", @@ -559,8 +546,6 @@ "educationChanges": "Education Changes", "projectChanges": "Project Changes", "certificationChanges": "Certification Changes", - "languageChanges": "Language Changes", - "awardChanges": "Award Changes", "rejectButton": "Reject & Regenerate", "confirmButton": "Confirm & Save" }, @@ -671,6 +656,9 @@ "reasoningEffortMedium": "Medium", "reasoningEffortHigh": "High", "reasoningEffortDescription": "Only affects reasoning-capable models (gpt-5 family, Claude 3.7+, DeepSeek R1, OpenAI o1/o3). Unsupported providers drop this value automatically.", + "timeoutLabel": "Request timeout (seconds)", + "timeoutPlaceholder": "Default (120s)", + "timeoutDescription": "Override the default timeout for AI operations. Increase if using a local or slow model.", "testConnection": "Test Connection", "errorPrefix": "ERROR: {error}", "connectionSuccessful": "CONNECTION SUCCESSFUL", @@ -727,12 +715,6 @@ "unableToSaveConfiguration": "Unable to save configuration", "failedToClearApiKeys": "Failed to clear API keys. Please try again.", "failedToResetDatabase": "Failed to reset database. Please try again." - }, - "apiKeys": { - "savedTitle": "Saved API Keys", - "deleteAria": "Delete {provider} key", - "deleteConfirmTitle": "Delete API key?", - "deleteConfirmDescription": "Remove the saved {provider} API key? You can add it again later." } }, "resumeViewer": { @@ -882,129 +864,5 @@ "clearApiKeysDescription": "You'll need to re-enter them before using AI features again.", "resetDatabase": "Reset everything and start over?", "resetDatabaseDescription": "This permanently deletes every resume, job description, and generated document." - }, - "resumeWizard": { - "title": "Resume Wizard", - "answerLabel": "Your answer", - "keepAddingPrompt": "What would you like to add or change?", - "readyHint": "You have enough for a solid resume — review and finish whenever you are ready.", - "sections": { - "intro": "Getting started", - "contact": "Contact details", - "summary": "Professional summary", - "workExperience": "Work experience", - "internships": "Internships", - "education": "Education", - "personalProjects": "Projects", - "skills": "Skills", - "review": "Review" - }, - "actions": { - "continue": "Continue", - "skip": "Skip", - "back": "Back", - "review": "Review & finish", - "create": "Create master resume", - "keepAdding": "Keep adding", - "backToDashboard": "Back to Dashboard" - }, - "preview": { - "label": "Live Draft", - "empty": "Your resume appears here as you answer.", - "unnamed": "Unnamed Resume", - "experience": "Experience", - "education": "Education", - "projects": "Projects", - "skills": "Skills" - }, - "errors": { - "turnFailed": "The wizard could not save that answer. Try again.", - "finalizeFailed": "The wizard could not create your master resume. Try again." - }, - "entry": { - "kicker": "Master Resume Setup", - "title": "Choose your starting point", - "description": "Create your master resume from an existing document or build it step by step with AI guidance.", - "upload": { - "kicker": "Existing File", - "title": "Upload a resume", - "description": "Start from a PDF, DOC, or DOCX file and let Resume Matcher parse it into your master profile.", - "action": "Upload Resume" - }, - "wizard": { - "kicker": "AI Guided", - "title": "Build with AI wizard", - "description": "Answer focused prompts and let the wizard assemble a strong first master resume draft.", - "action": "Start Wizard" - } - } - }, - "tracker": { - "scroll": { - "prev": "Scroll left", - "next": "Scroll right", - "hint": "Scroll for more stages" - }, - "title": "Application Tracker", - "subtitle": "Track where each tailored resume stands in your pipeline.", - "addApplication": "Add Application", - "columns": { - "saved": "Saved", - "applied": "Applied", - "no_response": "No Response", - "response": "Response", - "interview": "Interview", - "accepted": "Accepted", - "rejected": "Rejected", - "empty": "Nothing here yet" - }, - "empty": { - "title": "No applications yet", - "description": "Tailor a resume to a job, or add one manually, to start tracking." - }, - "card": { - "companyUnknown": "Unknown company", - "roleUnknown": "Untitled role", - "sharedResume": "Shared resume", - "selectAria": "Select application", - "dragAria": "Drag to reorder" - }, - "modal": { - "jobDescription": "Job Description", - "noJobDescription": "No job description on file.", - "notes": "Notes", - "notesPlaceholder": "Add notes about this application…", - "saveNotes": "Save notes", - "resumeUnavailable": "The applied resume is no longer available.", - "loadFailed": "Could not load this application.", - "editResume": "Edit resume" - }, - "bulk": { - "selected": "{count} selected", - "moveTo": "Move to…", - "clear": "Clear", - "deleteConfirmTitle": "Delete applications?", - "deleteConfirmDescription": "Delete {count} selected application(s)? This cannot be undone." - }, - "manualAdd": { - "title": "Add Application", - "description": "Track an application by pasting its job description.", - "resume": "Resume", - "untitledResume": "Untitled resume", - "jobDescription": "Job Description", - "jobDescriptionPlaceholder": "Paste the job description here…", - "company": "Company", - "role": "Role", - "optional": "Optional", - "status": "Status", - "submit": "Add", - "validation": "Pick a resume and paste a job description.", - "failed": "Could not create the application." - }, - "errors": { - "loadFailed": "Could not load applications.", - "moveFailed": "Could not move the application.", - "deleteFailed": "Could not delete the application(s)." - } } } diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index bcda907a6..f004fb2bf 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -44,8 +44,7 @@ "tailor": "Personalizar CV", "settings": "Configuración", "backToDashboard": "Volver al Panel", - "goToSettings": "Ir a Configuración", - "applicationTracker": "Seguimiento de candidaturas" + "goToSettings": "Ir a Configuración" }, "dashboard": { "title": "Panel", @@ -370,18 +369,6 @@ "modernTwoColumn": { "name": "Moderno de dos columnas", "description": "Diseño de dos columnas moderno con acentos coloridos y temas" - }, - "latex": { - "name": "LaTeX", - "description": "Diseño académico clásico con tipografía serif y encabezados con líneas" - }, - "clean": { - "name": "Limpio", - "description": "Diseño minimalista sans con encabezados grandes y sobrios" - }, - "vivid": { - "name": "Vívido", - "description": "Diseño de dos columnas colorido con encabezados de acento y viñetas de flecha" } }, "accentColor": "Color de acento", @@ -559,8 +546,6 @@ "educationChanges": "Cambios en educación", "projectChanges": "Cambios en proyectos", "certificationChanges": "Cambios en certificaciones", - "languageChanges": "Cambios en idiomas", - "awardChanges": "Cambios en premios", "rejectButton": "Rechazar y regenerar", "confirmButton": "Confirmar y guardar" }, @@ -579,7 +564,6 @@ "apiKeyError": "Tu clave API no funciona. Revísala en Configuración.", "rateLimit": "Se alcanzó el límite de tasa. Espera aproximadamente un minuto e inténtalo de nuevo.", "failedToGenerate": "No se pudo generar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", - "timeout": "La solicitud agotó el tiempo de espera. Inténtalo de nuevo con una descripción del trabajo más corta o comprueba tu conexión.", "failedToPreview": "No se pudo previsualizar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", "failedToConfirm": "No se pudieron aplicar los cambios. Inténtalo de nuevo." } @@ -671,6 +655,9 @@ "reasoningEffortMedium": "Medio", "reasoningEffortHigh": "Alto", "reasoningEffortDescription": "Solo afecta a modelos con capacidad de razonamiento (familia gpt-5, Claude 3.7+, DeepSeek R1, OpenAI o1/o3). Los proveedores no compatibles descartan este valor automáticamente.", + "timeoutLabel": "Tiempo de espera (segundos)", + "timeoutPlaceholder": "Predeterminado (120s)", + "timeoutDescription": "Anula el tiempo de espera predeterminado para operaciones de IA. Aumente si usa un modelo local o lento.", "testConnection": "Probar conexión", "errorPrefix": "ERROR: {error}", "connectionSuccessful": "CONEXIÓN EXITOSA", @@ -727,12 +714,6 @@ "unableToSaveConfiguration": "No se pudo guardar la configuración", "failedToClearApiKeys": "No se pudieron borrar las claves API. Inténtalo de nuevo.", "failedToResetDatabase": "No se pudo restablecer la base de datos. Inténtalo de nuevo." - }, - "apiKeys": { - "savedTitle": "Claves de API guardadas", - "deleteAria": "Eliminar la clave de {provider}", - "deleteConfirmTitle": "¿Eliminar clave de API?", - "deleteConfirmDescription": "¿Eliminar la clave de API de {provider} guardada? Puedes volver a añadirla más tarde." } }, "resumeViewer": { @@ -882,129 +863,5 @@ "clearApiKeysDescription": "Tendrás que volver a ingresarlas antes de usar las funciones de IA de nuevo.", "resetDatabase": "¿Restablecer todo y empezar de nuevo?", "resetDatabaseDescription": "Esto eliminará permanentemente cada currículum, descripción de trabajo y documento generado." - }, - "resumeWizard": { - "title": "Asistente de currículum", - "answerLabel": "Tu respuesta", - "keepAddingPrompt": "¿Qué te gustaría añadir o cambiar?", - "readyHint": "Ya tienes suficiente para un currículum sólido: revísalo y termínalo cuando quieras.", - "sections": { - "intro": "Primeros pasos", - "contact": "Datos de contacto", - "summary": "Resumen profesional", - "workExperience": "Experiencia laboral", - "internships": "Prácticas", - "education": "Educación", - "personalProjects": "Proyectos", - "skills": "Habilidades", - "review": "Revisión" - }, - "actions": { - "continue": "Continuar", - "skip": "Omitir", - "back": "Atrás", - "review": "Revisar y finalizar", - "create": "Crear currículum maestro", - "keepAdding": "Seguir añadiendo", - "backToDashboard": "Volver al panel" - }, - "preview": { - "label": "Borrador en vivo", - "empty": "Tu currículum aparece aquí a medida que respondes.", - "unnamed": "Currículum sin nombre", - "experience": "Experiencia", - "education": "Educación", - "projects": "Proyectos", - "skills": "Habilidades" - }, - "errors": { - "turnFailed": "El asistente no pudo guardar esa respuesta. Inténtalo de nuevo.", - "finalizeFailed": "El asistente no pudo crear tu currículum maestro. Inténtalo de nuevo." - }, - "entry": { - "kicker": "Configuración del currículum maestro", - "title": "Elige tu punto de partida", - "description": "Crea tu currículum maestro a partir de un documento existente o constrúyelo paso a paso con ayuda de IA.", - "upload": { - "kicker": "Archivo existente", - "title": "Subir un currículum", - "description": "Empieza con un archivo PDF, DOC o DOCX y deja que Resume Matcher lo convierta en tu perfil maestro.", - "action": "Subir currículum" - }, - "wizard": { - "kicker": "Guiado por IA", - "title": "Construir con el asistente de IA", - "description": "Responde preguntas enfocadas y deja que el asistente arme un primer borrador sólido de tu currículum maestro.", - "action": "Iniciar asistente" - } - } - }, - "tracker": { - "scroll": { - "prev": "Desplazar a la izquierda", - "next": "Desplazar a la derecha", - "hint": "Desplázate para ver más etapas" - }, - "title": "Seguimiento de candidaturas", - "subtitle": "Controla en qué punto está cada currículum adaptado de tu proceso.", - "addApplication": "Añadir candidatura", - "columns": { - "saved": "Guardado", - "applied": "Enviado", - "no_response": "Sin respuesta", - "response": "Respuesta", - "interview": "Entrevista", - "accepted": "Aceptado", - "rejected": "Rechazado", - "empty": "Aún no hay nada aquí" - }, - "empty": { - "title": "Aún no hay candidaturas", - "description": "Adapta un currículum a un empleo o añade uno manualmente para empezar." - }, - "card": { - "companyUnknown": "Empresa desconocida", - "roleUnknown": "Puesto sin título", - "sharedResume": "Currículum compartido", - "selectAria": "Seleccionar candidatura", - "dragAria": "Arrastra para reordenar" - }, - "modal": { - "jobDescription": "Descripción del empleo", - "noJobDescription": "No hay descripción del empleo.", - "notes": "Notas", - "notesPlaceholder": "Añade notas sobre esta candidatura…", - "saveNotes": "Guardar notas", - "resumeUnavailable": "El currículum enviado ya no está disponible.", - "loadFailed": "No se pudo cargar esta candidatura.", - "editResume": "Editar currículum" - }, - "bulk": { - "selected": "{count} seleccionadas", - "moveTo": "Mover a…", - "clear": "Limpiar", - "deleteConfirmTitle": "¿Eliminar candidaturas?", - "deleteConfirmDescription": "¿Eliminar {count} candidatura(s) seleccionada(s)? Esto no se puede deshacer." - }, - "manualAdd": { - "title": "Añadir candidatura", - "description": "Haz seguimiento de una candidatura pegando su descripción.", - "resume": "Currículum", - "untitledResume": "Currículum sin título", - "jobDescription": "Descripción del empleo", - "jobDescriptionPlaceholder": "Pega aquí la descripción del empleo…", - "company": "Empresa", - "role": "Puesto", - "optional": "Opcional", - "status": "Estado", - "submit": "Añadir", - "validation": "Elige un currículum y pega una descripción del empleo.", - "failed": "No se pudo crear la candidatura." - }, - "errors": { - "loadFailed": "No se pudieron cargar las candidaturas.", - "moveFailed": "No se pudo mover la candidatura.", - "deleteFailed": "No se pudieron eliminar las candidaturas." - } } } diff --git a/apps/frontend/messages/ja.json b/apps/frontend/messages/ja.json index 53ae18c0a..a531b3331 100644 --- a/apps/frontend/messages/ja.json +++ b/apps/frontend/messages/ja.json @@ -44,8 +44,7 @@ "tailor": "履歴書をカスタマイズ", "settings": "設定", "backToDashboard": "ダッシュボードに戻る", - "goToSettings": "設定へ", - "applicationTracker": "応募トラッカー" + "goToSettings": "設定へ" }, "dashboard": { "title": "ダッシュボード", @@ -370,18 +369,6 @@ "modernTwoColumn": { "name": "モダン2カラム", "description": "モダンな2カラムとカラフルなアクセント" - }, - "latex": { - "name": "LaTeX", - "description": "罫線付き見出しのクラシックなセリフ体レイアウト" - }, - "clean": { - "name": "クリーン", - "description": "大きく控えめな見出しのミニマルなサンセリフ体レイアウト" - }, - "vivid": { - "name": "ビビッド", - "description": "アクセント見出しと矢印箇条書きのカラフルな2カラムレイアウト" } }, "accentColor": "アクセントカラー", @@ -559,8 +546,6 @@ "educationChanges": "学歴の変更", "projectChanges": "プロジェクトの変更", "certificationChanges": "資格の変更", - "languageChanges": "言語の変更", - "awardChanges": "賞の変更", "rejectButton": "拒否して再生成", "confirmButton": "確認して保存" }, @@ -579,7 +564,6 @@ "apiKeyError": "API キーが機能していません。設定で確認してください。", "rateLimit": "レート制限に達しました。約1分待ってから再試行してください。", "failedToGenerate": "履歴書を生成できませんでした。設定で API キーを確認してから、もう一度お試しください。", - "timeout": "リクエストがタイムアウトしました。より短い求人内容で再試行するか、接続を確認してください。", "failedToPreview": "履歴書をプレビューできませんでした。設定で API キーを確認してから、もう一度お試しください。", "failedToConfirm": "変更を適用できませんでした。もう一度お試しください。" } @@ -671,6 +655,9 @@ "reasoningEffortMedium": "中", "reasoningEffortHigh": "高", "reasoningEffortDescription": "推論対応モデル(gpt-5 系、Claude 3.7+、DeepSeek R1、OpenAI o1/o3)にのみ影響します。対応していないプロバイダーではこの値は自動的に無視されます。", + "timeoutLabel": "リクエストタイムアウト(秒)", + "timeoutPlaceholder": "デフォルト(120秒)", + "timeoutDescription": "AI 操作のデフォルトタイムアウトを上書きします。ローカルまたは遅いモデルを使用する場合は増やしてください。", "testConnection": "接続テスト", "errorPrefix": "エラー: {error}", "connectionSuccessful": "接続成功", @@ -727,12 +714,6 @@ "unableToSaveConfiguration": "設定を保存できません", "failedToClearApiKeys": "API キーの消去に失敗しました。もう一度お試しください。", "failedToResetDatabase": "データベースのリセットに失敗しました。もう一度お試しください。" - }, - "apiKeys": { - "savedTitle": "保存済み API キー", - "deleteAria": "{provider} のキーを削除", - "deleteConfirmTitle": "API キーを削除しますか?", - "deleteConfirmDescription": "保存された {provider} の API キーを削除しますか?後で再度追加できます。" } }, "resumeViewer": { @@ -882,129 +863,5 @@ "clearApiKeysDescription": "AI機能を再び使うには、もう一度入力が必要になります。", "resetDatabase": "すべてリセットして最初からやり直しますか?", "resetDatabaseDescription": "すべての履歴書、求人内容、生成されたドキュメントが完全に削除されます。" - }, - "resumeWizard": { - "title": "履歴書ウィザード", - "answerLabel": "あなたの回答", - "keepAddingPrompt": "追加または変更したい内容は何ですか?", - "readyHint": "しっかりした履歴書を作るのに十分な情報が集まりました。準備ができたら確認して完成させましょう。", - "sections": { - "intro": "はじめに", - "contact": "連絡先情報", - "summary": "職務要約", - "workExperience": "職務経験", - "internships": "インターンシップ", - "education": "学歴", - "personalProjects": "プロジェクト", - "skills": "スキル", - "review": "確認" - }, - "actions": { - "continue": "続行", - "skip": "スキップ", - "back": "戻る", - "review": "確認して完了", - "create": "マスター履歴書を作成", - "keepAdding": "続けて追加", - "backToDashboard": "ダッシュボードに戻る" - }, - "preview": { - "label": "ライブ下書き", - "empty": "回答するにつれて、ここに履歴書が表示されます。", - "unnamed": "名前未設定の履歴書", - "experience": "職務経験", - "education": "学歴", - "projects": "プロジェクト", - "skills": "スキル" - }, - "errors": { - "turnFailed": "ウィザードはその回答を保存できませんでした。もう一度お試しください。", - "finalizeFailed": "ウィザードはマスター履歴書を作成できませんでした。もう一度お試しください。" - }, - "entry": { - "kicker": "マスター履歴書の設定", - "title": "開始方法を選択", - "description": "既存の書類からマスター履歴書を作成するか、AI の案内で一歩ずつ作成します。", - "upload": { - "kicker": "既存ファイル", - "title": "履歴書をアップロード", - "description": "PDF、DOC、DOCX ファイルから始め、Resume Matcher にマスタープロフィールへ解析させます。", - "action": "履歴書をアップロード" - }, - "wizard": { - "kicker": "AI ガイド", - "title": "AI ウィザードで作成", - "description": "焦点を絞った質問に答えると、ウィザードが質の高いマスター履歴書の初稿を組み立てます。", - "action": "ウィザードを開始" - } - } - }, - "tracker": { - "scroll": { - "prev": "左へスクロール", - "next": "右へスクロール", - "hint": "スクロールして他のステージを表示" - }, - "title": "応募トラッカー", - "subtitle": "各カスタマイズ済み履歴書の選考状況を管理します。", - "addApplication": "応募を追加", - "columns": { - "saved": "保存済み", - "applied": "応募済み", - "no_response": "返信なし", - "response": "返信あり", - "interview": "面接", - "accepted": "内定", - "rejected": "不採用", - "empty": "まだ何もありません" - }, - "empty": { - "title": "応募はまだありません", - "description": "求人に合わせて履歴書を調整するか、手動で追加して追跡を開始しましょう。" - }, - "card": { - "companyUnknown": "会社名不明", - "roleUnknown": "役職名なし", - "sharedResume": "共有された履歴書", - "selectAria": "応募を選択", - "dragAria": "ドラッグして並べ替え" - }, - "modal": { - "jobDescription": "求人内容", - "noJobDescription": "求人内容はありません。", - "notes": "メモ", - "notesPlaceholder": "この応募についてのメモを追加…", - "saveNotes": "メモを保存", - "resumeUnavailable": "応募した履歴書は利用できなくなりました。", - "loadFailed": "この応募を読み込めませんでした。", - "editResume": "履歴書を編集" - }, - "bulk": { - "selected": "{count} 件選択中", - "moveTo": "移動先…", - "clear": "クリア", - "deleteConfirmTitle": "応募を削除しますか?", - "deleteConfirmDescription": "選択した {count} 件の応募を削除しますか?この操作は取り消せません。" - }, - "manualAdd": { - "title": "応募を追加", - "description": "求人内容を貼り付けて応募を追跡します。", - "resume": "履歴書", - "untitledResume": "無題の履歴書", - "jobDescription": "求人内容", - "jobDescriptionPlaceholder": "ここに求人内容を貼り付け…", - "company": "会社", - "role": "役職", - "optional": "任意", - "status": "ステータス", - "submit": "追加", - "validation": "履歴書を選び、求人内容を貼り付けてください。", - "failed": "応募を作成できませんでした。" - }, - "errors": { - "loadFailed": "応募を読み込めませんでした。", - "moveFailed": "応募を移動できませんでした。", - "deleteFailed": "応募を削除できませんでした。" - } } } diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index 1055b8a53..86662d462 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -44,8 +44,7 @@ "tailor": "Personalizar Currículo", "settings": "Configurações", "backToDashboard": "Voltar ao Painel", - "goToSettings": "Ir para Configurações", - "applicationTracker": "Acompanhamento de candidaturas" + "goToSettings": "Ir para Configurações" }, "dashboard": { "title": "Painel", @@ -368,18 +367,6 @@ "modernTwoColumn": { "name": "Moderno Duas Colunas", "description": "Layout de duas colunas com acentos coloridos modernos e temas" - }, - "latex": { - "name": "LaTeX", - "description": "Layout acadêmico clássico em serifa com cabeçalhos com linhas" - }, - "clean": { - "name": "Limpo", - "description": "Layout minimalista sem serifa com cabeçalhos grandes e discretos" - }, - "vivid": { - "name": "Vívido", - "description": "Layout de duas colunas colorido com cabeçalhos de destaque e marcadores em seta" } }, "accentColors": { @@ -559,8 +546,6 @@ "educationChanges": "Alterações de educação", "projectChanges": "Alterações de projetos", "certificationChanges": "Alterações de certificações", - "languageChanges": "Alterações de idiomas", - "awardChanges": "Alterações de prêmios", "rejectButton": "Rejeitar e regenerar", "confirmButton": "Confirmar e salvar" }, @@ -579,7 +564,6 @@ "apiKeyError": "Sua chave de API não está funcionando. Verifique em Configurações.", "rateLimit": "Limite de requisições atingido. Aguarde cerca de um minuto e tente novamente.", "failedToGenerate": "Não foi possível gerar o currículo. Verifique sua chave de API em Configurações e tente novamente.", - "timeout": "A solicitação expirou. Tente novamente com uma descrição de vaga mais curta ou verifique sua conexão.", "failedToPreview": "Não foi possível pré-visualizar o currículo. Verifique sua chave de API em Configurações e tente novamente.", "failedToConfirm": "Não foi possível aplicar as alterações. Tente novamente." } @@ -671,6 +655,9 @@ "reasoningEffortMedium": "Médio", "reasoningEffortHigh": "Alto", "reasoningEffortDescription": "Afeta apenas modelos com capacidade de raciocínio (família gpt-5, Claude 3.7+, DeepSeek R1, OpenAI o1/o3). Provedores não compatíveis descartam este valor automaticamente.", + "timeoutLabel": "Tempo limite (segundos)", + "timeoutPlaceholder": "Padrão (120s)", + "timeoutDescription": "Substitui o tempo limite padrão para operações de IA. Aumente se estiver usando um modelo local ou lento.", "testConnection": "Testar Conexão", "errorPrefix": "ERRO: {error}", "connectionSuccessful": "CONEXÃO BEM-SUCEDIDA", @@ -727,12 +714,6 @@ "unableToSaveConfiguration": "Não foi possível salvar a configuração", "failedToClearApiKeys": "Falha ao limpar chaves de API. Por favor, tente novamente.", "failedToResetDatabase": "Falha ao redefinir banco de dados. Por favor, tente novamente." - }, - "apiKeys": { - "savedTitle": "Chaves de API salvas", - "deleteAria": "Excluir a chave de {provider}", - "deleteConfirmTitle": "Excluir chave de API?", - "deleteConfirmDescription": "Remover a chave de API de {provider} salva? Você pode adicioná-la novamente depois." } }, "resumeViewer": { @@ -882,129 +863,5 @@ "clearApiKeysDescription": "Você precisará inseri-las novamente antes de usar os recursos de IA.", "resetDatabase": "Redefinir tudo e começar de novo?", "resetDatabaseDescription": "Isso excluirá permanentemente cada currículo, descrição de vaga e documento gerado." - }, - "resumeWizard": { - "title": "Assistente de currículo", - "answerLabel": "Sua resposta", - "keepAddingPrompt": "O que você gostaria de adicionar ou alterar?", - "readyHint": "Você já tem o suficiente para um currículo sólido — revise e finalize quando quiser.", - "sections": { - "intro": "Primeiros passos", - "contact": "Dados de contato", - "summary": "Resumo profissional", - "workExperience": "Experiência profissional", - "internships": "Estágios", - "education": "Formação acadêmica", - "personalProjects": "Projetos", - "skills": "Habilidades", - "review": "Revisão" - }, - "actions": { - "continue": "Continuar", - "skip": "Pular", - "back": "Voltar", - "review": "Revisar e concluir", - "create": "Criar currículo mestre", - "keepAdding": "Continuar adicionando", - "backToDashboard": "Voltar ao painel" - }, - "preview": { - "label": "Rascunho ao vivo", - "empty": "Seu currículo aparece aqui conforme você responde.", - "unnamed": "Currículo sem nome", - "experience": "Experiência", - "education": "Formação", - "projects": "Projetos", - "skills": "Habilidades" - }, - "errors": { - "turnFailed": "O assistente não conseguiu salvar essa resposta. Tente novamente.", - "finalizeFailed": "O assistente não conseguiu criar seu currículo mestre. Tente novamente." - }, - "entry": { - "kicker": "Configuração do currículo mestre", - "title": "Escolha seu ponto de partida", - "description": "Crie seu currículo mestre a partir de um documento existente ou construa-o passo a passo com orientação de IA.", - "upload": { - "kicker": "Arquivo existente", - "title": "Enviar um currículo", - "description": "Comece com um arquivo PDF, DOC ou DOCX e deixe o Resume Matcher convertê-lo no seu perfil mestre.", - "action": "Enviar currículo" - }, - "wizard": { - "kicker": "Guiado por IA", - "title": "Criar com o assistente de IA", - "description": "Responda a perguntas objetivas e deixe o assistente montar um primeiro rascunho forte do seu currículo mestre.", - "action": "Iniciar assistente" - } - } - }, - "tracker": { - "scroll": { - "prev": "Rolar para a esquerda", - "next": "Rolar para a direita", - "hint": "Role para ver mais etapas" - }, - "title": "Acompanhamento de candidaturas", - "subtitle": "Acompanhe em que etapa está cada currículo personalizado.", - "addApplication": "Adicionar candidatura", - "columns": { - "saved": "Salvo", - "applied": "Enviado", - "no_response": "Sem resposta", - "response": "Resposta", - "interview": "Entrevista", - "accepted": "Aceito", - "rejected": "Recusado", - "empty": "Nada aqui ainda" - }, - "empty": { - "title": "Nenhuma candidatura ainda", - "description": "Personalize um currículo para uma vaga ou adicione manualmente para começar." - }, - "card": { - "companyUnknown": "Empresa desconhecida", - "roleUnknown": "Cargo sem título", - "sharedResume": "Currículo compartilhado", - "selectAria": "Selecionar candidatura", - "dragAria": "Arraste para reordenar" - }, - "modal": { - "jobDescription": "Descrição da vaga", - "noJobDescription": "Nenhuma descrição de vaga registrada.", - "notes": "Notas", - "notesPlaceholder": "Adicione notas sobre esta candidatura…", - "saveNotes": "Salvar notas", - "resumeUnavailable": "O currículo enviado não está mais disponível.", - "loadFailed": "Não foi possível carregar esta candidatura.", - "editResume": "Editar currículo" - }, - "bulk": { - "selected": "{count} selecionada(s)", - "moveTo": "Mover para…", - "clear": "Limpar", - "deleteConfirmTitle": "Excluir candidaturas?", - "deleteConfirmDescription": "Excluir {count} candidatura(s) selecionada(s)? Isso não pode ser desfeito." - }, - "manualAdd": { - "title": "Adicionar candidatura", - "description": "Acompanhe uma candidatura colando a descrição da vaga.", - "resume": "Currículo", - "untitledResume": "Currículo sem título", - "jobDescription": "Descrição da vaga", - "jobDescriptionPlaceholder": "Cole a descrição da vaga aqui…", - "company": "Empresa", - "role": "Cargo", - "optional": "Opcional", - "status": "Status", - "submit": "Adicionar", - "validation": "Escolha um currículo e cole uma descrição de vaga.", - "failed": "Não foi possível criar a candidatura." - }, - "errors": { - "loadFailed": "Não foi possível carregar as candidaturas.", - "moveFailed": "Não foi possível mover a candidatura.", - "deleteFailed": "Não foi possível excluir a(s) candidatura(s)." - } } } diff --git a/apps/frontend/messages/zh.json b/apps/frontend/messages/zh.json index 0e8d2d748..23aee27ae 100644 --- a/apps/frontend/messages/zh.json +++ b/apps/frontend/messages/zh.json @@ -44,8 +44,7 @@ "tailor": "定制简历", "settings": "设置", "backToDashboard": "返回仪表板", - "goToSettings": "前往设置", - "applicationTracker": "申请追踪" + "goToSettings": "前往设置" }, "dashboard": { "title": "仪表板", @@ -370,18 +369,6 @@ "modernTwoColumn": { "name": "现代双栏", "description": "现代双栏布局,带彩色点缀与主题色" - }, - "latex": { - "name": "LaTeX", - "description": "经典衬线学术版式,带分隔线的栏目标题" - }, - "clean": { - "name": "简洁", - "description": "极简无衬线版式,栏目标题大而低调" - }, - "vivid": { - "name": "鲜明", - "description": "彩色双栏布局,强调色标题与箭头项目符号" } }, "accentColor": "强调色", @@ -559,8 +546,6 @@ "educationChanges": "教育变更", "projectChanges": "项目变更", "certificationChanges": "认证变更", - "languageChanges": "语言变更", - "awardChanges": "奖项变更", "rejectButton": "拒绝并重新生成", "confirmButton": "确认并保存" }, @@ -671,6 +656,9 @@ "reasoningEffortMedium": "中", "reasoningEffortHigh": "高", "reasoningEffortDescription": "仅影响具备推理能力的模型(gpt-5 系列、Claude 3.7+、DeepSeek R1、OpenAI o1/o3)。不支持的提供商会自动忽略该参数。", + "timeoutLabel": "请求超时(秒)", + "timeoutPlaceholder": "默认(120秒)", + "timeoutDescription": "覆盖 AI 操作的默认超时时间。如果使用本地或较慢的模型,请增加此值。", "testConnection": "测试连接", "errorPrefix": "错误:{error}", "connectionSuccessful": "连接成功", @@ -727,12 +715,6 @@ "unableToSaveConfiguration": "无法保存配置", "failedToClearApiKeys": "清除 API 密钥失败,请重试。", "failedToResetDatabase": "重置数据库失败,请重试。" - }, - "apiKeys": { - "savedTitle": "已保存的 API 密钥", - "deleteAria": "删除 {provider} 密钥", - "deleteConfirmTitle": "删除 API 密钥?", - "deleteConfirmDescription": "删除已保存的 {provider} API 密钥?你之后可以重新添加。" } }, "resumeViewer": { @@ -882,129 +864,5 @@ "clearApiKeysDescription": "下次使用 AI 功能前,您需要重新输入它们。", "resetDatabase": "重置所有内容并重新开始?", "resetDatabaseDescription": "这将永久删除每一份简历、职位描述和生成的文档。" - }, - "resumeWizard": { - "title": "简历向导", - "answerLabel": "你的回答", - "keepAddingPrompt": "你想添加或修改什么?", - "readyHint": "你已经有足够的内容来生成一份出色的简历——随时可以查看并完成。", - "sections": { - "intro": "开始使用", - "contact": "联系方式", - "summary": "职业摘要", - "workExperience": "工作经历", - "internships": "实习经历", - "education": "教育背景", - "personalProjects": "项目", - "skills": "技能", - "review": "审查" - }, - "actions": { - "continue": "继续", - "skip": "跳过", - "back": "返回", - "review": "审查并完成", - "create": "创建主简历", - "keepAdding": "继续添加", - "backToDashboard": "返回仪表板" - }, - "preview": { - "label": "实时草稿", - "empty": "当你回答问题时,简历将在此处显示。", - "unnamed": "未命名简历", - "experience": "经历", - "education": "教育", - "projects": "项目", - "skills": "技能" - }, - "errors": { - "turnFailed": "向导无法保存该回答。请重试。", - "finalizeFailed": "向导无法创建你的主简历。请重试。" - }, - "entry": { - "kicker": "主简历设置", - "title": "选择你的起点", - "description": "从现有文档创建主简历,或在 AI 引导下逐步构建。", - "upload": { - "kicker": "现有文件", - "title": "上传简历", - "description": "从 PDF、DOC 或 DOCX 文件开始,让 Resume Matcher 将其解析为你的主档案。", - "action": "上传简历" - }, - "wizard": { - "kicker": "AI 引导", - "title": "使用 AI 向导构建", - "description": "回答聚焦问题,让向导为你的主简历整理出扎实的初稿。", - "action": "启动向导" - } - } - }, - "tracker": { - "scroll": { - "prev": "向左滚动", - "next": "向右滚动", - "hint": "滚动查看更多阶段" - }, - "title": "申请追踪", - "subtitle": "追踪每份定制简历在求职流程中的进展。", - "addApplication": "添加申请", - "columns": { - "saved": "已保存", - "applied": "已投递", - "no_response": "无回复", - "response": "有回复", - "interview": "面试", - "accepted": "已录用", - "rejected": "已拒绝", - "empty": "这里还没有内容" - }, - "empty": { - "title": "暂无申请", - "description": "为某个职位定制简历,或手动添加一项,即可开始追踪。" - }, - "card": { - "companyUnknown": "未知公司", - "roleUnknown": "未命名职位", - "sharedResume": "共享简历", - "selectAria": "选择申请", - "dragAria": "拖动以重新排序" - }, - "modal": { - "jobDescription": "职位描述", - "noJobDescription": "没有职位描述。", - "notes": "备注", - "notesPlaceholder": "为此申请添加备注……", - "saveNotes": "保存备注", - "resumeUnavailable": "所投递的简历已不可用。", - "loadFailed": "无法加载此申请。", - "editResume": "编辑简历" - }, - "bulk": { - "selected": "已选择 {count} 项", - "moveTo": "移动到……", - "clear": "清除", - "deleteConfirmTitle": "删除申请?", - "deleteConfirmDescription": "删除选中的 {count} 项申请?此操作无法撤销。" - }, - "manualAdd": { - "title": "添加申请", - "description": "粘贴职位描述以追踪一项申请。", - "resume": "简历", - "untitledResume": "未命名简历", - "jobDescription": "职位描述", - "jobDescriptionPlaceholder": "在此粘贴职位描述……", - "company": "公司", - "role": "职位", - "optional": "可选", - "status": "状态", - "submit": "添加", - "validation": "请选择一份简历并粘贴职位描述。", - "failed": "无法创建该申请。" - }, - "errors": { - "loadFailed": "无法加载申请。", - "moveFailed": "无法移动该申请。", - "deleteFailed": "无法删除该申请。" - } } } diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 9193fbd9c..521d0777c 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -2,20 +2,10 @@ import type { NextConfig } from 'next'; const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN || 'http://127.0.0.1:8000'; -// Request timeout (ms) for the API proxy. MUST match the backend's -// REQUEST_TIMEOUT_SECONDS and the client AbortController (lib/api/client.ts) — -// the shortest layer aborts first, so all three are driven by the same -// NEXT_PUBLIC_REQUEST_TIMEOUT_MS env var. Bounded to [30s, 30min]. -const rawTimeoutMs = process.env.NEXT_PUBLIC_REQUEST_TIMEOUT_MS; -const parsedTimeoutMs = rawTimeoutMs ? Number(rawTimeoutMs) : NaN; -const REQUEST_TIMEOUT_MS = Number.isFinite(parsedTimeoutMs) - ? Math.min(1_800_000, Math.max(30_000, parsedTimeoutMs)) - : 240_000; - const nextConfig: NextConfig = { output: 'standalone', experimental: { - proxyTimeout: REQUEST_TIMEOUT_MS, + proxyTimeout: 3_600_000, // Tree-shake barrel imports — saves ~200-800ms cold start per route optimizePackageImports: [ 'lucide-react',