diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82d984..93f56bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,8 +75,6 @@ jobs: # If your settings require these keys to exist, provide harmless placeholders: OPENAI_API_KEY: "test" GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY}} - # If your app reads LLM_BASE_URL in tests: - LLM_BASE_URL: "http://localhost:9999" # If Redis URL is required by settings but tests mock it: REDIS_URL: "redis://localhost:6379/0" DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" diff --git a/.gitignore b/.gitignore index b0aa15c..06f8e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -418,4 +418,5 @@ __marimo__/ # MLflow mlruns/ -backend/mlruns/ \ No newline at end of file +backend/mlruns/task-def.json +frontend/.env diff --git a/DEPLOY.txt b/DEPLOY.txt new file mode 100644 index 0000000..89c9664 --- /dev/null +++ b/DEPLOY.txt @@ -0,0 +1,366 @@ +================================================================================ + FULL DEPLOYMENT GUIDE — AWS (Backend) + Vercel (Frontend) + Account: xxxxxxxx | Region: us-east-1 +================================================================================ + +WHAT EXISTS ALREADY IN AWS +--------------------------- +- ECS Cluster: cv-cluster +- ECS Service: cv-backend-service (1 Fargate task, currently running) +- ECR Repo: xxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/cv-backend +- CloudWatch: /ecs/cv-backend-task (logs active) + +WHAT IS MISSING (must be created below) +----------------------------------------- +- RDS Postgres instance (backend needs DATABASE_URL) +- Application Load Balancer (backend has no public URL yet) +- GEMINI_API_KEY + env vars injected into ECS task +- ECR repo for frontend (optional — Vercel builds it) + + +================================================================================ + PART 1 — AWS BACKEND +================================================================================ + +STEP 1 — PREREQUISITES +----------------------- +Make sure you have installed: + - AWS CLI (configured with profile "adrian") + - Docker Desktop (running) + - psql (optional, for DB verification) + +Verify your profile works: + aws sts get-caller-identity --profile adrian + + +STEP 2 — CREATE RDS POSTGRES +------------------------------ +This creates a small Postgres instance in the default VPC. +It takes ~5 minutes to become available. + + # Create a subnet group from the 6 existing public subnets + aws rds create-db-subnet-group \ + --db-subnet-group-name cv-subnet-group \ + --db-subnet-group-description "CV app subnets" \ + --subnet-ids \ + subnet-087661463378e408d \ + subnet-04a420c43262a5112 \ + subnet-0fb8a066fc0881338 \ + --profile adrian + + # Create the RDS instance (db.t3.micro — free tier eligible) + aws rds create-db-instance \ + --db-instance-identifier cv-postgres \ + --db-instance-class db.t3.micro \ + --engine postgres \ + --engine-version 15 \ + --master-username postgres \ + --master-user-password YOUR_DB_PASSWORD \ + --db-name postgres \ + --db-subnet-group-name cv-subnet-group \ + --vpc-security-group-ids sg-0a0c6dc8099172819 \ + --publicly-accessible \ + --allocated-storage 20 \ + --no-multi-az \ + --profile adrian + + # Wait for it to be available (~5 min) + aws rds wait db-instance-available \ + --db-instance-identifier cv-postgres \ + --profile adrian + + # Get the endpoint + aws rds describe-db-instances \ + --db-instance-identifier cv-postgres \ + --query 'DBInstances[0].Endpoint.Address' \ + --output text \ + --profile adrian + + # Save it — you'll need it as: + DATABASE_URL=postgresql://postgres:YOUR_DB_PASSWORD@:5432/postgres + + +STEP 3 — STORE SECRETS IN AWS SECRETS MANAGER +----------------------------------------------- + aws secretsmanager create-secret \ + --name cv-backend/GEMINI_API_KEY \ + --secret-string "YOUR_GEMINI_API_KEY" \ + --profile adrian + + aws secretsmanager create-secret \ + --name cv-backend/DATABASE_URL \ + --secret-string "postgresql://postgres:YOUR_DB_PASSWORD@:5432/postgres" \ + --profile adrian + + aws secretsmanager create-secret \ + --name cv-backend/REDIS_URL \ + --secret-string "redis://localhost:6379/0" \ + --profile adrian + + # Note the ARNs — you'll need them in Step 5. + # To retrieve an ARN later: + aws secretsmanager describe-secret \ + --secret-id cv-backend/GEMINI_API_KEY \ + --query 'ARN' --output text --profile adrian + + +STEP 4 — GRANT ECS EXECUTION ROLE ACCESS TO SECRETS +----------------------------------------------------- + # Get the execution role ARN + aws iam get-role \ + --role-name ecsTaskExecutionRole \ + --query 'Role.Arn' --output text --profile adrian + + # Attach the Secrets Manager read policy + aws iam put-role-policy \ + --role-name ecsTaskExecutionRole \ + --policy-name cv-secrets-access \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "arn:aws:secretsmanager:us-east-1:xxxxxxxx:secret:cv-backend/*" + }] + }' \ + --profile adrian + + +STEP 5 — OPEN RDS SECURITY GROUP TO ECS +----------------------------------------- +The default security group needs to allow port 5432 from itself +(ECS tasks and RDS are both in the default SG). + + aws ec2 authorize-security-group-ingress \ + --group-id sg-0a0c6dc8099172819 \ + --protocol tcp \ + --port 5432 \ + --source-group sg-0a0c6dc8099172819 \ + --profile adrian + + +STEP 6 — BUILD AND PUSH A FRESH BACKEND IMAGE +----------------------------------------------- + # Authenticate Docker to ECR + aws ecr get-login-password --region us-east-1 --profile adrian \ + | docker login --username AWS --password-stdin \ + xxxxxxxx.dkr.ecr.us-east-1.amazonaws.com + + # Build from repo root + docker build \ + -f backend/Dockerfile \ + -t cv-backend:latest \ + --platform linux/amd64 \ + backend/ + + # Tag and push + docker tag cv-backend:latest \ + xxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/cv-backend:latest + + docker push \ + xxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/cv-backend:latest + + +STEP 7 — REGISTER NEW TASK DEFINITION WITH SECRETS +---------------------------------------------------- +Replace the three SECRET_ARN values with the ARNs from Step 3. + + aws ecs register-task-definition \ + --family cv-backend-task \ + --execution-role-arn arn:aws:iam::xxxxxxxx:role/ecsTaskExecutionRole \ + --network-mode awsvpc \ + --requires-compatibilities FARGATE \ + --cpu 512 \ + --memory 1024 \ + --container-definitions '[{ + "name": "cv-backend", + "image": "xxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/cv-backend:latest", + "essential": true, + "portMappings": [{"containerPort": 8000, "protocol": "tcp"}], + "secrets": [ + {"name": "GEMINI_API_KEY", "valueFrom": "ARN_FOR_cv-backend/GEMINI_API_KEY"}, + {"name": "DATABASE_URL", "valueFrom": "ARN_FOR_cv-backend/DATABASE_URL"}, + {"name": "REDIS_URL", "valueFrom": "ARN_FOR_cv-backend/REDIS_URL"} + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/cv-backend-task", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "ecs" + } + } + }]' \ + --profile adrian + + +STEP 8 — CREATE APPLICATION LOAD BALANCER +------------------------------------------ + # Create ALB security group (allows HTTP from anywhere) + aws ec2 create-security-group \ + --group-name cv-alb-sg \ + --description "ALB public HTTP" \ + --vpc-id vpc-0d30b4de1142471a2 \ + --profile adrian + # Note the GroupId returned (ALB_SG_ID) + + aws ec2 authorize-security-group-ingress \ + --group-id ALB_SG_ID \ + --protocol tcp --port 80 --cidr 0.0.0.0/0 \ + --profile adrian + + # Allow ALB to reach ECS tasks on port 8000 + aws ec2 authorize-security-group-ingress \ + --group-id sg-0a0c6dc8099172819 \ + --protocol tcp --port 8000 \ + --source-group ALB_SG_ID \ + --profile adrian + + # Create the ALB + aws elbv2 create-load-balancer \ + --name cv-alb \ + --subnets \ + subnet-087661463378e408d \ + subnet-04a420c43262a5112 \ + subnet-0fb8a066fc0881338 \ + --security-groups ALB_SG_ID \ + --profile adrian + # Note the LoadBalancerArn and DNSName + + # Create target group + aws elbv2 create-target-group \ + --name cv-backend-tg \ + --protocol HTTP --port 8000 \ + --vpc-id vpc-0d30b4de1142471a2 \ + --target-type ip \ + --health-check-path /health \ + --profile adrian + # Note the TargetGroupArn + + # Create listener + aws elbv2 create-listener \ + --load-balancer-arn ALB_ARN \ + --protocol HTTP --port 80 \ + --default-actions Type=forward,TargetGroupArn=TARGET_GROUP_ARN \ + --profile adrian + + +STEP 9 — UPDATE ECS SERVICE +----------------------------- + aws ecs update-service \ + --cluster cv-cluster \ + --service cv-backend-service \ + --task-definition cv-backend-task \ + --load-balancers \ + "targetGroupArn=TARGET_GROUP_ARN,containerName=cv-backend,containerPort=8000" \ + --force-new-deployment \ + --profile adrian + + # Watch it stabilise (takes ~2 min) + aws ecs wait services-stable \ + --cluster cv-cluster \ + --services cv-backend-service \ + --profile adrian + + +STEP 10 — VERIFY BACKEND IS LIVE +---------------------------------- + curl http:///health + # Expected: {"status": "ok"} or similar 200 response + + # View live logs + aws logs tail /ecs/cv-backend-task --follow --profile adrian + + +================================================================================ + PART 2 — VERCEL FRONTEND +================================================================================ + +STEP 1 — PUSH REPO TO GITHUB +------------------------------ +Make sure all current changes are committed and pushed: + git add . + git commit -m "your message" + git push origin main + + +STEP 2 — IMPORT PROJECT INTO VERCEL +------------------------------------- +1. Go to https://vercel.com/new +2. Click "Import Git Repository" +3. Select your GitHub repo (SmartGridsML/genai-project or similar) +4. Configure the project: + Framework Preset: Vite + Root Directory: frontend ← IMPORTANT: set this + Build Command: npm run build (auto-detected) + Output Directory: dist (auto-detected) + + +STEP 3 — ADD ENVIRONMENT VARIABLE +----------------------------------- +In the Vercel project settings → Environment Variables, add: + + Name: VITE_API_BASE_URL + Value: http:// + (the DNS name from Step 8 of Part 1, e.g. cv-alb-1234567890.us-east-1.elb.amazonaws.com) + + Apply to: Production, Preview, Development + + +STEP 4 — DEPLOY +---------------- +Click "Deploy". Vercel will: + 1. Run: npm ci + 2. Run: npm run build (Vite builds with your VITE_API_BASE_URL baked in) + 3. Serve the dist/ folder globally via Vercel's CDN + +Your app will be live at: https://.vercel.app + + +STEP 5 — FIX CORS ON BACKEND +------------------------------ +Once you have the Vercel URL, update the backend's CORS allowed origins. +Find the CORS config in backend/app/main.py and add your Vercel domain, +then rebuild and push (repeat Part 1 Steps 6–9). + +Example (in main.py): + allow_origins=["https://your-project.vercel.app"] + + +================================================================================ + ONGOING DEPLOYMENTS (after initial setup) +================================================================================ + +TO DEPLOY A BACKEND UPDATE: + 1. Build & push new image (Part 1 Step 6) + 2. Force new ECS deployment: + aws ecs update-service \ + --cluster cv-cluster \ + --service cv-backend-service \ + --force-new-deployment \ + --profile adrian + +TO DEPLOY A FRONTEND UPDATE: + Push to your main branch — Vercel auto-deploys on every push. + + +================================================================================ + MONTHLY COST ESTIMATE (approximate) +================================================================================ + + ECS Fargate (1 task, 512 CPU / 1024 MB) ~$15/mo + RDS Postgres db.t3.micro ~$15/mo (or free tier if <12 mo) + ALB ~$18/mo + ECR storage (1 image ~325MB) ~$0.03/mo + CloudWatch logs ~$1/mo + Vercel (Hobby plan) FREE + ───────────────────────────────────────────────── + Total ~$49/mo + + Cost-saving tip: Stop RDS and ECS tasks when not in use during development. + aws ecs update-service --cluster cv-cluster \ + --service cv-backend-service --desired-count 0 --profile adrian + aws rds stop-db-instance \ + --db-instance-identifier cv-postgres --profile adrian + +================================================================================ diff --git a/backend/Dockerfile b/backend/Dockerfile index 649a81b..d4f80fa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,8 +8,8 @@ ENV PYTHONUNBUFFERED=1 COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt -COPY app /app/app +COPY . /app/backend EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/applications.py b/backend/app/api/routes/applications.py index 9d083db..d21b510 100644 --- a/backend/app/api/routes/applications.py +++ b/backend/app/api/routes/applications.py @@ -1,46 +1,159 @@ -from fastapi import APIRouter, UploadFile, File, HTTPException -from starlette import status +import asyncio +import hashlib import json import logging import time from uuid import uuid4 -from functools import lru_cache -from backend.app.core.document_parser import parse_cv +from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, UploadFile +from starlette import status -import hashlib -from fastapi import Request, Form from backend.app.config import settings +from backend.app.core.auditor import Auditor +from backend.app.core.cv_enhancer import CVEnhancer +from backend.app.core.document_parser import parse_cv +from backend.app.models.schemas import ApplicationGenerateRequest, ExtractedFacts from backend.app.services.cache_service import CacheService -from fastapi import APIRouter, UploadFile, File, HTTPException, Request, Form, Depends from backend.app.services.llm_client import LLMClient, get_llm_client -from backend.app.core.cv_enhancer import CVEnhancer -from backend.app.services.llm_service import LLMService -from backend.app.core.auditor import get_auditor -from backend.app.models.schemas import ExtractedFacts - +from backend.app.utils.rate_limiter import limiter +from backend.app.utils.result_token import sign as sign_token, verify as verify_token router = APIRouter(prefix="/applications", tags=["applications"]) logger = logging.getLogger(__name__) +MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5MB +JOB_QUEUE_KEY = "application:jobs:queue" +JOB_PROCESSING_KEY = "application:jobs:processing" +WORKER_BRPOP_TIMEOUT_SECONDS = 1 + +_worker_task: asyncio.Task | None = None +_worker_stop_event: asyncio.Event | None = None + + def get_cache() -> CacheService: - return CacheService(settings.redis_url) + return CacheService() + def _sha256(b: bytes) -> str: return hashlib.sha256(b).hexdigest() + def _log_event(payload: dict) -> None: logger.info(json.dumps(payload)) -@lru_cache -def get_llm_service() -> LLMService: - return LLMService() -MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5MB +async def _load_result_from_cache(cache: CacheService, request_id: str) -> dict | None: + key = f"application:result:{request_id}" + try: + data = await cache.get_encrypted_json(key) + except Exception: + data = None + if data is None: + data = await cache.get_json(key) + return data + + +async def _enqueue_generation_job( + cache: CacheService, + request_id: str, + body: ApplicationGenerateRequest, +) -> None: + job_payload = {"request_id": request_id, "body": body.model_dump()} + await cache.client.lpush(JOB_QUEUE_KEY, json.dumps(job_payload).encode("utf-8")) + + +async def _requeue_stale_jobs(cache: CacheService) -> int: + moved = 0 + while True: + payload = await cache.client.rpop(JOB_PROCESSING_KEY) + if payload is None: + break + await cache.client.lpush(JOB_QUEUE_KEY, payload) + moved += 1 + return moved + + +async def _generation_worker_loop(stop_event: asyncio.Event) -> None: + cache = get_cache() + logger.info("Generation worker loop running") + + while not stop_event.is_set(): + payload = await cache.client.brpoplpush( + JOB_QUEUE_KEY, + JOB_PROCESSING_KEY, + timeout=WORKER_BRPOP_TIMEOUT_SECONDS, + ) + if payload is None: + continue + + payload_text = payload.decode("utf-8") if isinstance(payload, bytes) else str(payload) + request_id = None + try: + parsed = json.loads(payload_text) + request_id = str(parsed["request_id"]) + body = ApplicationGenerateRequest.model_validate(parsed["body"]) + + existing = await _load_result_from_cache(cache, request_id) + if isinstance(existing, dict) and existing.get("status") in {"done", "failed"}: + logger.info(f"Skipping already completed request_id={request_id}") + continue + + llm = get_llm_client() + await _run_generation_pipeline(request_id=request_id, body=body, llm=llm) + except Exception as exc: + logger.error(f"Worker failed processing payload: {exc}", exc_info=True) + if request_id: + await cache.set_json( + f"application:result:{request_id}", + {"request_id": request_id, "status": "failed", "error": str(exc)}, + ttl_seconds=settings.cache_ttl_seconds, + ) + finally: + await cache.client.lrem(JOB_PROCESSING_KEY, 1, payload) + + +async def start_generation_worker() -> None: + global _worker_task, _worker_stop_event + + if _worker_task is not None and not _worker_task.done(): + return + + cache = get_cache() + moved = await _requeue_stale_jobs(cache) + if moved: + logger.warning(f"Requeued {moved} stale generation jobs from processing list") + + _worker_stop_event = asyncio.Event() + _worker_task = asyncio.create_task(_generation_worker_loop(_worker_stop_event), name="generation-worker") + logger.info("Generation worker started") + + +async def stop_generation_worker() -> None: + global _worker_task, _worker_stop_event + + if _worker_task is None: + return + + if _worker_stop_event is not None: + _worker_stop_event.set() + + try: + await asyncio.wait_for(_worker_task, timeout=30) + except asyncio.TimeoutError: + _worker_task.cancel() + try: + await _worker_task + except asyncio.CancelledError: + pass + finally: + _worker_task = None + _worker_stop_event = None + logger.info("Generation worker stopped") @router.post("/parse") -async def parse_application_cv(file: UploadFile = File(...)): +@limiter.limit("30/minute") +async def parse_application_cv(request: Request, file: UploadFile = File(...)): filename = (file.filename or "").lower() if not (filename.endswith(".pdf") or filename.endswith(".docx")): @@ -63,134 +176,214 @@ async def parse_application_cv(file: UploadFile = File(...)): return { "filename": file.filename, + "parsed_text": parsed.raw_text, "detected_headings": parsed.detected_headings, "sections": parsed.sections, - "raw_text_preview": parsed.raw_text[:1000], # preview only; avoids huge responses } -from backend.app.models.schemas import ApplicationGenerateRequest + @router.post("/generate") +@limiter.limit("10/minute") async def generate_application( request: Request, body: ApplicationGenerateRequest, - llm: LLMClient = Depends(get_llm_client), ): - cv_text = body.cv_text - job_description = body.job_description - tone = body.tone or "professional" - filename = "cv_text.txt" - - # Build "sections" input expected by extractor - sections = {"raw": cv_text} - - # cache keys - cv_hash = _sha256(cv_text.encode("utf-8")) - jd_hash = _sha256(job_description.encode("utf-8")) - facts_cache_key = f"facts:{cv_hash}" - jd_cache_key = f"jd:{jd_hash}" - - # 2) facts (cached) - cache = get_cache() - facts = cache.get_json(facts_cache_key) - if facts is None: - facts = await llm.extract_facts(sections) - cache.set_json(facts_cache_key, facts, ttl_seconds=settings.cache_ttl_seconds) - - # 3) jd analysis (cached) - jd = cache.get_json(jd_cache_key) - if jd is None: - jd = await llm.analyze_jd(job_description) - cache.set_json(jd_cache_key, jd, ttl_seconds=settings.cache_ttl_seconds) - - - # 4) cover letter - t0 = time.perf_counter() - cover = await llm.generate_cover_letter(facts=facts, jd=jd, tone=tone) - _log_event({"event": "stage_complete", "stage": "cover_letter", "ms": round((time.perf_counter()-t0)*1000, 2)}) - request_id = getattr(request.state, "request_id", None) or str(uuid4()) + access_token = sign_token(request_id, ttl_seconds=settings.cache_ttl_seconds) + cv_hash = _sha256(body.cv_text.encode("utf-8")) + jd_hash = _sha256(body.job_description.encode("utf-8")) + cache = get_cache() - cover_letter_text = cover.get("cover_letter", "") + await cache.set_json( + f"application:result:{request_id}", + {"request_id": request_id, "status": "processing"}, + ttl_seconds=settings.cache_ttl_seconds, + ) - # 5) audit report (Person A Thursday) - # If auditor is not ready/exposed, you can temporarily set audit_report = [] - t0 = time.perf_counter() try: - auditor = get_auditor() - facts_model = ExtractedFacts.model_validate(facts) - - audit_report_obj = auditor.audit( - cover_letter=cover_letter_text, - fact_table=facts_model, - request_id=request_id, + await _enqueue_generation_job(cache, request_id, body) + except Exception as exc: + logger.error(f"Failed to enqueue generation job for {request_id}: {exc}", exc_info=True) + await cache.set_json( + f"application:result:{request_id}", + {"request_id": request_id, "status": "failed", "error": "Could not queue generation job"}, + ttl_seconds=settings.cache_ttl_seconds, ) - audit_report = audit_report_obj.model_dump() - except Exception as e: - audit_report = {"error": str(e)} + raise HTTPException(status_code=503, detail="Could not queue generation job") + return { + "request_id": request_id, + "access_token": access_token, + "status": "processing", + "filename": "cv_text.txt", + "cv_hash": cv_hash, + "jd_hash": jd_hash, + "tone": body.tone or "professional", + } - _log_event({"event": "stage_complete", "stage": "audit", "request_id": request_id, "ms": round((time.perf_counter()-t0)*1000, 2)}) - # 6) cv enhancement - t0 = time.perf_counter() +async def _run_generation_pipeline( + request_id: str, + body: ApplicationGenerateRequest, + llm: LLMClient, +) -> None: + cache = get_cache() + cv_text = body.cv_text + job_description = body.job_description + tone = body.tone or "professional" + cv_hash = _sha256(cv_text.encode("utf-8")) + jd_hash = _sha256(job_description.encode("utf-8")) + sections = {"raw": cv_text} + try: - enhancer = CVEnhancer(llm_service=get_llm_service()) - original_cv_text = cv_text - cv_patches = enhancer.enhance( - original_cv_text=original_cv_text, - fact_table=facts, - jd_requirements=jd, - max_suggestions=8, - ) - cv_suggestions = [ + async def _fetch_facts() -> dict: + key = f"facts:{cv_hash}" + try: + cached = await cache.get_encrypted_json(key) + except Exception: + cached = await cache.get_json(key) + if cached is not None: + return cached + result = await llm.extract_facts(sections) + await cache.set_encrypted_json(key, result, ttl_seconds=settings.cache_ttl_seconds) + return result + + async def _fetch_jd() -> dict: + key = f"jd:{jd_hash}" + try: + cached = await cache.get_encrypted_json(key) + except Exception: + cached = await cache.get_json(key) + if cached is not None: + return cached + result = await llm.analyze_jd(job_description) + await cache.set_encrypted_json(key, result, ttl_seconds=settings.cache_ttl_seconds) + return result + + t0 = time.perf_counter() + facts, jd = await asyncio.gather(_fetch_facts(), _fetch_jd()) + _log_event( { - "section": p.section, - "before": p.before, - "after": p.after, - "rationale": p.rationale, - "grounded_sources": p.grounded_sources, - "diff_unified": p.diff_unified, + "event": "stage_complete", + "stage": "facts_and_jd", + "request_id": request_id, + "ms": round((time.perf_counter() - t0) * 1000, 2), } - for p in cv_patches - ] - except Exception as e: - cv_suggestions = [{"error": str(e)}] - - _log_event({"event": "stage_complete", "stage": "cv_enhance", "request_id": request_id, "ms": round((time.perf_counter()-t0)*1000, 2)}) - + ) - # 7) store results - result_blob = { - "request_id": request_id, - "filename": filename, - "cv_hash": cv_hash, - "jd_hash": jd_hash, - "tone": tone, - "cover_letter": cover_letter_text, - "audit_report": audit_report, - "cv_suggestions": cv_suggestions, - "cv_raw_text": cv_text, - } + t0 = time.perf_counter() + cover = await llm.generate_cover_letter(facts=facts, jd=jd, tone=tone) + cover_letter_text = cover.get("cover_letter", "") + _log_event( + { + "event": "stage_complete", + "stage": "cover_letter", + "request_id": request_id, + "ms": round((time.perf_counter() - t0) * 1000, 2), + } + ) + async def _run_audit() -> dict: + try: + auditor = Auditor(llm_client=llm) + facts_model = ExtractedFacts.model_validate(facts) + report = await auditor.audit( + cover_letter=cover_letter_text, + fact_table=facts_model, + request_id=request_id, + ) + return report.model_dump() + except Exception as exc: + logger.error(f"Audit failed for request {request_id}: {exc}", exc_info=True) + return {"error": str(exc)} + + async def _run_cv_enhance() -> list: + try: + enhancer = CVEnhancer(llm_client=llm) + patches = await enhancer.enhance( + original_cv_text=cv_text, + fact_table=facts, + jd_requirements=jd, + max_suggestions=8, + ) + return [ + { + "section": p.section, + "before": p.before, + "after": p.after, + "rationale": p.rationale, + "grounded_sources": p.grounded_sources, + "diff_unified": p.diff_unified, + } + for p in patches + ] + except Exception as exc: + logger.error(f"CV enhancement failed for request {request_id}: {exc}", exc_info=True) + return [{"error": str(exc)}] + + t0 = time.perf_counter() + audit_report, cv_suggestions = await asyncio.gather(_run_audit(), _run_cv_enhance()) + _log_event( + { + "event": "stage_complete", + "stage": "audit_and_enhance", + "request_id": request_id, + "ms": round((time.perf_counter() - t0) * 1000, 2), + } + ) - cache.set_json(f"application:result:{request_id}", result_blob, ttl_seconds=settings.cache_ttl_seconds) - _log_event({"event": "stage_complete", "stage": "store_results", "request_id": request_id, "ms": round((time.perf_counter()-t0)*1000, 2)}) + warnings = [] + if isinstance(audit_report, dict) and "error" in audit_report: + warnings.append(f"Audit failed: {audit_report['error']}") + if cv_suggestions and isinstance(cv_suggestions[0], dict) and "error" in cv_suggestions[0]: + warnings.append(f"CV enhancement failed: {cv_suggestions[0]['error']}") + + result_blob = { + "request_id": request_id, + "status": "done", + "warnings": warnings, + "filename": "cv_text.txt", + "cv_hash": cv_hash, + "jd_hash": jd_hash, + "tone": tone, + "cover_letter": cover_letter_text, + "audit_report": audit_report, + "cv_suggestions": cv_suggestions, + "cv_raw_text": cv_text, + } + + await cache.set_encrypted_json( + f"application:result:{request_id}", + result_blob, + ttl_seconds=settings.cache_ttl_seconds, + ) + _log_event({"event": "stage_complete", "stage": "store_results", "request_id": request_id}) + + except Exception as exc: + logger.error(f"Generation pipeline failed for {request_id}: {exc}", exc_info=True) + await cache.set_json( + f"application:result:{request_id}", + {"request_id": request_id, "status": "failed", "error": str(exc)}, + ttl_seconds=settings.cache_ttl_seconds, + ) - # Keep response small; fetch full payload via /applications/{id}/results - return { - "request_id": request_id, - "filename": filename, - "cv_hash": cv_hash, - "jd_hash": jd_hash, - "tone": tone, - } +@router.get("/{request_id}/results") +async def get_application_results( + request_id: str, + authorization: str | None = Header(default=None), + cache: CacheService = Depends(get_cache), +): + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header") + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Authorization header must be 'Bearer '") + token = authorization.removeprefix("Bearer ") + if not verify_token(request_id, token): + raise HTTPException(status_code=403, detail="Invalid access token") -@router.get("/{request_id}/results") -async def get_application_results(request_id: str): - cache = get_cache() - data = cache.get_json(f"application:result:{request_id}") + data = await _load_result_from_cache(cache, request_id) if not data: raise HTTPException(status_code=404, detail="Results not found (expired or invalid request id)") return data diff --git a/backend/app/api/routes/downloads.py b/backend/app/api/routes/downloads.py index 7513436..92a60d3 100644 --- a/backend/app/api/routes/downloads.py +++ b/backend/app/api/routes/downloads.py @@ -1,37 +1,60 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import Response -from backend.app.config import settings from backend.app.services.cache_service import CacheService from backend.app.services.document_service import DocumentService +from backend.app.utils.result_token import verify as verify_token router = APIRouter(prefix="/applications", tags=["applications"]) def get_cache() -> CacheService: - return CacheService(settings.redis_url) + return CacheService() def get_docs() -> DocumentService: return DocumentService() -def _load_result(cache: CacheService, request_id: str) -> dict: - data = cache.get_json(f"application:result:{request_id}") +async def _load_result(cache: CacheService, request_id: str, authorization: str | None) -> dict: + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header") + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Authorization header must be 'Bearer '") + + access_token = authorization.removeprefix("Bearer ") + if not verify_token(request_id, access_token): + raise HTTPException(status_code=403, detail="Invalid access token") + + try: + data = await cache.get_encrypted_json(f"application:result:{request_id}") + except Exception: + data = await cache.get_json(f"application:result:{request_id}") + if not data: raise HTTPException(status_code=404, detail="Results not found (expired or invalid request id)") + + result_status = data.get("status") + if result_status == "processing": + raise HTTPException(status_code=409, detail="Result is still processing") + if result_status == "failed": + raise HTTPException(status_code=409, detail=data.get("error", "Generation failed")) + if result_status != "done": + raise HTTPException(status_code=409, detail=f"Result is not downloadable (status={result_status})") + return data @router.get("/{request_id}/download/cover-letter.docx") -def download_cover_letter_docx( +async def download_cover_letter_docx( request_id: str, + authorization: str | None = Header(default=None), cache: CacheService = Depends(get_cache), docs: DocumentService = Depends(get_docs), ): - data = _load_result(cache, request_id) + data = await _load_result(cache, request_id, authorization) cover = data.get("cover_letter", "") or "" f = docs.cover_letter_docx(cover, filename="cover_letter.docx") return Response( @@ -42,12 +65,13 @@ def download_cover_letter_docx( @router.get("/{request_id}/download/cover-letter.pdf") -def download_cover_letter_pdf( +async def download_cover_letter_pdf( request_id: str, + authorization: str | None = Header(default=None), cache: CacheService = Depends(get_cache), docs: DocumentService = Depends(get_docs), ): - data = _load_result(cache, request_id) + data = await _load_result(cache, request_id, authorization) cover = data.get("cover_letter", "") or "" f = docs.cover_letter_pdf(cover, filename="cover_letter.pdf") return Response( @@ -58,33 +82,16 @@ def download_cover_letter_pdf( @router.get("/{request_id}/download/enhanced-cv.docx") -def download_enhanced_cv_docx( +async def download_enhanced_cv_docx( request_id: str, + authorization: str | None = Header(default=None), cache: CacheService = Depends(get_cache), docs: DocumentService = Depends(get_docs), ): - data = _load_result(cache, request_id) + data = await _load_result(cache, request_id, authorization) cv_text = data.get("cv_raw_text", "") or "" - - # your cv_suggestions are dicts; turn them into readable bullet strings suggestions = data.get("cv_suggestions", []) or [] - bullets = [] - for s in suggestions: - if isinstance(s, dict) and "error" in s: - continue - if isinstance(s, dict): - after = (s.get("after") or "").strip() - rationale = (s.get("rationale") or "").strip() - if after and rationale: - bullets.append(f"{after} — {rationale}") - elif after: - bullets.append(after) - else: - # last-resort stringify - bullets.append(str(s)) - else: - bullets.append(str(s)) - + bullets = _suggestions_to_bullets(suggestions) f = docs.enhanced_cv_docx(cv_text, bullets, filename="enhanced_cv.docx") return Response( content=f.data, @@ -94,15 +101,25 @@ def download_enhanced_cv_docx( @router.get("/{request_id}/download/enhanced-cv.pdf") -def download_enhanced_cv_pdf( +async def download_enhanced_cv_pdf( request_id: str, + authorization: str | None = Header(default=None), cache: CacheService = Depends(get_cache), docs: DocumentService = Depends(get_docs), ): - data = _load_result(cache, request_id) + data = await _load_result(cache, request_id, authorization) cv_text = data.get("cv_raw_text", "") or "" - suggestions = data.get("cv_suggestions", []) or [] + bullets = _suggestions_to_bullets(suggestions) + f = docs.enhanced_cv_pdf(cv_text, bullets, filename="enhanced_cv.pdf") + return Response( + content=f.data, + media_type=f.content_type, + headers={"Content-Disposition": f'attachment; filename="{f.filename}"'}, + ) + + +def _suggestions_to_bullets(suggestions: list) -> list: bullets = [] for s in suggestions: if isinstance(s, dict) and "error" in s: @@ -118,10 +135,4 @@ def download_enhanced_cv_pdf( bullets.append(str(s)) else: bullets.append(str(s)) - - f = docs.enhanced_cv_pdf(cv_text, bullets, filename="enhanced_cv.pdf") - return Response( - content=f.data, - media_type=f.content_type, - headers={"Content-Disposition": f'attachment; filename="{f.filename}"'}, - ) + return bullets diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py deleted file mode 100644 index 449baf0..0000000 --- a/backend/app/api/routes/llm.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from fastapi import APIRouter - -from backend.app.models.llm_contracts import ( - FactExtractRequest, - FactExtractResponse, - JDAnalyzeRequest, - JDAnalyzeResponse, - CoverLetterRequest, - CoverLetterResponse, -) -from backend.app.core.jd_analyzer import analyze_job_description -from backend.app.core.cover_letter_gen import generate_cover_letter -from backend.app.services.llm_service import get_llm_service - -router = APIRouter(tags=["llm"]) - - -@router.post("/fact-extract", response_model=FactExtractResponse) -async def fact_extract(req: FactExtractRequest): - # Stub until Day 2 extractor lands - return FactExtractResponse(raw_sections=req.sections) - - -@router.post("/jd-analyze", response_model=JDAnalyzeResponse) -async def jd_analyze(req: JDAnalyzeRequest): - llm = get_llm_service() - out = await analyze_job_description(llm, req.job_description) - return JDAnalyzeResponse(**out) - - -@router.post("/cover-letter", response_model=CoverLetterResponse) -async def cover_letter(req: CoverLetterRequest): - llm = get_llm_service() - letter = await generate_cover_letter(llm_service=llm, facts=req.facts, job=req.job, tone=req.tone) - return CoverLetterResponse(cover_letter=letter, tone=req.tone) diff --git a/backend/app/config.py b/backend/app/config.py index d6b5424..dace161 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,19 @@ class Settings(BaseSettings): redis_url: str = "redis://localhost:6379" database_url: str = "postgresql://localhost:5432/postgres" cache_ttl_seconds: int = int(24 * 3600) - llm_base_url: str = "https://api.openai.com/v1" + + # Deployment environment — set to "production" in prod to enforce security checks + environment: str = Field(default="development") + + # Proxy/header trust for rate limiting + trust_proxy_headers: bool = Field( + default=False, + description="Whether to trust X-Forwarded-For for client IP extraction.", + ) + trusted_proxy_ips: str = Field( + default="", + description="Comma-separated proxy IPs allowed to forward client IP headers.", + ) # LLM Configuration # NOTE: keep it optional for import-time, enforce at call-time. @@ -31,12 +43,19 @@ class Settings(BaseSettings): max_retries: int = 3 timeout_seconds: int = 30 - # MLflow + # MLflow — disabled by default; enable explicitly via MLFLOW_ENABLED=true mlflow_tracking_uri: str = "file:./mlruns" experiment_name: str = "cv_helper_v1" + mlflow_enabled: bool = False + + # Access token secret — MUST be set in production via RESULT_TOKEN_SECRET env var + result_token_secret: SecretStr = Field( + default="dev-secret-change-in-production", + description="HMAC secret for signing result access tokens", + ) @lru_cache def get_settings() -> Settings: return Settings() -settings = get_settings() \ No newline at end of file +settings = get_settings() diff --git a/backend/app/core/auditor.py b/backend/app/core/auditor.py index 6c810b3..6b9eeac 100644 --- a/backend/app/core/auditor.py +++ b/backend/app/core/auditor.py @@ -1,35 +1,26 @@ """ Auditor: Anti-Hallucination System for Cover Letter Generation - -This is the SECRET WEAPON - a rigorous verification system that prevents -the LLM from inventing facts not present in the CV. - -10x Principles Applied: -1. Defense in Depth: Multiple verification layers -2. Observability: Comprehensive metrics and logging -3. Fail-Safe: Flag suspicious content proactively -4. Auditability: Clear evidence trail for every claim - -Author: Person A """ import json import logging -from typing import List -from functools import lru_cache +from contextlib import nullcontext +from typing import List, TYPE_CHECKING import mlflow from pydantic import ValidationError +from backend.app.config import get_settings from backend.app.core.prompts import PromptVersion, Prompts -from backend.app.services.llm_service import LLMService -from backend.app.services.null_llm import NullLLMService from backend.app.models.schemas import ( ExtractedFacts, ClaimVerification, - AuditReport + AuditReport, ) +if TYPE_CHECKING: + from backend.app.services.llm_client import LLMClient + logger = logging.getLogger(__name__) @@ -42,81 +33,56 @@ class Auditor: """ Audits generated cover letters to prevent hallucinations. - The auditor works in two phases: - 1. Claim Extraction: Identify all factual claims in the cover letter - 2. Claim Verification: Verify each claim against the fact table - - This is a 10x engineer's approach to the hallucination problem: - - Don't just hope the LLM is accurate - - Actively verify and flag issues - - Provide actionable feedback + Phase 1: Extract all factual claims from the cover letter. + Phase 2: Verify each claim against the extracted fact table. """ - # Threshold for flagging a letter as problematic UNSUPPORTED_CLAIM_THRESHOLD = 2 - def __init__(self, llm_service: LLMService = None): - """Initialize with optional LLM service for dependency injection.""" - self.llm = llm_service or NullLLMService() + def __init__(self, llm_client: "LLMClient"): + self.llm = llm_client self.version = PromptVersion.V1 + self._mlflow = get_settings().mlflow_enabled - def _get_llm(self) -> LLMService: - if self.llm is None: - self.llm = LLMService() - return self.llm - + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ - def audit( + async def audit( self, cover_letter: str, fact_table: ExtractedFacts, - request_id: str = None + request_id: str = None, ) -> AuditReport: - """ - Audit a cover letter against the fact table. - - Args: - cover_letter: The generated cover letter to audit - fact_table: The extracted facts from the CV - request_id: Optional request ID for tracking - - Returns: - AuditReport: Complete audit results with flagging - - Raises: - AuditorError: If auditing fails - """ if not cover_letter or not cover_letter.strip(): raise AuditorError("Cover letter cannot be empty") - if not fact_table or not fact_table.facts: raise AuditorError("Fact table cannot be empty") try: - with mlflow.start_run(run_name="audit", nested=True): - # Log audit parameters - mlflow.log_param("prompt_version", self.version.value) - mlflow.log_param("cover_letter_length", len(cover_letter)) - mlflow.log_param("fact_count", len(fact_table.facts)) - if request_id: - mlflow.log_param("request_id", request_id) - - # Phase 1: Extract claims from cover letter + if self._mlflow: + mlflow.set_experiment("audit") + ctx = mlflow.start_run(run_name="audit") if self._mlflow else nullcontext() + with ctx: + if self._mlflow: + mlflow.log_param("prompt_version", self.version.value) + mlflow.log_param("cover_letter_length", len(cover_letter)) + mlflow.log_param("fact_count", len(fact_table.facts)) + if request_id: + mlflow.log_param("request_id", request_id) + logger.info("Phase 1: Extracting claims from cover letter") - claims = self._extract_claims(cover_letter) - mlflow.log_metric("claims_extracted", len(claims)) + claims = await self._extract_claims(cover_letter) + if self._mlflow: + mlflow.log_metric("claims_extracted", len(claims)) - # Phase 2: Verify each claim logger.info( f"Phase 2: Verifying {len(claims)} claims " f"against {len(fact_table.facts)} facts" ) - verifications = self._verify_claims(claims, fact_table) + verifications = await self._verify_claims_batch(claims, fact_table) - # Phase 3: Generate audit report report = self._generate_report(verifications) - - # Log audit metrics self._log_audit_metrics(report) logger.info( @@ -124,258 +90,209 @@ def audit( f"{report.total_claims} claims supported, " f"hallucination rate: {report.hallucination_rate:.2%}" ) - if report.flagged: logger.warning( - f"FLAGGED: Cover letter contains " - f"{report.unsupported_claims} unsupported claims " + f"FLAGGED: {report.unsupported_claims} unsupported claims " f"(threshold: {self.UNSUPPORTED_CLAIM_THRESHOLD})" ) - return report except AuditorError: raise except Exception as e: - logger.error( - f"Unexpected error during audit: {e}", - exc_info=True - ) + logger.error(f"Unexpected error during audit: {e}", exc_info=True) raise AuditorError(f"Audit failed: {str(e)}") - def _extract_claims(self, cover_letter: str) -> List[str]: - """ - Extract factual claims from the cover letter. + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ - Returns: - List of claim strings - """ + async def _extract_claims(self, cover_letter: str) -> List[str]: system_prompt = Prompts.get_claim_extraction_system(self.version) - try: - response = self._get_llm().generate_response( + response = await self.llm.generate_text( system_prompt=system_prompt, user_prompt=cover_letter, - temperature=0.2, # Low temp for consistency + temperature=0.2, max_tokens=1000, - response_format={"type": "json_object"} + json_mode=True, ) - raw_content = response["content"] - - # Clean up potential Markdown formatting (common with Gemini) + if raw_content.strip().startswith("```"): - # Remove opening ```json or ``` raw_content = raw_content.split("\n", 1)[1] - # Remove closing ``` if raw_content.strip().endswith("```"): raw_content = raw_content.rsplit("```", 1)[0] - + parsed_json = json.loads(raw_content) - # Validate response structure if "claims" not in parsed_json: - raise AuditorError( - "LLM response missing 'claims' field" - ) - + raise AuditorError("LLM response missing 'claims' field") claims = parsed_json["claims"] if not isinstance(claims, list): - raise AuditorError( - "'claims' field must be a list" - ) + raise AuditorError("'claims' field must be a list") - # Filter out empty claims claims = [c.strip() for c in claims if c and c.strip()] - logger.info(f"Extracted {len(claims)} claims") + if len(claims) == 0: logger.warning(f"No claims extracted! Raw response: {raw_content[:500]}") - mlflow.log_text(raw_content, "empty_claims_response.txt") - mlflow.log_text( - json.dumps(claims, indent=2), - "extracted_claims.json" - ) + if self._mlflow: + mlflow.log_text(raw_content, "empty_claims_response.txt") + if self._mlflow: + mlflow.log_text(json.dumps(claims, indent=2), "extracted_claims.json") return claims except json.JSONDecodeError as e: logger.error(f"Failed to parse claim extraction response: {e}") - raise AuditorError( - f"LLM returned invalid JSON: {str(e)}" - ) + raise AuditorError(f"LLM returned invalid JSON: {str(e)}") + except AuditorError: + raise except Exception as e: logger.error(f"Claim extraction failed: {e}") - raise AuditorError( - f"Failed to extract claims: {str(e)}" - ) + raise AuditorError(f"Failed to extract claims: {str(e)}") - def _verify_claims( + async def _verify_claims_batch( self, claims: List[str], - fact_table: ExtractedFacts + fact_table: ExtractedFacts, ) -> List[ClaimVerification]: - """ - Verify each claim against the fact table. + """Verify all claims in a single LLM call. Falls back to sequential on parse failure.""" + if not claims: + return [] + + system_prompt = Prompts.get_batch_claim_verification_system(self.version) + user_prompt = json.dumps( + { + "claims": claims, + "facts": [f.model_dump() for f in fact_table.facts], + }, + ensure_ascii=False, + ) - Returns: - List of ClaimVerification objects - """ - verifications = [] - system_prompt = Prompts.get_claim_verification_system(self.version) + try: + response = await self.llm.generate_text( + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=0.1, + max_tokens=4000, + json_mode=True, + ) + raw = response["content"] + if raw.strip().startswith("```"): + raw = raw.split("\n", 1)[1] + if raw.strip().endswith("```"): + raw = raw.rsplit("```", 1)[0] + + data = json.loads(raw) + verifications_raw = data.get("verifications", []) + if not isinstance(verifications_raw, list) or len(verifications_raw) != len(claims): + raise ValueError( + f"Expected {len(claims)} verifications, got {len(verifications_raw)}" + ) - # Convert fact table to JSON for LLM - facts_json = json.dumps( - [fact.model_dump() for fact in fact_table.facts], - indent=2 - ) + verifications = [ + ClaimVerification( + claim=v.get("claim", claims[i]), + supported=v.get("supported", False), + source=v.get("source", "UNSUPPORTED"), + confidence=float(v.get("confidence", 0.0)), + reasoning=v.get("reasoning", ""), + ) + for i, v in enumerate(verifications_raw) + ] - for i, claim in enumerate(claims): - logger.debug(f"Verifying claim {i+1}/{len(claims)}: {claim}") + except Exception as e: + logger.warning(f"Batch verification failed ({e}), falling back to sequential") + verifications = await self._verify_claims_sequential(claims, fact_table) - # Construct verification prompt - user_prompt = f""" -CLAIM: {claim} + if self._mlflow: + mlflow.log_text( + json.dumps([v.model_dump() for v in verifications], indent=2), + "verifications.json", + ) + return verifications -FACTS: -{facts_json} -""" + async def _verify_claims_sequential( + self, + claims: List[str], + fact_table: ExtractedFacts, + ) -> List[ClaimVerification]: + """Sequential per-claim fallback.""" + verifications = [] + system_prompt = Prompts.get_claim_verification_system(self.version) + facts_json = json.dumps([f.model_dump() for f in fact_table.facts], indent=2) + for i, claim in enumerate(claims): + logger.debug(f"Verifying claim {i+1}/{len(claims)}: {claim}") + user_prompt = f"CLAIM: {claim}\n\nFACTS:\n{facts_json}" try: - response = self._get_llm().generate_response( + response = await self.llm.generate_text( system_prompt=system_prompt, user_prompt=user_prompt, - temperature=0.1, # Very low temp for strict verification + temperature=0.1, max_tokens=500, - response_format={"type": "json_object"} + json_mode=True, ) - raw_content = response["content"] - - # Clean up potential Markdown formatting (common with Gemini) if raw_content.strip().startswith("```"): - # Remove opening ```json or ``` raw_content = raw_content.split("\n", 1)[1] - # Remove closing ``` if raw_content.strip().endswith("```"): raw_content = raw_content.rsplit("```", 1)[0] parsed_json = json.loads(raw_content) - - # Create verification object - verification = ClaimVerification( + verifications.append(ClaimVerification( claim=claim, supported=parsed_json.get("supported", False), source=parsed_json.get("source", "UNSUPPORTED"), confidence=parsed_json.get("confidence", 0.0), - reasoning=parsed_json.get("reasoning", "") - ) - - verifications.append(verification) - - logger.debug( - f"Claim '{claim}': " - f"supported={verification.supported}, " - f"confidence={verification.confidence:.2f}" - ) - - except (json.JSONDecodeError, ValidationError) as e: - # If verification fails, mark as unsupported - logger.error( - f"Failed to verify claim '{claim}': {e}" - ) - verifications.append( - ClaimVerification( - claim=claim, - supported=False, - source="VERIFICATION_FAILED", - confidence=0.0, - reasoning=f"Verification error: {str(e)}" - ) - ) - - # Log verification results - mlflow.log_text( - json.dumps( - [v.model_dump() for v in verifications], - indent=2 - ), - "verifications.json" - ) - + reasoning=parsed_json.get("reasoning", ""), + )) + except (json.JSONDecodeError, ValidationError, Exception) as e: + logger.error(f"Failed to verify claim '{claim}': {e}") + verifications.append(ClaimVerification( + claim=claim, + supported=False, + source="VERIFICATION_FAILED", + confidence=0.0, + reasoning=f"Verification error: {str(e)}", + )) return verifications - def _generate_report( - self, - verifications: List[ClaimVerification] - ) -> AuditReport: - """ - Generate the final audit report with metrics. - - Returns: - AuditReport object - """ + def _generate_report(self, verifications: List[ClaimVerification]) -> AuditReport: total_claims = len(verifications) supported_claims = sum(1 for v in verifications if v.supported) unsupported_claims = total_claims - supported_claims - - # Calculate hallucination rate - hallucination_rate = ( - unsupported_claims / total_claims if total_claims > 0 else 0.0 + hallucination_rate = unsupported_claims / total_claims if total_claims > 0 else 0.0 + overall_confidence = ( + sum(v.confidence for v in verifications) / total_claims + if total_claims > 0 else 0.0 ) - - # Calculate overall confidence - if total_claims > 0: - overall_confidence = sum( - v.confidence for v in verifications - ) / total_claims - else: - overall_confidence = 0.0 - - # Flag if too many unsupported claims flagged = unsupported_claims > self.UNSUPPORTED_CLAIM_THRESHOLD - - report = AuditReport( + return AuditReport( verifications=verifications, total_claims=total_claims, supported_claims=supported_claims, unsupported_claims=unsupported_claims, hallucination_rate=hallucination_rate, flagged=flagged, - overall_confidence=overall_confidence + overall_confidence=overall_confidence, ) - return report - def _log_audit_metrics(self, report: AuditReport) -> None: - """Log comprehensive metrics to MLflow.""" + if not self._mlflow: + return mlflow.log_metric("total_claims", report.total_claims) mlflow.log_metric("supported_claims", report.supported_claims) mlflow.log_metric("unsupported_claims", report.unsupported_claims) mlflow.log_metric("hallucination_rate", report.hallucination_rate) mlflow.log_metric("overall_confidence", report.overall_confidence) mlflow.log_metric("flagged", int(report.flagged)) - - # Log distribution of confidence scores if report.verifications: confidences = [v.confidence for v in report.verifications] mlflow.log_metric("min_confidence", min(confidences)) mlflow.log_metric("max_confidence", max(confidences)) - - # Log unsupported claim types for analysis - unsupported_claims = [ - v.claim for v in report.verifications if not v.supported - ] - if unsupported_claims: - mlflow.log_text( - json.dumps(unsupported_claims, indent=2), - "unsupported_claims.json" - ) - - -# Singleton instance -#auditor = Auditor() - -@lru_cache -def get_auditor() -> "Auditor": - return Auditor() \ No newline at end of file + unsupported = [v.claim for v in report.verifications if not v.supported] + if unsupported: + mlflow.log_text(json.dumps(unsupported, indent=2), "unsupported_claims.json") diff --git a/backend/app/core/cover_letter_gen.py b/backend/app/core/cover_letter_gen.py deleted file mode 100644 index d3b137a..0000000 --- a/backend/app/core/cover_letter_gen.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, Dict -from pydantic import BaseModel, Field, ValidationError - - -COVER_LETTER_PROMPT_V1 = """You write concise, professional cover letters. - -You will receive JSON with: -- FACTS: extracted CV facts (ground truth) -- JOB: job requirements/keywords (ground truth) - -Write a cover letter grounded ONLY in FACTS. - -Rules (critical): -- Do NOT invent employers, titles, degrees, dates, achievements, numbers, or tools not present in FACTS. -- If a requirement isn't supported by FACTS, do NOT claim it. Instead say you're eager to learn. -- Keep it 180-260 words. -- Tone: {tone} - -Return STRICT JSON only (no markdown, no extra text): -{{ - "cover_letter": "string" -}} -""" - - -class _CoverOut(BaseModel): - cover_letter: str = Field(..., min_length=80) - - -def _coerce_json(raw: Any) -> Dict[str, Any]: - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - return json.loads(raw) - raise TypeError("Unexpected LLM output type") - - -async def generate_cover_letter(llm_service, facts: Dict[str, Any], job: Dict[str, Any], tone: str) -> str: - payload = {"FACTS": facts, "JOB": job} - system = COVER_LETTER_PROMPT_V1.format(tone=tone) - - resp = llm_service.generate_response( - system_prompt=system, - user_prompt=json.dumps(payload, ensure_ascii=False), - temperature=0.3, - max_tokens=700, - ) - - raw = resp.get("content", "") - - try: - data = _coerce_json(raw) - parsed = _CoverOut.model_validate(data) - return parsed.cover_letter - except (json.JSONDecodeError, ValidationError, TypeError): - return ( - "I’m excited to apply for this role. Based on the experience described in my CV, " - "I believe I can contribute effectively and grow into the responsibilities of the position. " - "I would welcome the opportunity to discuss how my background aligns with your needs." - ) diff --git a/backend/app/core/cv_enhancer.py b/backend/app/core/cv_enhancer.py index 51c9f37..e2935e3 100644 --- a/backend/app/core/cv_enhancer.py +++ b/backend/app/core/cv_enhancer.py @@ -3,11 +3,14 @@ import json import difflib from dataclasses import dataclass -from typing import Any, Dict, List +from typing import Any, Dict, List, TYPE_CHECKING from backend.app.core.prompts import Prompts, PromptVersion from backend.app.models.schemas import CVEnhancement +if TYPE_CHECKING: + from backend.app.services.llm_client import LLMClient + class CVEnhancerError(RuntimeError): pass @@ -36,10 +39,10 @@ def _unified_diff(before: str, after: str) -> str: class CVEnhancer: - def __init__(self, llm_service: Any): - self.llm = llm_service + def __init__(self, llm_client: "LLMClient"): + self.llm = llm_client - def enhance( + async def enhance( self, *, original_cv_text: str, @@ -48,7 +51,6 @@ def enhance( max_suggestions: int = 8, ) -> List[CVPatch]: system_prompt = Prompts.get_cv_enhancement_system(PromptVersion.V1) - user_prompt = json.dumps( { "max_suggestions": max_suggestions, @@ -61,12 +63,12 @@ def enhance( ) try: - resp = self.llm.generate_response( + resp = await self.llm.generate_text( system_prompt=system_prompt, user_prompt=user_prompt, temperature=0.2, max_tokens=1400, - response_format={"type": "json_object"}, + json_mode=True, ) except Exception as e: raise CVEnhancerError(f"LLM call failed: {e}") from e @@ -75,13 +77,11 @@ def enhance( if not content: raise CVEnhancerError("Empty response from CV enhancer") - # LLMService returns text; parse JSON try: data = json.loads(content) if isinstance(content, str) else content except Exception as e: raise CVEnhancerError(f"Invalid JSON from CV enhancer: {e}") from e - # Validate schema strictly try: parsed = CVEnhancement.model_validate(data) except Exception as e: @@ -93,7 +93,6 @@ def enhance( after = s.after.strip() if not before or not after or before == after: continue - patches.append( CVPatch( section=s.section.strip(), @@ -104,5 +103,4 @@ def enhance( diff_unified=_unified_diff(before, after), ) ) - return patches diff --git a/backend/app/core/fact_extractor.py b/backend/app/core/fact_extractor.py deleted file mode 100644 index 0020afb..0000000 --- a/backend/app/core/fact_extractor.py +++ /dev/null @@ -1,161 +0,0 @@ -"""CV → JSON facts (LLM) -Extracts structured facts from CV text using LLM with validation. -""" - -import json -import logging - -import mlflow -from pydantic import ValidationError - -from backend.app.core.prompts import PromptVersion, Prompts -from backend.app.services.llm_service import LLMService -from backend.app.services.null_llm import NullLLMService -from backend.app.models.schemas import ExtractedFacts - -logger = logging.getLogger(__name__) - - -class FactExtractionError(Exception): - """Raised when fact extraction fails.""" - pass - - -class FactExtractor: - """Extracts structured facts from CV text using LLM.""" - - def __init__(self, llm_service: LLMService = None): - """Initialize with optional LLM service for dependency injection.""" - self.llm = llm_service or NullLLMService() - self.version = PromptVersion.V1 - - def _get_llm(self) -> LLMService: - if self.llm is None: - self.llm = LLMService() - return self.llm - - def extract( - self, - cv_text: str, - request_id: str = None - ) -> ExtractedFacts: - """ - Extract structured facts from CV text. - - Args: - cv_text: The CV text to extract facts from - request_id: Optional request ID for tracking - - Returns: - ExtractedFacts: Validated structured facts - - Raises: - FactExtractionError: If extraction or validation fails - """ - if not cv_text or not cv_text.strip(): - raise FactExtractionError("CV text cannot be empty") - - try: - with mlflow.start_run(run_name="fact_extraction", nested=True): - # Log extraction parameters - mlflow.log_param("prompt_version", self.version.value) - mlflow.log_param("cv_length", len(cv_text)) - if request_id: - mlflow.log_param("request_id", request_id) - - # Get system prompt - system_prompt = Prompts.get_fact_extraction_system( - self.version - ) - - # Call LLM with JSON mode - cv_len = len(cv_text) - logger.info(f"Extracting facts from CV (length: {cv_len})") - response = self._get_llm().generate_response( - system_prompt=system_prompt, - user_prompt=cv_text, - temperature=0.3, # Lower temp for consistency - max_tokens=2000, - response_format={"type": "json_object"} - ) - - # Parse JSON response - raw_content = response["content"] - - # Clean up potential Markdown formatting (common with Gemini) - if raw_content.strip().startswith("```"): - # Remove opening ```json or ``` - raw_content = raw_content.split("\n", 1)[1] - # Remove closing ``` - if raw_content.strip().endswith("```"): - raw_content = raw_content.rsplit("```", 1)[0] - - try: - parsed_json = json.loads(raw_content) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse LLM response as JSON: {e}") - logger.error(f"Raw response: {raw_content[:500]}") - mlflow.log_text(raw_content, "failed_response.txt") - raise FactExtractionError(f"LLM returned invalid JSON: {str(e)}") - - # Validate against Pydantic schema - try: - extracted_facts = ExtractedFacts.model_validate( - parsed_json - ) - except ValidationError as e: - logger.error(f"Schema validation failed: {e}") - logger.error(f"Parsed JSON: {parsed_json}") - invalid_json = json.dumps(parsed_json, indent=2) - mlflow.log_text(invalid_json, "invalid_schema.json") - msg = f"Response doesn't match schema: {str(e)}" - raise FactExtractionError(msg) - - # Log extraction metrics - fact_count = len(extracted_facts.facts) - mlflow.log_metric("facts_extracted", fact_count) - - if fact_count > 0: - confidences = [ - f.confidence for f in extracted_facts.facts - ] - avg_confidence = sum(confidences) / fact_count - mlflow.log_metric("avg_confidence", avg_confidence) - - # Log category distribution - categories = {} - for fact in extracted_facts.facts: - cat = fact.category - categories[cat] = categories.get(cat, 0) + 1 - mlflow.log_dict( - categories, - "category_distribution.json" - ) - - # Log successful extraction - mlflow.log_text(raw_content, "extracted_facts.json") - logger.info(f"Successfully extracted {fact_count} facts") - - return extracted_facts - - except FactExtractionError: - # Re-raise our custom errors - raise - except Exception as e: - # Catch any unexpected errors - logger.error( - f"Unexpected error during fact extraction: {e}", - exc_info=True - ) - raise FactExtractionError( - f"Fact extraction failed: {str(e)}" - ) - - -# Singleton instance for easy import -#fact_extractor = FactExtractor() -from functools import lru_cache - -@lru_cache -def get_fact_extractor() -> "FactExtractor": - return FactExtractor() diff --git a/backend/app/core/jd_analyzer.py b/backend/app/core/jd_analyzer.py deleted file mode 100644 index f428616..0000000 --- a/backend/app/core/jd_analyzer.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, Dict, List -from pydantic import BaseModel, Field, ValidationError - - -JD_ANALYSIS_PROMPT_V1 = """You are an expert recruiter. -Extract the job requirements from the job description. - -Return STRICT JSON only (no markdown, no extra text) with this schema: -{ - "requirements": [string], - "keywords": [string] -} - -Rules: -- requirements must be concrete (skills, years, responsibilities, tools). -- keywords must be short terms (e.g., "Python", "SQL", "FastAPI"). -- Do not invent anything not explicitly in the job description. -- If uncertain, return empty lists. -""" - - -class _JDOut(BaseModel): - requirements: List[str] = Field(default_factory=list) - keywords: List[str] = Field(default_factory=list) - - -def _coerce_json(raw: Any) -> Dict[str, Any]: - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - return json.loads(raw) - raise TypeError("Unexpected LLM output type") - - -async def analyze_job_description(llm_service, job_description: str) -> Dict[str, Any]: - jd_text = job_description.strip() - - # LLMService.generate_response is sync in your code; call directly. - # If you later make it async, change accordingly. - resp = llm_service.generate_response( - system_prompt=JD_ANALYSIS_PROMPT_V1, - user_prompt=jd_text, - temperature=0.2, - max_tokens=700, - ) - - raw = resp.get("content", "") - - try: - data = _coerce_json(raw) - parsed = _JDOut.model_validate(data) - return {"requirements": parsed.requirements, "keywords": parsed.keywords} - except (json.JSONDecodeError, ValidationError, TypeError): - return {"requirements": [], "keywords": []} diff --git a/backend/app/core/prompts.py b/backend/app/core/prompts.py index 342931a..dd54b29 100644 --- a/backend/app/core/prompts.py +++ b/backend/app/core/prompts.py @@ -1,6 +1,6 @@ import json from enum import Enum -from backend.app.models.schemas import ExtractedFacts, JobAnalysis +from backend.app.models.schemas import ExtractedFacts, JobAnalysis, CVEnhancement class PromptVersion(Enum): V1 = "v1" @@ -131,6 +131,41 @@ def get_claim_verification_system(version: PromptVersion) -> str: else: raise ValueError(f"Unsupported prompt version: {version}") + @staticmethod + def get_batch_claim_verification_system(version: PromptVersion) -> str: + """Verify ALL claims against the fact table in a single LLM call.""" + if version == PromptVersion.V1: + return """ + You are a rigorous fact-checker. You will receive a JSON object with: + - "claims": a list of factual statements from a cover letter + - "facts": the complete fact table extracted from the candidate's CV + + For EACH claim, verify if it is supported by the facts. + + Confidence scoring: + - 1.0: Exact match with explicit fact + - 0.8-0.9: Strong support, directly inferable + - 0.5-0.7: Partial support, somewhat related + - 0.0-0.4: Weak or no support + + Return STRICT JSON with this exact structure (one entry per claim, same order): + { + "verifications": [ + { + "claim": "the original claim text", + "supported": true or false, + "source": "Description of supporting fact(s) or 'UNSUPPORTED'", + "confidence": 0.0-1.0, + "reasoning": "Brief explanation" + } + ] + } + + The "verifications" array MUST contain exactly the same number of items as the input "claims" array, in the same order. + """ + else: + raise ValueError(f"Unsupported prompt version: {version}") + @staticmethod def get_cv_enhancement_system(version: PromptVersion) -> str: schema_json = json.dumps(CVEnhancement.model_json_schema(), indent=2) diff --git a/backend/app/main.py b/backend/app/main.py index 2284133..a81979d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,30 +1,112 @@ +import logging +import os +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from backend.app.api.routes.health import router as health_router -from backend.app.api.routes.applications import router as applications_router +from backend.app.api.routes.applications import ( + router as applications_router, + start_generation_worker, + stop_generation_worker, +) from backend.app.utils.request_id import RequestIDMiddleware -from backend.app.api.routes.llm import router as llm_router +from backend.app.utils.rate_limiter import limiter from backend.app.api.routes.downloads import router as downloads_router +from backend.app.config import get_settings + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +def _check_redis() -> str: + try: + import redis as _redis + r = _redis.Redis.from_url(get_settings().redis_url, socket_connect_timeout=2) + r.ping() + return "ok" + except Exception as exc: + return f"UNAVAILABLE ({exc})" + + +def _check_secret(settings) -> None: + if settings.result_token_secret.get_secret_value() == "dev-secret-change-in-production": + if settings.environment == "production": + raise RuntimeError( + "RESULT_TOKEN_SECRET must be set to a secure random value in production. " + "Generate one with: openssl rand -hex 32" + ) + logger.warning( + "SECURITY: RESULT_TOKEN_SECRET is using the insecure default. " + "Set RESULT_TOKEN_SECRET env var before deploying to production." + ) + + +def _check_llm() -> str: + try: + settings = get_settings() + if not settings.gemini_api_key: + return "UNAVAILABLE (GEMINI_API_KEY not set)" + settings.gemini_api_key.get_secret_value() # just ensure it's readable + return "ok" + except Exception as exc: + return f"UNAVAILABLE ({exc})" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("=== CVForge backend starting ===") + logger.info("Service: API | status: initialising") + + redis_status = _check_redis() + logger.info(f"Service: Redis | status: {redis_status}") + + llm_status = _check_llm() + logger.info(f"Service: LLM (Gemini) | status: {llm_status}") + + logger.info("Service: Auditor | status: ok (lazy-loaded)") + logger.info("Service: CV Enhancer | status: ok (lazy-loaded)") + logger.info("Service: Document Parser | status: ok") + + _check_secret(get_settings()) + await start_generation_worker() + + logger.info("=== CVForge backend ready ===") + + yield + + await stop_generation_worker() + logger.info("=== CVForge backend shutting down ===") def create_app() -> FastAPI: - app = FastAPI(title="CV Application Helper API", version="0.1.0") + app = FastAPI(title="CV Application Helper API", version="0.1.0", lifespan=lifespan) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(RequestIDMiddleware) + allowed_origins = os.getenv( + "ALLOWED_ORIGINS", + "http://localhost:3000,http://localhost:5173" + ).split(",") app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=allowed_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST"], + allow_headers=["Content-Type", "X-Request-ID", "Authorization"], ) app.include_router(health_router) - app.include_router(applications_router) - app.include_router(llm_router) - app.include_router(downloads_router) + app.include_router(applications_router, prefix="/v1") + app.include_router(downloads_router, prefix="/v1") return app diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index e4e4e97..3e0cdff 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -24,15 +24,19 @@ class ErrorResponse(BaseModel): # Placeholder for Day-2/3 endpoints class ApplicationGenerateRequest(BaseModel): - cv_text: str = Field(..., min_length=20, description="Extracted CV text (from /applications/parse)") - job_description: str = Field(..., min_length=20) + cv_text: str = Field(..., min_length=20, max_length=50_000, description="Extracted CV text (from /applications/parse)") + job_description: str = Field(..., min_length=20, max_length=10_000) tone: Optional[str] = Field(default="professional") class ApplicationGenerateResponse(BaseModel): request_id: str - cover_letter: str - cv_suggestions: List[str] = [] + access_token: str + status: str = "processing" + filename: str + cv_hash: str + jd_hash: str + tone: str = "professional" class KeyFact(BaseModel): diff --git a/backend/app/services/cache_service.py b/backend/app/services/cache_service.py index b52ded1..02af1e3 100644 --- a/backend/app/services/cache_service.py +++ b/backend/app/services/cache_service.py @@ -1,16 +1,53 @@ +import base64 +import hashlib import json +from functools import lru_cache from typing import Any, Optional -import redis + +import redis.asyncio as redis +from cryptography.fernet import Fernet + +from backend.app.config import get_settings + + +@lru_cache(maxsize=1) +def _get_pool() -> redis.ConnectionPool: + # decode_responses=False so we can handle both plain strings and encrypted bytes + return redis.ConnectionPool.from_url( + get_settings().redis_url, + max_connections=20, + decode_responses=False, + ) + + +def _fernet(secret: str) -> Fernet: + """Derive a Fernet key from the token secret using a purpose-specific prefix.""" + raw = hashlib.sha256(f"cache-encryption:{secret}".encode()).digest() + return Fernet(base64.urlsafe_b64encode(raw)) + class CacheService: - def __init__(self, redis_url: str): - self.client = redis.Redis.from_url(redis_url, decode_responses=True) + def __init__(self): + self.client = redis.Redis(connection_pool=_get_pool()) + + async def get_json(self, key: str) -> Optional[Any]: + val = await self.client.get(key) + if val is None: + return None + return json.loads(val.decode() if isinstance(val, bytes) else val) + + async def set_json(self, key: str, value: Any, ttl_seconds: int) -> None: + await self.client.set(key, json.dumps(value).encode(), ex=ttl_seconds) - def get_json(self, key: str) -> Optional[Any]: - val = self.client.get(key) + async def get_encrypted_json(self, key: str) -> Optional[Any]: + val = await self.client.get(key) if val is None: return None - return json.loads(val) + secret = get_settings().result_token_secret.get_secret_value() + plaintext = _fernet(secret).decrypt(val if isinstance(val, bytes) else val.encode()) + return json.loads(plaintext) - def set_json(self, key: str, value: Any, ttl_seconds: int) -> None: - self.client.set(key, json.dumps(value), ex=ttl_seconds) + async def set_encrypted_json(self, key: str, value: Any, ttl_seconds: int) -> None: + secret = get_settings().result_token_secret.get_secret_value() + ciphertext = _fernet(secret).encrypt(json.dumps(value).encode()) + await self.client.set(key, ciphertext, ex=ttl_seconds) diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py index ecab776..9622154 100644 --- a/backend/app/services/llm_client.py +++ b/backend/app/services/llm_client.py @@ -1,47 +1,60 @@ -from typing import Any, Dict -import httpx import json +import logging +from typing import Any, Dict, Optional + +import openai from google import genai from google.genai import types + from backend.app.config import get_settings +logger = logging.getLogger(__name__) -# Note: The endpoints /fact-extract, /jd-analyze, /cover-letter are placeholders. When Person A finishes their FastAPI routes, you align names. class LLMClient: - """ - Talks to Person A's service (or your own internal endpoints later). - For now, it can run in stub mode if LLM_BASE_URL is not set. - """ - - def __init__(self, base_url: str | None): - self.base_url = base_url + def __init__(self): settings = get_settings() + + # Gemini (required — used for structured JSON extraction and cover letter) if not settings.gemini_api_key: raise RuntimeError("GEMINI_API_KEY is not set") - self.client = genai.Client(api_key=settings.gemini_api_key.get_secret_value()) + self._gemini = genai.Client(api_key=settings.gemini_api_key.get_secret_value()) self.model = settings.gemini_model # e.g. "gemini-2.5-flash" - + + # OpenAI (optional — used as primary in generate_text() with Gemini fallback) + self._openai: Optional[openai.AsyncOpenAI] = None + if settings.openai_api_key: + self._openai = openai.AsyncOpenAI( + api_key=settings.openai_api_key.get_secret_value(), + timeout=settings.timeout_seconds, + max_retries=2, + ) + logger.info(f"OpenAI client initialised ({settings.openai_model})") + else: + logger.info("OPENAI_API_KEY not set — generate_text() will use Gemini only") + + self._openai_model = settings.openai_model + + # ------------------------------------------------------------------ + # Structured JSON extraction (Gemini native schema enforcement) + # ------------------------------------------------------------------ + async def _generate_json(self, *, system: str, user: str, schema: dict) -> dict: - # request JSON output config = types.GenerateContentConfig( response_mime_type="application/json", response_schema=schema, system_instruction=system, temperature=0.2, ) - - resp = self.client.models.generate_content( + resp = await self._gemini.aio.models.generate_content( model=self.model, contents=user, config=config, ) - # resp.text should be JSON when response_mime_type is application/json return json.loads(resp.text) async def extract_facts(self, sections: dict) -> dict: cv_text = "\n\n".join([f"{k.upper()}:\n{v}" for k, v in sections.items() if v]) - schema = { "type": "object", "properties": { @@ -60,15 +73,12 @@ async def extract_facts(self, sections: dict) -> dict: }, "required": ["facts"], } - system = ( "Extract key facts from the CV text. " "Return only facts supported by the text. " "Confidence must be between 0 and 1." ) - user = f"CV TEXT:\n{cv_text}" - - return await self._generate_json(system=system, user=user, schema=schema) + return await self._generate_json(system=system, user=f"CV TEXT:\n{cv_text}", schema=schema) async def analyze_jd(self, job_description: str) -> dict: schema = { @@ -81,12 +91,11 @@ async def analyze_jd(self, job_description: str) -> dict: }, "required": ["summary", "required_skills", "experience_level", "remote_policy"], } - - system = "Analyze the job description and extract requirements." - user = f"JOB DESCRIPTION:\n{job_description}" - - return await self._generate_json(system=system, user=user, schema=schema) - + return await self._generate_json( + system="Analyze the job description and extract requirements.", + user=f"JOB DESCRIPTION:\n{job_description}", + schema=schema, + ) async def generate_cover_letter(self, *, facts: dict, jd: dict, tone: str = "professional") -> dict: system = "You write concise, professional cover letters grounded strictly in provided facts." @@ -96,21 +105,82 @@ async def generate_cover_letter(self, *, facts: dict, jd: dict, tone: str = "pro f"JOB ANALYSIS (JSON):\n{json.dumps(jd, ensure_ascii=False)}\n\n" "Write a cover letter. Do not invent details not present in FACTS." ) - - resp = self.client.models.generate_content( + resp = await self._gemini.aio.models.generate_content( model=self.model, contents=user, - config=types.GenerateContentConfig( - system_instruction=system, - temperature=0.4, - ), + config=types.GenerateContentConfig(system_instruction=system, temperature=0.4), ) return {"cover_letter": resp.text} + # ------------------------------------------------------------------ + # Free-text generation (OpenAI primary → Gemini fallback) + # Used by Auditor and CVEnhancer — no shared state, fully async. + # ------------------------------------------------------------------ + + async def generate_text( + self, + *, + system_prompt: str, + user_prompt: str, + temperature: float = 0.2, + max_tokens: int = 1000, + json_mode: bool = False, + ) -> Dict[str, Any]: + """ + Generate text with OpenAI (if configured) → Gemini fallback. + + Set json_mode=True when the caller expects a JSON response. This enforces + response_format={"type":"json_object"} on OpenAI and response_mime_type on + Gemini, preventing empty or prose responses that fail json.loads(). + + Returns {"content": str, "provider": "openai"|"gemini"}. + """ + if self._openai is not None: + try: + extra: Dict[str, Any] = {} + if json_mode: + extra["response_format"] = {"type": "json_object"} + resp = await self._openai.chat.completions.create( + model=self._openai_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=temperature, + max_tokens=max_tokens, + **extra, + ) + content = resp.choices[0].message.content or "" + if not content: + raise ValueError("OpenAI returned empty content") + logger.info("generate_text: OpenAI succeeded") + return {"content": content, "provider": "openai"} + except Exception as e: + logger.warning(f"generate_text: OpenAI failed ({e}), falling back to Gemini") + + # Gemini fallback — native async, no thread pool + if json_mode: + config = types.GenerateContentConfig( + response_mime_type="application/json", + system_instruction=system_prompt, + temperature=temperature, + ) + else: + config = types.GenerateContentConfig( + system_instruction=system_prompt, + temperature=temperature, + ) + resp = await self._gemini.aio.models.generate_content( + model=self.model, + contents=user_prompt, + config=config, + ) + content = resp.text or "" + if not content: + raise RuntimeError("Gemini returned empty content") + logger.info("generate_text: Gemini succeeded") + return {"content": content, "provider": "gemini"} -from backend.app.config import get_settings - -def get_llm_client(): - settings = get_settings() - return LLMClient(base_url=settings.llm_base_url) +def get_llm_client() -> LLMClient: + return LLMClient() diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py deleted file mode 100644 index 966a06c..0000000 --- a/backend/app/services/llm_service.py +++ /dev/null @@ -1,374 +0,0 @@ -""" -LLM Service with Multi-Provider Fallback (OpenAI → Gemini) - -10x Principles: -1. Reliability: Automatic fallback to secondary provider -2. Observability: Comprehensive logging and metrics -3. Abstraction: Single interface for multiple LLM providers -4. Cost Optimization: Use cheaper fallback when primary fails - -Architecture: -- Primary: OpenAI (gpt-4o) -- Fallback: Google Gemini (gemini-1.5-flash) -- Uses LangChain for provider abstraction -""" -import logging -import time -from typing import Dict, Any, Optional -from enum import Enum - -try: - import tiktoken # type: ignore -except Exception: - tiktoken = None # type: ignore - -import mlflow - -try: - from langchain_openai import ChatOpenAI # type: ignore -except ModuleNotFoundError: - ChatOpenAI = None # type: ignore - -from langchain_google_genai import ChatGoogleGenerativeAI -from langchain_core.messages import SystemMessage, HumanMessage - -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - retry_if_exception_type, - before_sleep_log -) - -from backend.app.config import get_settings - -# Setup structured logging -logger = logging.getLogger(__name__) - - -class LLMProvider(str, Enum): - """Available LLM providers.""" - OPENAI = "openai" - GEMINI = "gemini" - - -class LLMService: - """ - Multi-provider LLM service with automatic fallback. - - Flow: - 1. Try OpenAI (primary) - 2. If OpenAI fails → fallback to Gemini - 3. If both fail → raise exception - - All calls are: - - Logged to MLflow for tracking - - Retried with exponential backoff - - Timed for performance monitoring - """ - - def __init__(self): - self.settings = get_settings() - - # Initialize providers - self.openai_available = self._init_openai() - self.gemini_available = self._init_gemini() - - if not self.openai_available and not self.gemini_available: - raise RuntimeError( - "No LLM providers configured. Set OPENAI_API_KEY or GEMINI_API_KEY" - ) - - # Token encoding for cost estimation (must never prevent service startup) - self.encoding = None - if tiktoken is not None: - try: - self.encoding = tiktoken.encoding_for_model("gpt-4") - except Exception as e: - logger.warning(f"tiktoken unavailable/broken ({e}); using fallback token counting.") - self.encoding = None - - - # Initialize MLflow - mlflow.set_tracking_uri(self.settings.mlflow_tracking_uri) - mlflow.set_experiment(self.settings.experiment_name) - - logger.info( - f"LLM Service initialized: " - f"OpenAI={self.openai_available}, Gemini={self.gemini_available}" - ) - - def _init_openai(self) -> bool: - """Initialize OpenAI provider if API key available.""" - try: - if self.settings.openai_api_key is None: - logger.warning("OPENAI_API_KEY not set - OpenAI unavailable") - return False - - self.openai_client = ChatOpenAI( - model=self.settings.openai_model, - api_key=self.settings.openai_api_key.get_secret_value(), - temperature=0.7, # Default, overridden per call - timeout=self.settings.timeout_seconds, - max_retries=0, # We handle retries ourselves - ) - logger.info(f"OpenAI initialized: {self.settings.openai_model}") - return True - except Exception as e: - logger.error(f"Failed to initialize OpenAI: {e}") - return False - - def _init_gemini(self) -> bool: - """Initialize Gemini provider if API key available.""" - try: - if self.settings.gemini_api_key is None: - logger.warning("GEMINI_API_KEY not set - Gemini unavailable") - return False - - self.gemini_client = ChatGoogleGenerativeAI( - model=self.settings.gemini_model, - google_api_key=self.settings.gemini_api_key.get_secret_value(), - temperature=0.7, # Default, overridden per call - timeout=self.settings.timeout_seconds, - ) - logger.info(f"Gemini initialized: {self.settings.gemini_model}") - return True - except Exception as e: - logger.error(f"Failed to initialize Gemini: {e}") - return False - - def count_tokens(self, text: str) -> int: - """ - Estimate token count for cost tracking. - Falls back to a deterministic heuristic if tiktoken is unavailable/broken. - """ - if not text: - return 0 - - if self.encoding is not None: - try: - return len(self.encoding.encode(text)) - except Exception as e: - logger.warning(f"Token counting failed with tiktoken: {e}. Falling back.") - - # Fallback heuristic: count word-ish chunks + punctuation (stable) - import re - return len(re.findall(r"\w+|[^\w\s]", text, flags=re.UNICODE)) - - - def _call_openai( - self, - system_prompt: str, - user_prompt: str, - temperature: float, - max_tokens: int, - response_format: Optional[Dict[str, Any]] - ) -> Dict[str, Any]: - """ - Call OpenAI via LangChain. - - Note: response_format is OpenAI-specific (JSON mode). - Gemini doesn't support this directly, so we handle it differently. - """ - # Update temperature for this call - self.openai_client.temperature = temperature - self.openai_client.max_tokens = max_tokens - - # Configure JSON mode if requested - if response_format and response_format.get("type") == "json_object": - self.openai_client.model_kwargs = {"response_format": response_format} - else: - self.openai_client.model_kwargs = {} - - messages = [ - SystemMessage(content=system_prompt), - HumanMessage(content=user_prompt) - ] - - response = self.openai_client.invoke(messages) - - return { - "content": response.content, - "provider": LLMProvider.OPENAI, - "model": self.settings.openai_model - } - - def _call_gemini( - self, - system_prompt: str, - user_prompt: str, - temperature: float, - max_tokens: int, - response_format: Optional[Dict[str, Any]] - ) -> Dict[str, Any]: - """ - Call Gemini via LangChain. - - For JSON mode, we append instructions to the system prompt - since Gemini doesn't have native JSON mode. - """ - # Update temperature for this call - self.gemini_client.temperature = temperature - self.gemini_client.max_output_tokens = max_tokens - - # Handle JSON mode by augmenting the prompt - if response_format and response_format.get("type") == "json_object": - system_prompt += "\n\nIMPORTANT: You MUST respond with valid JSON only. No additional text before or after the JSON." - - messages = [ - SystemMessage(content=system_prompt), - HumanMessage(content=user_prompt) - ] - - response = self.gemini_client.invoke(messages) - - return { - "content": response.content, - "provider": LLMProvider.GEMINI, - "model": self.settings.gemini_model - } - - @retry( - retry=retry_if_exception_type((Exception,)), - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=4, max=10), - before_sleep=before_sleep_log(logger, logging.WARNING), - ) - def generate_response( - self, - system_prompt: str, - user_prompt: str, - temperature: float = 0.7, - max_tokens: int = 1000, - response_format: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - Generate LLM response with automatic fallback. - - Flow: - 1. Try OpenAI (if available) - 2. On failure, try Gemini (if available) - 3. If both fail, raise exception - - Args: - system_prompt: System/instruction prompt - user_prompt: User input - temperature: Sampling temperature (0.0-1.0) - max_tokens: Maximum tokens to generate - response_format: Optional format spec (e.g., {"type": "json_object"}) - - Returns: - Dict with: - - content: Generated text - - usage: Token counts - - model: Model used - - provider: Provider used - """ - start_time = time.time() - input_tokens = self.count_tokens(system_prompt + user_prompt) - - provider_used = None - result_text = None - error_chain = [] - - try: - with mlflow.start_run(nested=True, run_name="llm_generation"): - # Try OpenAI first - if self.openai_available: - try: - logger.debug("Attempting OpenAI...") - result = self._call_openai( - system_prompt, user_prompt, temperature, - max_tokens, response_format - ) - result_text = result["content"] - provider_used = LLMProvider.OPENAI - mlflow.log_param("provider", "openai") - mlflow.log_param("model", self.settings.openai_model) - logger.info("✓ OpenAI succeeded") - - except Exception as e: - error_msg = f"OpenAI failed: {str(e)}" - logger.warning(error_msg) - error_chain.append(error_msg) - - # Fallback to Gemini - if self.gemini_available: - try: - logger.warning("→ Falling back to Gemini...") - result = self._call_gemini( - system_prompt, user_prompt, temperature, - max_tokens, response_format - ) - result_text = result["content"] - provider_used = LLMProvider.GEMINI - mlflow.log_param("provider", "gemini") - mlflow.log_param("model", self.settings.gemini_model) - mlflow.log_param("fallback_used", True) - logger.info("✓ Gemini fallback succeeded") - - except Exception as gemini_error: - error_msg = f"Gemini failed: {str(gemini_error)}" - logger.error(error_msg) - error_chain.append(error_msg) - raise Exception( - f"All providers failed: {'; '.join(error_chain)}" - ) - else: - raise Exception( - f"OpenAI failed and no fallback available: {str(e)}" - ) - - # If OpenAI not available, use Gemini directly - elif self.gemini_available: - try: - logger.debug("Using Gemini (OpenAI not configured)...") - result = self._call_gemini( - system_prompt, user_prompt, temperature, - max_tokens, response_format - ) - result_text = result["content"] - provider_used = LLMProvider.GEMINI - mlflow.log_param("provider", "gemini") - mlflow.log_param("model", self.settings.gemini_model) - logger.info("✓ Gemini succeeded") - - except Exception as e: - error_msg = f"Gemini failed: {str(e)}" - logger.error(error_msg) - raise Exception(error_msg) - - # Calculate metrics - output_tokens = self.count_tokens(result_text) - total_tokens = input_tokens + output_tokens - duration = time.time() - start_time - - # Log metrics to MLflow - mlflow.log_param("temperature", temperature) - mlflow.log_param("input_tokens", input_tokens) - mlflow.log_metric("duration_seconds", duration) - mlflow.log_metric("output_tokens", output_tokens) - mlflow.log_metric("total_tokens", total_tokens) - - if error_chain: - mlflow.log_param("errors_before_success", error_chain) - - return { - "content": result_text, - "usage": { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_tokens": total_tokens - }, - "model": result["model"], - "provider": provider_used.value - } - - except Exception as e: - logger.error(f"LLM generation failed: {str(e)}") - raise - - -def get_llm_service() -> "LLMService": - """Factory function to get LLM service instance.""" - return LLMService() diff --git a/backend/app/services/null_llm.py b/backend/app/services/null_llm.py deleted file mode 100644 index e9a3f3b..0000000 --- a/backend/app/services/null_llm.py +++ /dev/null @@ -1,4 +0,0 @@ -class NullLLMService: - """Non-None default for tests/imports. Fails only when called.""" - def generate_response(self, *args, **kwargs): - raise RuntimeError("LLM not configured. Set OPENAI_API_KEY or GEMINI_API_KEY.") diff --git a/backend/app/utils/prometheus_metrics.py b/backend/app/utils/prometheus_metrics.py index 387dd88..f6a0f66 100644 --- a/backend/app/utils/prometheus_metrics.py +++ b/backend/app/utils/prometheus_metrics.py @@ -410,27 +410,31 @@ def metrics(): record_llm_usage ) -@app.post("/api/generate") +@app.post("/v1/applications/generate") @track_request_metrics("generate_cover_letter") -async def generate_cover_letter_endpoint(cv: str, job: str): - # Extract facts - facts = fact_extractor.extract(cv) - record_fact_extraction_metrics(len(facts.facts)) +async def generate_cover_letter_endpoint(cv: str, job: str, llm: LLMClient): + # Extract facts and analyse JD + facts = await llm.extract_facts({"raw": cv}) + record_fact_extraction_metrics(len(facts["facts"])) + jd = await llm.analyze_jd(job) # Generate cover letter - cover_letter = generate_cover_letter(facts, job) + cover = await llm.generate_cover_letter(facts=facts, jd=jd) + cover_letter = cover["cover_letter"] # Audit - audit = auditor.audit(cover_letter, facts) + from backend.app.models.schemas import ExtractedFacts + from backend.app.core.auditor import Auditor + auditor = Auditor(llm_client=llm) + audit = await auditor.audit(cover_letter, ExtractedFacts.model_validate(facts)) record_hallucination_metrics(audit) - # Record LLM usage (if available from LLM service) record_llm_usage( operation="total_pipeline", input_tokens=500, output_tokens=300, cost=0.024, - model="gpt-4" + model="gemini-2.5-flash" ) return { diff --git a/backend/app/utils/rate_limiter.py b/backend/app/utils/rate_limiter.py new file mode 100644 index 0000000..2bd4039 --- /dev/null +++ b/backend/app/utils/rate_limiter.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from ipaddress import ip_address, ip_network + +from starlette.requests import Request +from slowapi import Limiter + +from backend.app.config import get_settings + + +def _is_trusted_proxy(client_ip: str | None, trusted_proxies: str) -> bool: + if not client_ip or not trusted_proxies.strip(): + return False + + for raw in trusted_proxies.split(","): + entry = raw.strip() + if not entry: + continue + if "/" in entry: + try: + if ip_address(client_ip) in ip_network(entry, strict=False): + return True + except ValueError: + continue + elif entry == client_ip: + return True + return False + + +def _real_ip(request: Request) -> str: + settings = get_settings() + client_ip = request.client.host if request.client else None + + if settings.trust_proxy_headers and _is_trusted_proxy(client_ip, settings.trusted_proxy_ips): + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + forwarded_ip = forwarded.split(",")[0].strip() + if forwarded_ip: + return forwarded_ip + + return client_ip or "unknown" + + +limiter = Limiter( + key_func=_real_ip, + storage_uri=get_settings().redis_url, + in_memory_fallback_enabled=True, +) diff --git a/backend/app/utils/request_id.py b/backend/app/utils/request_id.py index b49ed70..adb7a01 100644 --- a/backend/app/utils/request_id.py +++ b/backend/app/utils/request_id.py @@ -7,7 +7,7 @@ class RequestIDMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - request_id = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4()) + request_id = str(uuid.uuid4()) # always server-generated; never trust client header request.state.request_id = request_id response: Response = await call_next(request) diff --git a/backend/app/utils/result_token.py b/backend/app/utils/result_token.py new file mode 100644 index 0000000..28807e4 --- /dev/null +++ b/backend/app/utils/result_token.py @@ -0,0 +1,31 @@ +import hashlib +import hmac +import time + +from backend.app.config import get_settings + + +def sign(request_id: str, ttl_seconds: int = 86400) -> str: + """Return a time-limited HMAC token: '{expiry_hex}.{hmac_hex}'. + + The expiry is baked into the token so validation is stateless. + Default TTL is 24 hours — callers should pass settings.cache_ttl_seconds + so the token and the cached result expire together. + """ + expiry_hex = format(int(time.time()) + ttl_seconds, "x") + secret = get_settings().result_token_secret.get_secret_value().encode() + digest = hmac.new(secret, f"{request_id}:{expiry_hex}".encode(), hashlib.sha256).hexdigest() + return f"{expiry_hex}.{digest}" + + +def verify(request_id: str, token: str) -> bool: + """Verify token signature and expiry. Constant-time comparison prevents timing attacks.""" + try: + expiry_hex, digest = token.split(".", 1) + if int(expiry_hex, 16) < time.time(): + return False + secret = get_settings().result_token_secret.get_secret_value().encode() + expected = hmac.new(secret, f"{request_id}:{expiry_hex}".encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, digest) + except Exception: + return False diff --git a/backend/requirements.txt b/backend/requirements.txt index 7a83802..67407c2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,15 +11,9 @@ pytest==9.0.2 redis==5.0.8 httpx==0.27.2 openai==1.59.3 -tiktoken==0.8.0 -tenacity==9.0.0 mlflow==2.19.0 jinja2==3.1.5 -langchain>=0.3.0 -langchain-core>=0.3.0 -langchain-openai>=0.2.0 -langchain-google-genai>=2.0.0 -langsmith>=0.1.0 reportlab google-genai - +slowapi==0.1.9 +cryptography>=42.0.0 diff --git a/backend/tests/integration/test_downloads.py b/backend/tests/integration/test_downloads.py index f0494a4..2e8b638 100644 --- a/backend/tests/integration/test_downloads.py +++ b/backend/tests/integration/test_downloads.py @@ -1,66 +1,127 @@ -from fastapi.testclient import TestClient -from backend.app.main import app -from backend.app.services.llm_client import get_llm_client +import time from pathlib import Path -client = TestClient(app) +from fastapi.testclient import TestClient + +from backend.app.api.routes import applications as applications_routes +from backend.app.main import app class FakeLLMClient: async def extract_facts(self, cv_sections): - return {"experiences": [], "education": [], "skills": [], "raw_sections": cv_sections} + return {"facts": [{"category": "experience", "fact": "Python engineer", "confidence": 0.9}]} async def analyze_jd(self, job_description: str): - return {"requirements": [], "keywords": [], "raw": job_description} + return { + "summary": "Python role", + "required_skills": ["Python"], + "experience_level": "mid", + "remote_policy": "hybrid", + } + + async def generate_cover_letter(self, *, facts, jd, tone: str = "professional"): + return { + "cover_letter": ( + "I am excited to apply for this Python engineering role. " + "My experience with Python and FastAPI directly aligns with your requirements." + ) + } + + async def generate_text( + self, + *, + system_prompt, + user_prompt, + temperature=0.2, + max_tokens=1000, + json_mode=False, + ): + if '"claims"' in system_prompt and '"verifications"' not in system_prompt: + return {"content": '{"claims": ["I have Python experience."]}', "provider": "fake"} + return { + "content": ( + '{"verifications":[{"claim":"I have Python experience.","supported":true,' + '"source":"CV fact: Python engineer","confidence":0.95,"reasoning":"Exact match"}]}' + ), + "provider": "fake", + } + + +def _wait_for_done(client: TestClient, request_id: str, access_token: str, timeout_seconds: float = 10.0) -> dict: + deadline = time.time() + timeout_seconds + headers = {"Authorization": f"Bearer {access_token}"} + + while time.time() < deadline: + res = client.get(f"/v1/applications/{request_id}/results", headers=headers) + assert res.status_code == 200, res.text + payload = res.json() + if payload.get("status") in {"done", "failed"}: + return payload + time.sleep(0.1) + + raise AssertionError("Timed out waiting for generation to complete") - async def generate_cover_letter(self, facts, jd, tone: str): - return {"cover_letter": "TEST COVER LETTER", "tone": tone} +def test_generate_then_downloads(monkeypatch): + monkeypatch.setattr(applications_routes, "get_llm_client", lambda: FakeLLMClient()) + fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "sample.docx" -def test_generate_then_downloads(): - app.dependency_overrides[get_llm_client] = lambda: FakeLLMClient() - try: - fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "sample.docx" + with TestClient(app) as client: with open(fixture_path, "rb") as f: - files = { - "file": ( - "sample.docx", - f.read(), - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) - } - - data = { - "job_description": "We are looking for a Python engineer with FastAPI experience and strong testing practices.", - "tone": "professional", - } + parse_r = client.post( + "/v1/applications/parse", + files={ + "file": ( + "sample.docx", + f.read(), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + }, + ) + assert parse_r.status_code == 200, parse_r.text + parsed_text = parse_r.json()["parsed_text"] + assert parsed_text + + gen_r = client.post( + "/v1/applications/generate", + json={ + "cv_text": parsed_text, + "job_description": ( + "We are looking for a Python engineer with FastAPI experience and strong testing practices." + ), + "tone": "professional", + }, + ) + assert gen_r.status_code == 200, gen_r.text + request_id = gen_r.json()["request_id"] + access_token = gen_r.json()["access_token"] + assert gen_r.json()["status"] == "processing" + + final = _wait_for_done(client, request_id, access_token) + assert final["status"] == "done", final - r = client.post("/applications/generate", files=files, data=data) - assert r.status_code == 200, r.text - request_id = r.json()["request_id"] + headers = {"Authorization": f"Bearer {access_token}"} - r = client.get(f"/applications/{request_id}/download/cover-letter.docx") + r = client.get(f"/v1/applications/{request_id}/download/cover-letter.docx", headers=headers) assert r.status_code == 200 assert r.headers["content-type"].startswith( "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) assert len(r.content) > 500 - r = client.get(f"/applications/{request_id}/download/cover-letter.pdf") + r = client.get(f"/v1/applications/{request_id}/download/cover-letter.pdf", headers=headers) assert r.status_code == 200 assert r.headers["content-type"].startswith("application/pdf") assert r.content[:4] == b"%PDF" - r = client.get(f"/applications/{request_id}/download/enhanced-cv.docx") + r = client.get(f"/v1/applications/{request_id}/download/enhanced-cv.docx", headers=headers) assert r.status_code == 200 assert r.headers["content-type"].startswith( "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) assert len(r.content) > 500 - r = client.get(f"/applications/{request_id}/download/enhanced-cv.pdf") + r = client.get(f"/v1/applications/{request_id}/download/enhanced-cv.pdf", headers=headers) assert r.status_code == 200 assert r.headers["content-type"].startswith("application/pdf") assert r.content[:4] == b"%PDF" - finally: - app.dependency_overrides.clear() diff --git a/backend/tests/integration/test_llm_routes.py b/backend/tests/integration/test_llm_routes.py deleted file mode 100644 index 0d2f029..0000000 --- a/backend/tests/integration/test_llm_routes.py +++ /dev/null @@ -1,49 +0,0 @@ -from unittest.mock import MagicMock -from fastapi.testclient import TestClient - -from backend.app.main import app - -client = TestClient(app) - - -def test_fact_extract_stub(): - r = client.post("/fact-extract", json={"sections": {"skills": "Python"}}) - assert r.status_code == 200 - data = r.json() - assert data["raw_sections"]["skills"] == "Python" - - -def test_jd_analyze_mocked(monkeypatch): - # Patch get_llm_service used by llm router module - import backend.app.api.routes.llm as llm_routes - - fake = MagicMock() - fake.generate_response.return_value = { - "content": '{"requirements":["Python","SQL"],"keywords":["Python","SQL"]}' - } - - monkeypatch.setattr(llm_routes, "get_llm_service", lambda: fake) - - r = client.post("/jd-analyze", json={"job_description": "Need Python and SQL."}) - assert r.status_code == 200 - data = r.json() - assert "Python" in data["requirements"] - - -def test_cover_letter_mocked(monkeypatch): - import backend.app.api.routes.llm as llm_routes - - fake = MagicMock() - fake.generate_response.return_value = {"content": '{"cover_letter":"Hello "}'} - # too short -> will trigger fallback; that's fine - - monkeypatch.setattr(llm_routes, "get_llm_service", lambda: fake) - - r = client.post( - "/cover-letter", - json={"facts": {"skills": ["Python"]}, "job": {"requirements": ["Python"]}, "tone": "professional"}, - ) - assert r.status_code == 200 - data = r.json() - assert isinstance(data["cover_letter"], str) - assert len(data["cover_letter"]) > 0 diff --git a/backend/tests/unit/test_auditor.py b/backend/tests/unit/test_auditor.py index cf7e01b..e91aa61 100644 --- a/backend/tests/unit/test_auditor.py +++ b/backend/tests/unit/test_auditor.py @@ -1,816 +1,97 @@ -""" -Unit tests for Auditor - -Tests the anti-hallucination system that verifies cover letters -against the fact table. - -10x Testing Principles: -1. Test all failure modes -2. Test boundary conditions -3. Test integration points -4. Verify observability (MLflow logging) -""" +import asyncio +from unittest.mock import AsyncMock import pytest -import json -from unittest.mock import MagicMock, patch from backend.app.core.auditor import Auditor, AuditorError -from backend.app.models.schemas import ( - ExtractedFacts, - KeyFact, - ClaimVerification, - AuditReport -) +from backend.app.models.schemas import ExtractedFacts, KeyFact -# Test data fixtures @pytest.fixture -def sample_fact_table(): - """Sample fact table extracted from a CV.""" +def fact_table() -> ExtractedFacts: return ExtractedFacts( facts=[ - KeyFact( - category="experience", - fact="Senior Software Engineer at Google, 2020-2023", - confidence=0.95 - ), - KeyFact( - category="experience", - fact="Led a team of 5 engineers", - confidence=0.90 - ), - KeyFact( - category="skills", - fact="Expert in Python, FastAPI, and Docker", - confidence=0.92 - ), - KeyFact( - category="education", - fact="BS Computer Science, MIT, 2018", - confidence=0.98 - ) + KeyFact(category="experience", fact="Python engineer at Acme", confidence=0.95), + KeyFact(category="skills", fact="FastAPI and Docker", confidence=0.9), ] ) -@pytest.fixture -def truthful_cover_letter(): - """Cover letter with only supported claims.""" - return """ - I am excited to apply for this position. During my time as a - Senior Software Engineer at Google from 2020-2023, I led a team - of 5 engineers and gained expertise in Python, FastAPI, and Docker. - I hold a BS in Computer Science from MIT (2018). - """ - - -@pytest.fixture -def hallucinated_cover_letter(): - """Cover letter with unsupported claims (hallucinations).""" - return """ - I am excited to apply for this position. During my time as a - Principal Engineer at Amazon from 2015-2023, I led a team of - 20 developers and gained expertise in Java, Spring Boot, and - Kubernetes. I hold a PhD in Computer Science from Stanford. - """ - - -@pytest.fixture -def mock_llm_service(): - """Mock LLMService for dependency injection.""" - return MagicMock() - - -@pytest.fixture -def mock_claim_extraction_response(): - """Mock LLM response for claim extraction.""" - return { - "content": json.dumps({ - "claims": [ - "Senior Software Engineer at Google", - "Worked from 2020-2023", - "Led a team of 5 engineers", - "Expert in Python, FastAPI, and Docker", - "BS Computer Science from MIT", - "Graduated in 2018" - ] - }), - "usage": {"input_tokens": 100, "output_tokens": 50}, - "model": "gpt-4" - } +def test_audit_rejects_empty_inputs(fact_table: ExtractedFacts) -> None: + llm = AsyncMock() + auditor = Auditor(llm_client=llm) + with pytest.raises(AuditorError): + asyncio.run(auditor.audit("", fact_table)) -@pytest.fixture -def mock_verification_response_supported(): - """Mock LLM response for supported claim verification.""" - return { - "content": json.dumps({ - "supported": True, - "source": "CV fact: Senior Software Engineer at Google, 2020-2023", - "confidence": 1.0, - "reasoning": "Exact match with CV fact" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - -@pytest.fixture -def mock_verification_response_unsupported(): - """Mock LLM response for unsupported claim verification.""" - return { - "content": json.dumps({ - "supported": False, - "source": "UNSUPPORTED", - "confidence": 0.1, - "reasoning": "No matching fact in CV" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } + with pytest.raises(AuditorError): + asyncio.run(auditor.audit("Valid letter", ExtractedFacts(facts=[]))) -class TestAuditorInit: - """Test initialization and dependency injection.""" - - def test_init_without_llm_service(self): - """Should create its own LLMService if none provided.""" - auditor = Auditor() - assert auditor.llm is not None - assert hasattr(auditor, 'version') - assert auditor.UNSUPPORTED_CLAIM_THRESHOLD == 2 - - def test_init_with_llm_service(self, mock_llm_service): - """Should use injected LLMService for testing.""" - auditor = Auditor(llm_service=mock_llm_service) - assert auditor.llm == mock_llm_service - - -class TestAuditorHappyPath: - """Test successful auditing scenarios.""" - - @patch('backend.app.core.auditor.mlflow') - def test_audit_truthful_cover_letter( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table, - mock_claim_extraction_response, - mock_verification_response_supported - ): - """ - Happy path: Truthful cover letter with all supported claims. - - This tests the complete flow: - 1. Extract claims from cover letter - 2. Verify each claim against fact table - 3. Generate audit report - 4. All claims supported -> not flagged - """ - # Arrange - mock_llm_service.generate_response.side_effect = [ - mock_claim_extraction_response, # Claim extraction - mock_verification_response_supported, # Verification 1 - mock_verification_response_supported, # Verification 2 - mock_verification_response_supported, # Verification 3 - mock_verification_response_supported, # Verification 4 - mock_verification_response_supported, # Verification 5 - mock_verification_response_supported, # Verification 6 +def test_audit_happy_path_batch_verification(fact_table: ExtractedFacts) -> None: + llm = AsyncMock() + llm.generate_text = AsyncMock( + side_effect=[ + {"content": '{"claims": ["Python engineer at Acme"]}', "provider": "fake"}, + { + "content": ( + '{"verifications":[{"claim":"Python engineer at Acme","supported":true,' + '"source":"CV fact: Python engineer at Acme","confidence":1.0,' + '"reasoning":"Exact match"}]}' + ), + "provider": "fake", + }, ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit( - truthful_cover_letter, - sample_fact_table, - request_id="test-123" - ) - - # Assert - assert isinstance(report, AuditReport) - assert report.total_claims == 6 - assert report.supported_claims == 6 - assert report.unsupported_claims == 0 - assert report.hallucination_rate == 0.0 - assert report.flagged is False - assert report.overall_confidence > 0.9 - - # Verify LLM was called 7 times (1 extraction + 6 verifications) - assert mock_llm_service.generate_response.call_count == 7 - - # Verify MLflow logging - mock_mlflow.start_run.assert_called() - mock_mlflow.log_param.assert_called() - mock_mlflow.log_metric.assert_called() - - @patch('backend.app.core.auditor.mlflow') - def test_audit_hallucinated_cover_letter( - self, - mock_mlflow, - mock_llm_service, - hallucinated_cover_letter, - sample_fact_table - ): - """ - Test auditing a cover letter with hallucinations. - - Expected: Multiple unsupported claims -> flagged - """ - # Arrange - Extract claims - claim_response = { - "content": json.dumps({ - "claims": [ - "Principal Engineer at Amazon", - "Worked from 2015-2023", - "Led a team of 20 developers", - "Expert in Java and Spring Boot" - ] - }), - "usage": {"input_tokens": 100, "output_tokens": 50}, - "model": "gpt-4" - } - - # All verifications return unsupported - unsupported = { - "content": json.dumps({ - "supported": False, - "source": "UNSUPPORTED", - "confidence": 0.2, - "reasoning": "No matching fact in CV" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.side_effect = [ - claim_response, - unsupported, - unsupported, - unsupported, - unsupported - ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit( - hallucinated_cover_letter, - sample_fact_table - ) - - # Assert - assert isinstance(report, AuditReport) - assert report.total_claims == 4 - assert report.unsupported_claims == 4 - assert report.hallucination_rate == 1.0 - assert report.flagged is True # >2 unsupported claims - assert report.overall_confidence < 0.3 - - @patch('backend.app.core.auditor.mlflow') - def test_audit_mixed_claims( - self, - mock_mlflow, - mock_llm_service, - sample_fact_table - ): - """Test cover letter with mix of supported and unsupported claims.""" - # Arrange - mixed_letter = "I worked at Google and Amazon." - - claim_response = { - "content": json.dumps({ - "claims": ["Worked at Google", "Worked at Amazon"] - }), - "usage": {"input_tokens": 20, "output_tokens": 10}, - "model": "gpt-4" - } - - supported = { - "content": json.dumps({ - "supported": True, - "source": "CV fact: Google", - "confidence": 0.9, - "reasoning": "Found in CV" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - unsupported = { - "content": json.dumps({ - "supported": False, - "source": "UNSUPPORTED", - "confidence": 0.3, - "reasoning": "Not in CV" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.side_effect = [ - claim_response, - supported, - unsupported - ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit(mixed_letter, sample_fact_table) - - # Assert - assert report.total_claims == 2 - assert report.supported_claims == 1 - assert report.unsupported_claims == 1 - assert report.hallucination_rate == 0.5 - assert report.flagged is False # Only 1 unsupported (<=2) - - -class TestAuditorErrorHandling: - """Test error handling for various failure modes.""" - - def test_audit_empty_cover_letter( - self, - mock_llm_service, - sample_fact_table - ): - """Should raise AuditorError for empty cover letter.""" - auditor = Auditor(llm_service=mock_llm_service) - - with pytest.raises(AuditorError) as exc_info: - auditor.audit("", sample_fact_table) - - assert "cannot be empty" in str(exc_info.value) - mock_llm_service.generate_response.assert_not_called() - - def test_audit_whitespace_only_cover_letter( - self, - mock_llm_service, - sample_fact_table - ): - """Should raise AuditorError for whitespace-only cover letter.""" - auditor = Auditor(llm_service=mock_llm_service) - - with pytest.raises(AuditorError) as exc_info: - auditor.audit(" \n \t ", sample_fact_table) - - assert "cannot be empty" in str(exc_info.value) - - def test_audit_empty_fact_table( - self, - mock_llm_service, - truthful_cover_letter - ): - """Should raise AuditorError for empty fact table.""" - auditor = Auditor(llm_service=mock_llm_service) - empty_facts = ExtractedFacts(facts=[]) - - with pytest.raises(AuditorError) as exc_info: - auditor.audit(truthful_cover_letter, empty_facts) - - assert "Fact table cannot be empty" in str(exc_info.value) - - @patch('backend.app.core.auditor.mlflow') - def test_audit_invalid_claim_extraction_json( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table - ): - """Should handle invalid JSON from claim extraction.""" - # Arrange - invalid_response = { - "content": "This is not valid JSON {broken", - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = invalid_response - auditor = Auditor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(AuditorError) as exc_info: - auditor.audit(truthful_cover_letter, sample_fact_table) - - assert "invalid JSON" in str(exc_info.value) - - @patch('backend.app.core.auditor.mlflow') - def test_audit_missing_claims_field( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table - ): - """Should handle response missing 'claims' field.""" - # Arrange - missing_field_response = { - "content": json.dumps({"wrong_field": ["claim1"]}), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = ( - missing_field_response - ) - auditor = Auditor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(AuditorError) as exc_info: - auditor.audit(truthful_cover_letter, sample_fact_table) - - assert "missing 'claims' field" in str(exc_info.value) + ) - @patch('backend.app.core.auditor.mlflow') - def test_audit_claims_not_list( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table - ): - """Should handle 'claims' field that's not a list.""" - # Arrange - wrong_type_response = { - "content": json.dumps({"claims": "not a list"}), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = ( - wrong_type_response + auditor = Auditor(llm_client=llm) + report = asyncio.run( + auditor.audit( + cover_letter="I worked as a Python engineer at Acme.", + fact_table=fact_table, + request_id="req-1", ) - auditor = Auditor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(AuditorError) as exc_info: - auditor.audit(truthful_cover_letter, sample_fact_table) - - assert "must be a list" in str(exc_info.value) - - @patch('backend.app.core.auditor.mlflow') - def test_audit_verification_failure_fallback( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table - ): - """ - Should handle verification failures gracefully. - - If a single claim verification fails, mark it as unsupported - and continue with other claims. - """ - # Arrange - claim_response = { - "content": json.dumps({ - "claims": ["Claim 1", "Claim 2"] - }), - "usage": {"input_tokens": 20, "output_tokens": 10}, - "model": "gpt-4" - } - - valid_verification = { - "content": json.dumps({ - "supported": True, - "source": "CV fact", - "confidence": 0.9, - "reasoning": "Found" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - # Second verification returns invalid JSON - invalid_verification = { - "content": "Invalid JSON {", - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.side_effect = [ - claim_response, - valid_verification, - invalid_verification - ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit(truthful_cover_letter, sample_fact_table) - - # Assert - assert report.total_claims == 2 - assert report.supported_claims == 1 - assert report.unsupported_claims == 1 - # Second claim should be marked unsupported due to failure - assert report.verifications[1].supported is False - assert "VERIFICATION_FAILED" in report.verifications[1].source - - -class TestAuditorBoundaryConditions: - """Test boundary conditions and edge cases.""" - - @patch('backend.app.core.auditor.mlflow') - def test_audit_exactly_threshold_unsupported_claims( - self, - mock_mlflow, - mock_llm_service, - sample_fact_table - ): - """ - Test the exact threshold (2 unsupported claims). - - Should NOT be flagged (only >2 is flagged). - """ - # Arrange - cover_letter = "Test letter" - - claim_response = { - "content": json.dumps({ - "claims": ["Claim 1", "Claim 2", "Claim 3"] - }), - "usage": {"input_tokens": 20, "output_tokens": 10}, - "model": "gpt-4" - } - - supported = { - "content": json.dumps({ - "supported": True, - "source": "CV", - "confidence": 0.9, - "reasoning": "OK" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - unsupported = { - "content": json.dumps({ - "supported": False, - "source": "UNSUPPORTED", - "confidence": 0.2, - "reasoning": "Not found" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.side_effect = [ - claim_response, - supported, # 1 supported - unsupported, # 1 unsupported - unsupported # 2 unsupported (exactly at threshold) - ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit(cover_letter, sample_fact_table) - - # Assert - assert report.unsupported_claims == 2 - assert report.flagged is False # Exactly 2, not >2 - - @patch('backend.app.core.auditor.mlflow') - def test_audit_one_over_threshold( - self, - mock_mlflow, - mock_llm_service, - sample_fact_table - ): - """Test with 3 unsupported claims (one over threshold).""" - # Arrange - cover_letter = "Test letter" - - claim_response = { - "content": json.dumps({ - "claims": ["C1", "C2", "C3", "C4"] - }), - "usage": {"input_tokens": 20, "output_tokens": 10}, - "model": "gpt-4" - } - - supported = { - "content": json.dumps({ - "supported": True, - "source": "CV", - "confidence": 0.9, - "reasoning": "OK" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - unsupported = { - "content": json.dumps({ - "supported": False, - "source": "UNSUPPORTED", - "confidence": 0.2, - "reasoning": "Not found" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.side_effect = [ - claim_response, - supported, - unsupported, - unsupported, - unsupported # 3 unsupported (>2) - ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit(cover_letter, sample_fact_table) - - # Assert - assert report.unsupported_claims == 3 - assert report.flagged is True - - @patch('backend.app.core.auditor.mlflow') - def test_audit_no_claims_extracted( - self, - mock_mlflow, - mock_llm_service, - sample_fact_table - ): - """Test cover letter with no factual claims.""" - # Arrange - cover_letter = "I am very interested in this role." - - claim_response = { - "content": json.dumps({ - "claims": [] - }), - "usage": {"input_tokens": 20, "output_tokens": 10}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.return_value = claim_response - auditor = Auditor(llm_service=mock_llm_service) - - # Act - report = auditor.audit(cover_letter, sample_fact_table) - - # Assert - assert report.total_claims == 0 - assert report.hallucination_rate == 0.0 - assert report.flagged is False - - -class TestAuditorMLflowLogging: - """Test MLflow logging functionality.""" + ) - @patch('backend.app.core.auditor.mlflow') - def test_logs_audit_parameters( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table, - mock_claim_extraction_response, - mock_verification_response_supported - ): - """Should log audit parameters to MLflow.""" - # Arrange - mock_llm_service.generate_response.side_effect = [ - mock_claim_extraction_response, - mock_verification_response_supported, - mock_verification_response_supported, - mock_verification_response_supported, - mock_verification_response_supported, - mock_verification_response_supported, - mock_verification_response_supported + assert report.total_claims == 1 + assert report.supported_claims == 1 + assert report.unsupported_claims == 0 + assert report.flagged is False + assert llm.generate_text.await_count == 2 + + +def test_audit_falls_back_to_sequential_when_batch_parse_fails(fact_table: ExtractedFacts) -> None: + llm = AsyncMock() + llm.generate_text = AsyncMock( + side_effect=[ + {"content": '{"claims": ["Python engineer at Acme", "Knows FastAPI"]}', "provider": "fake"}, + {"content": '{"verifications": "broken"}', "provider": "fake"}, + { + "content": ( + '{"supported":true,"source":"CV fact: Python engineer at Acme",' + '"confidence":0.95,"reasoning":"Match"}' + ), + "provider": "fake", + }, + { + "content": ( + '{"supported":true,"source":"CV fact: FastAPI and Docker",' + '"confidence":0.9,"reasoning":"Match"}' + ), + "provider": "fake", + }, ] - auditor = Auditor(llm_service=mock_llm_service) + ) - # Act + auditor = Auditor(llm_client=llm) + report = asyncio.run( auditor.audit( - truthful_cover_letter, - sample_fact_table, - request_id="test-123" + cover_letter="I worked as a Python engineer at Acme and know FastAPI.", + fact_table=fact_table, ) + ) - # Assert - mock_mlflow.log_param.assert_called() - mock_mlflow.log_metric.assert_called() - - @patch('backend.app.core.auditor.mlflow') - def test_logs_comprehensive_metrics( - self, - mock_mlflow, - mock_llm_service, - truthful_cover_letter, - sample_fact_table, - mock_claim_extraction_response, - mock_verification_response_supported - ): - """Should log comprehensive audit metrics.""" - # Arrange - mock_llm_service.generate_response.side_effect = [ - mock_claim_extraction_response, - ] + [mock_verification_response_supported] * 6 - - auditor = Auditor(llm_service=mock_llm_service) - - # Act - auditor.audit(truthful_cover_letter, sample_fact_table) - - # Assert - Check that key metrics were logged - # In a real test, you'd verify specific metric names - assert mock_mlflow.log_metric.call_count >= 5 - - @patch('backend.app.core.auditor.mlflow') - def test_logs_unsupported_claims( - self, - mock_mlflow, - mock_llm_service, - sample_fact_table - ): - """Should log unsupported claims for analysis.""" - # Arrange - cover_letter = "Test" - claim_response = { - "content": json.dumps({"claims": ["Unsupported claim"]}), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - unsupported = { - "content": json.dumps({ - "supported": False, - "source": "UNSUPPORTED", - "confidence": 0.1, - "reasoning": "Not in CV" - }), - "usage": {"input_tokens": 50, "output_tokens": 30}, - "model": "gpt-4" - } - - mock_llm_service.generate_response.side_effect = [ - claim_response, - unsupported - ] - auditor = Auditor(llm_service=mock_llm_service) - - # Act - auditor.audit(cover_letter, sample_fact_table) - - # Assert - mock_mlflow.log_text.assert_called() - # Verify unsupported claims were logged - - -class TestAuditorReportGeneration: - """Test audit report generation.""" - - def test_generate_report_calculates_metrics_correctly(self): - """Test that report metrics are calculated correctly.""" - # Arrange - verifications = [ - ClaimVerification( - claim="Claim 1", - supported=True, - source="CV", - confidence=0.9, - reasoning="OK" - ), - ClaimVerification( - claim="Claim 2", - supported=True, - source="CV", - confidence=0.8, - reasoning="OK" - ), - ClaimVerification( - claim="Claim 3", - supported=False, - source="UNSUPPORTED", - confidence=0.2, - reasoning="Not found" - ) - ] - - auditor = Auditor(llm_service=MagicMock()) - - # Act - report = auditor._generate_report(verifications) - - # Assert - assert report.total_claims == 3 - assert report.supported_claims == 2 - assert report.unsupported_claims == 1 - assert abs(report.hallucination_rate - 0.333) < 0.01 - assert abs(report.overall_confidence - 0.633) < 0.01 - assert report.flagged is False # Only 1 unsupported - - def test_generate_report_empty_verifications(self): - """Test report generation with no verifications.""" - auditor = Auditor(llm_service=MagicMock()) - - # Act - report = auditor._generate_report([]) - - # Assert - assert report.total_claims == 0 - assert report.hallucination_rate == 0.0 - assert report.overall_confidence == 0.0 - assert report.flagged is False + assert report.total_claims == 2 + assert report.supported_claims == 2 + assert report.unsupported_claims == 0 + assert llm.generate_text.await_count == 4 diff --git a/backend/tests/unit/test_fact_extractor.py b/backend/tests/unit/test_fact_extractor.py deleted file mode 100644 index 145d843..0000000 --- a/backend/tests/unit/test_fact_extractor.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -Unit tests for FactExtractor -Tests all failure modes and edge cases for fact extraction. -""" - -import pytest -import json -from unittest.mock import MagicMock, patch - -from backend.app.core.fact_extractor import FactExtractor, FactExtractionError -from backend.app.models.schemas import ExtractedFacts - - -# Test data fixtures -@pytest.fixture -def valid_llm_response(): - """Valid LLM response matching ExtractedFacts schema.""" - return { - "content": json.dumps({ - "facts": [ - { - "category": "experience", - "fact": "5 years of Python development", - "confidence": 0.95 - }, - { - "category": "skills", - "fact": "Expert in FastAPI and Docker", - "confidence": 0.90 - } - ] - }), - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "total_tokens": 150 - }, - "model": "gpt-4" - } - - -@pytest.fixture -def mock_llm_service(): - """Mock LLMService for dependency injection.""" - return MagicMock() - - -@pytest.fixture -def sample_cv_text(): - """Sample CV text for testing.""" - return """ - John Doe - Senior Software Engineer - - Experience: - - 5 years of Python development - - Expert in FastAPI and Docker - - Built scalable microservices - - Education: - - BS Computer Science, MIT - """ - - -class TestFactExtractorInit: - """Test initialization and dependency injection.""" - - def test_init_without_llm_service(self): - """Should create its own LLMService if none provided.""" - extractor = FactExtractor() - assert extractor.llm is not None - assert hasattr(extractor, 'version') - - def test_init_with_llm_service(self, mock_llm_service): - """Should use injected LLMService for testing.""" - extractor = FactExtractor(llm_service=mock_llm_service) - assert extractor.llm == mock_llm_service - - -class TestFactExtractorHappyPath: - """Test successful fact extraction scenarios.""" - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_valid_cv( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text, - valid_llm_response - ): - """ - Happy path: Valid CV text → Valid JSON → ExtractedFacts - - This tests the complete flow: - 1. Accept CV text - 2. Call LLM service - 3. Parse JSON response - 4. Validate schema - 5. Log metrics - 6. Return ExtractedFacts - """ - # Arrange - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - result = extractor.extract(sample_cv_text) - - # Assert - assert isinstance(result, ExtractedFacts) - assert len(result.facts) == 2 - assert result.facts[0].category == "experience" - assert result.facts[0].confidence == 0.95 - - # Verify LLM was called with correct parameters - mock_llm_service.generate_response.assert_called_once() - call_args = mock_llm_service.generate_response.call_args - assert call_args.kwargs['user_prompt'] == sample_cv_text - assert call_args.kwargs['temperature'] == 0.3 - assert call_args.kwargs['response_format'] == { - "type": "json_object" - } - - # Verify MLflow logging - mock_mlflow.start_run.assert_called() - mock_mlflow.log_param.assert_called() - mock_mlflow.log_metric.assert_called() - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_with_request_id( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text, - valid_llm_response - ): - """Should log request_id when provided.""" - # Arrange - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - request_id = "test-request-123" - - # Act - result = extractor.extract(sample_cv_text, request_id=request_id) - - # Assert - assert isinstance(result, ExtractedFacts) - # Verify request_id was logged - # (You'd need to check the specific call in a real test) - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_empty_facts_list( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text - ): - """Should handle valid JSON with empty facts list.""" - # Arrange - empty_response = { - "content": json.dumps({"facts": []}), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = empty_response - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - result = extractor.extract(sample_cv_text) - - # Assert - assert isinstance(result, ExtractedFacts) - assert len(result.facts) == 0 - - -class TestFactExtractorErrorHandling: - """Test error handling for various failure modes.""" - - def test_extract_empty_cv_text(self, mock_llm_service): - """Should raise FactExtractionError for empty CV text.""" - extractor = FactExtractor(llm_service=mock_llm_service) - - with pytest.raises(FactExtractionError) as exc_info: - extractor.extract("") - - assert "cannot be empty" in str(exc_info.value) - # Verify LLM was never called (fail fast) - mock_llm_service.generate_response.assert_not_called() - - def test_extract_whitespace_only_cv_text(self, mock_llm_service): - """Should raise FactExtractionError for whitespace-only text.""" - extractor = FactExtractor(llm_service=mock_llm_service) - - with pytest.raises(FactExtractionError) as exc_info: - extractor.extract(" \n \t ") - - assert "cannot be empty" in str(exc_info.value) - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_invalid_json_response( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text - ): - """Should handle invalid JSON from LLM gracefully.""" - # Arrange - invalid_json_response = { - "content": "This is not valid JSON {broken", - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = ( - invalid_json_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(FactExtractionError) as exc_info: - extractor.extract(sample_cv_text) - - assert "invalid JSON" in str(exc_info.value) - # Verify error was logged to MLflow - mock_mlflow.log_text.assert_called() - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_wrong_schema( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text - ): - """Should handle JSON that doesn't match ExtractedFacts schema.""" - # Arrange - Valid JSON but wrong structure - wrong_schema_response = { - "content": json.dumps({ - "wrong_key": "wrong_value", - "not_facts": [] - }), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = ( - wrong_schema_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(FactExtractionError) as exc_info: - extractor.extract(sample_cv_text) - - assert "doesn't match schema" in str(exc_info.value) - # Verify invalid schema was logged - mock_mlflow.log_text.assert_called() - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_missing_required_fields( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text - ): - """Should handle JSON with missing required fields.""" - # Arrange - Missing 'confidence' field - missing_field_response = { - "content": json.dumps({ - "facts": [ - { - "category": "experience", - "fact": "5 years Python" - # Missing 'confidence' field - } - ] - }), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = ( - missing_field_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(FactExtractionError) as exc_info: - extractor.extract(sample_cv_text) - - assert "doesn't match schema" in str(exc_info.value) - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_llm_service_failure( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text - ): - """Should handle LLM service failures gracefully.""" - # Arrange - mock_llm_service.generate_response.side_effect = Exception( - "OpenAI API error" - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act & Assert - with pytest.raises(FactExtractionError) as exc_info: - extractor.extract(sample_cv_text) - - assert "Fact extraction failed" in str(exc_info.value) - - -class TestFactExtractorMLflowLogging: - """Test MLflow logging functionality.""" - - @patch('backend.app.core.fact_extractor.mlflow') - def test_logs_extraction_parameters( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text, - valid_llm_response - ): - """Should log extraction parameters to MLflow.""" - # Arrange - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - extractor.extract(sample_cv_text, request_id="test-123") - - # Assert - Verify parameters were logged - # Note: In real tests you'd check specific calls - assert mock_mlflow.log_param.call_count >= 3 - mock_mlflow.log_metric.assert_called() - - @patch('backend.app.core.fact_extractor.mlflow') - def test_logs_fact_metrics( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text, - valid_llm_response - ): - """Should log fact count and average confidence.""" - # Arrange - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - extractor.extract(sample_cv_text) - - # Assert - mock_mlflow.log_metric.assert_called() - # Check that both facts_extracted and avg_confidence were logged - - @patch('backend.app.core.fact_extractor.mlflow') - def test_logs_category_distribution( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text, - valid_llm_response - ): - """Should log category distribution as JSON.""" - # Arrange - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - extractor.extract(sample_cv_text) - - # Assert - mock_mlflow.log_dict.assert_called_once() - - -class TestFactExtractorEdgeCases: - """Test edge cases and boundary conditions.""" - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_very_long_cv( - self, - mock_mlflow, - mock_llm_service, - valid_llm_response - ): - """Should handle very long CV text.""" - # Arrange - long_cv = "Very long text. " * 10000 # ~150,000 chars - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - result = extractor.extract(long_cv) - - # Assert - assert isinstance(result, ExtractedFacts) - # Verify CV length was logged - mock_mlflow.log_param.assert_called() - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_special_characters( - self, - mock_mlflow, - mock_llm_service, - valid_llm_response - ): - """Should handle CVs with special characters.""" - # Arrange - special_cv = "Name: José María\nEmail: test@example.com\n日本語" - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - result = extractor.extract(special_cv) - - # Assert - assert isinstance(result, ExtractedFacts) - - @patch('backend.app.core.fact_extractor.mlflow') - def test_extract_confidence_boundary_values( - self, - mock_mlflow, - mock_llm_service, - sample_cv_text - ): - """Should accept confidence values at boundaries (0.0 and 1.0).""" - # Arrange - boundary_response = { - "content": json.dumps({ - "facts": [ - { - "category": "test", - "fact": "Min confidence", - "confidence": 0.0 - }, - { - "category": "test", - "fact": "Max confidence", - "confidence": 1.0 - } - ] - }), - "usage": {"input_tokens": 10, "output_tokens": 5}, - "model": "gpt-4" - } - mock_llm_service.generate_response.return_value = ( - boundary_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - result = extractor.extract(sample_cv_text) - - # Assert - assert len(result.facts) == 2 - assert result.facts[0].confidence == 0.0 - assert result.facts[1].confidence == 1.0 - - -# Integration-style test (optional, can be moved to integration tests) -class TestFactExtractorIntegration: - """ - These tests are closer to integration tests as they test - the interaction between components. - """ - - @patch('backend.app.core.fact_extractor.mlflow') - @patch('backend.app.core.fact_extractor.Prompts') - def test_uses_correct_prompt_version( - self, - mock_prompts, - mock_mlflow, - mock_llm_service, - sample_cv_text, - valid_llm_response - ): - """Should use the configured prompt version.""" - # Arrange - mock_prompts.get_fact_extraction_system.return_value = ( - "System prompt" - ) - mock_llm_service.generate_response.return_value = ( - valid_llm_response - ) - extractor = FactExtractor(llm_service=mock_llm_service) - - # Act - extractor.extract(sample_cv_text) - - # Assert - mock_prompts.get_fact_extraction_system.assert_called_once() - # Verify it was called with the correct version diff --git a/backend/tests/unit/test_llm_service.py b/backend/tests/unit/test_llm_service.py deleted file mode 100644 index d122a5a..0000000 --- a/backend/tests/unit/test_llm_service.py +++ /dev/null @@ -1,117 +0,0 @@ -from unittest.mock import MagicMock, patch -from backend.app.services.llm_service import get_llm_service - - -def _fake_settings(with_gemini=True): - s = MagicMock() - s.openai_api_key = MagicMock() - s.openai_api_key.get_secret_value.return_value = "fake-openai-key" - s.gemini_api_key = MagicMock() if with_gemini else None - if with_gemini: - s.gemini_api_key.get_secret_value.return_value = "fake-gemini-key" - s.openai_model = "gpt-4o" - s.gemini_model = "gemini-1.5-flash" - s.timeout_seconds = 30 - s.max_retries = 1 - s.mlflow_tracking_uri = "file:./test_mlruns" - s.experiment_name = "test-exp" - return s - - -@patch("backend.app.services.llm_service.mlflow") -@patch("backend.app.services.llm_service.get_settings") -@patch("backend.app.services.llm_service.ChatOpenAI") -@patch("backend.app.services.llm_service.ChatGoogleGenerativeAI") -def test_count_tokens(mock_gemini, mock_openai, mock_get_settings, mock_mlflow): - """Test token counting functionality.""" - mock_get_settings.return_value = _fake_settings() - - # Mock LangChain clients initialization - mock_openai.return_value = MagicMock() - mock_gemini.return_value = MagicMock() - - llm_service = get_llm_service() - count = llm_service.count_tokens("Hello world") - - assert isinstance(count, int) - assert count > 0 - - -@patch("backend.app.services.llm_service.mlflow") -@patch("backend.app.services.llm_service.get_settings") -@patch("backend.app.services.llm_service.ChatOpenAI") -@patch("backend.app.services.llm_service.ChatGoogleGenerativeAI") -def test_generate_response_success(mock_gemini, mock_openai, mock_get_settings, mock_mlflow): - """Test successful response generation using OpenAI.""" - mock_get_settings.return_value = _fake_settings() - - # Make mlflow.start_run act like a context manager - mock_mlflow.start_run.return_value.__enter__.return_value = None - mock_mlflow.start_run.return_value.__exit__.return_value = None - - # Mock OpenAI client and response - mock_openai_client = MagicMock() - mock_openai.return_value = mock_openai_client - - mock_response = MagicMock() - mock_response.content = "This is a mock response from OpenAI" - mock_openai_client.invoke.return_value = mock_response - - # Mock Gemini client (shouldn't be called in success case) - mock_gemini_client = MagicMock() - mock_gemini.return_value = mock_gemini_client - - llm_service = get_llm_service() - result = llm_service.generate_response("System prompt", "User prompt") - - # Verify response structure - assert "content" in result - assert result["content"] == "This is a mock response from OpenAI" - assert "usage" in result - assert "provider" in result - assert result["provider"] == "openai" - - # OpenAI should have been called - mock_openai_client.invoke.assert_called_once() - - # Gemini should NOT have been called (no fallback needed) - mock_gemini_client.invoke.assert_not_called() - - -@patch("backend.app.services.llm_service.mlflow") -@patch("backend.app.services.llm_service.get_settings") -@patch("backend.app.services.llm_service.ChatOpenAI") -@patch("backend.app.services.llm_service.ChatGoogleGenerativeAI") -def test_fallback_to_gemini(mock_gemini, mock_openai, mock_get_settings, mock_mlflow): - """Test fallback to Gemini when OpenAI fails.""" - mock_get_settings.return_value = _fake_settings() - - # Make mlflow.start_run act like a context manager - mock_mlflow.start_run.return_value.__enter__.return_value = None - mock_mlflow.start_run.return_value.__exit__.return_value = None - - # Mock OpenAI client to fail - mock_openai_client = MagicMock() - mock_openai.return_value = mock_openai_client - mock_openai_client.invoke.side_effect = Exception("OpenAI API error") - - # Mock Gemini client to succeed - mock_gemini_client = MagicMock() - mock_gemini.return_value = mock_gemini_client - - mock_gemini_response = MagicMock() - mock_gemini_response.content = "This is a mock response from Gemini" - mock_gemini_client.invoke.return_value = mock_gemini_response - - llm_service = get_llm_service() - result = llm_service.generate_response("System prompt", "User prompt") - - # Verify response structure - assert "content" in result - assert result["content"] == "This is a mock response from Gemini" - assert "provider" in result - assert result["provider"] == "gemini" - - # Both should have been called (OpenAI failed, Gemini succeeded) - mock_openai_client.invoke.assert_called_once() - mock_gemini_client.invoke.assert_called_once() diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..776c168 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +RUN npm run build + +# Serve stage +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +# Route all paths to index.html for SPA +RUN printf 'server {\n\ + listen 80;\n\ + root /usr/share/nginx/html;\n\ + index index.html;\n\ + location / {\n\ + try_files $uri $uri/ /index.html;\n\ + }\n\ +}\n' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/src/api/applications.ts b/frontend/src/api/applications.ts index b84e377..42c9376 100644 --- a/frontend/src/api/applications.ts +++ b/frontend/src/api/applications.ts @@ -11,8 +11,9 @@ export type GenerateRequest = { }; export type GenerateResponse = { - application_id: string; - status?: "queued" | "processing" | "done" | "failed"; + request_id: string; + access_token: string; + status?: "processing" | "done" | "failed"; }; export type AuditItem = { @@ -20,39 +21,54 @@ export type AuditItem = { supported: boolean; source: string; confidence: number; + reasoning?: string; +}; + +export type AuditReport = { + verifications: AuditItem[]; + total_claims: number; + supported_claims: number; + unsupported_claims: number; + hallucination_rate: number; + flagged: boolean; + overall_confidence: number; }; export type ResultsResponse = { - application_id: string; + request_id: string; status: "processing" | "done" | "failed"; cover_letter?: string; cv_suggestions?: Array<{ before: string; after: string }>; - audit_report?: AuditItem[]; + audit_report?: AuditReport; error?: string; + warnings?: string[]; }; export async function parseCv(file: File): Promise { const form = new FormData(); form.append("file", file); - const { data } = await api.post("/applications/parse", form, { + const { data } = await api.post("/v1/applications/parse", form, { headers: { "Content-Type": "multipart/form-data" }, }); return data; } export async function generateApplication(req: GenerateRequest): Promise { - const { data } = await api.post("/applications/generate", req); + const { data } = await api.post("/v1/applications/generate", req); return data; } -export async function getResults(id: string): Promise { - const { data } = await api.get(`/applications/${id}/results`); +export async function getResults(id: string, accessToken: string): Promise { + const { data } = await api.get(`/v1/applications/${id}/results`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); return data; } export async function pollResults( id: string, + accessToken: string, opts?: { intervalMs?: number; timeoutMs?: number } ): Promise { const intervalMs = opts?.intervalMs ?? 1500; @@ -60,13 +76,12 @@ export async function pollResults( const start = Date.now(); while (true) { - const res = await getResults(id); + const res = await getResults(id, accessToken); if (res.status === "done" || res.status === "failed") return res; if (Date.now() - start > timeoutMs) { - return { application_id: id, status: "failed", error: "Polling timed out" }; + return { request_id: id, status: "failed", error: "Polling timed out" }; } await new Promise((r) => setTimeout(r, intervalMs)); } } - diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 24c4350..e097348 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -17,7 +17,7 @@ api.interceptors.response.use( const status = err.response?.status; const data = err.response?.data as any; const apiErr: ApiError = { - message: data?.message ?? data?.detail ?? err.message ?? "Request failed", + message: data?.message ?? (typeof data?.detail === "string" ? data.detail : data?.detail ? JSON.stringify(data.detail) : null) ?? err.message ?? "Request failed", status, detail: data, }; diff --git a/frontend/src/components/CVUploader.tsx b/frontend/src/components/CVUploader.tsx index 3e3e985..d33b752 100644 --- a/frontend/src/components/CVUploader.tsx +++ b/frontend/src/components/CVUploader.tsx @@ -22,56 +22,82 @@ export function CVUploader({ function handleFile(file: File) { const err = validate(file); - if (err) { - setError(err); - return; - } + if (err) { setError(err); return; } setError(null); onFileSelected(file); } return ( -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - const f = e.dataTransfer.files?.[0]; - if (f) handleFile(f); - }} - > -

Upload your CV

-

Drag & drop a PDF/DOCX, or click to choose

- - - - { - const f = e.target.files?.[0]; +
+
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }} - /> - - {error &&

{error}

} + onClick={() => inputRef.current?.click()} + > +
+ {isDragging ? "⬇" : "◈"} +
+

+ Drop your CV here +

+

+ PDF or DOCX · up to {maxSizeMb}MB +

+ + { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + /> +
+ {error && ( +

{error}

+ )}
); -} - +} \ No newline at end of file diff --git a/frontend/src/components/DiffViewer.tsx b/frontend/src/components/DiffViewer.tsx index 1ab0f7e..46e5785 100644 --- a/frontend/src/components/DiffViewer.tsx +++ b/frontend/src/components/DiffViewer.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import type { CvSuggestion } from "../types/application"; type Decision = "accepted" | "rejected"; - type Props = { suggestions: CvSuggestion[]; onChange?: (decisions: Record) => void; @@ -27,61 +26,89 @@ export default function DiffViewer({ suggestions, onChange }: Props) { } return ( -
-
-

CV Suggestions

-
- Acceptance rate: {Math.round(acceptanceRate * 100)}% +
+
+

+ CV Suggestions +

+
+
+ {Math.round(acceptanceRate * 100)}% +
+
ACCEPTED
-
- {suggestions.length === 0 ? ( -

No suggestions available.

- ) : ( - suggestions.map((s) => ( -
-
-
{s.section}
- -
- - + {suggestions.length === 0 ? ( +

No suggestions available.

+ ) : ( +
+ {suggestions.map((s) => { + const dec = decisions[s.id]; + return ( +
+
+ + {s.section} + +
+ + +
-
-
-
-
Before
-
- {s.before} +
+
+
Before
+
{s.before}
-
-
-
After
-
- {s.after} +
+
After
+
{s.after}
- - {decisions[s.id] ? ( -
Decision: {decisions[s.id]}
- ) : null} -
- )) - )} -
+ ); + })} +
+ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 60087d7..48c4232 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -1,24 +1,92 @@ +import { useState } from "react"; + type Props = { applicationId: string; + accessToken: string; apiBaseUrl: string; }; -export default function DownloadButton({ applicationId, apiBaseUrl }: Props) { - const cvUrl = `${apiBaseUrl}/applications/${applicationId}/download?type=cv`; - const clUrl = `${apiBaseUrl}/applications/${applicationId}/download?type=cover_letter`; - const zipUrl = `${apiBaseUrl}/applications/${applicationId}/download?type=zip`; +type DownloadTarget = { + path: string; + filename: string; + label: string; + primary?: boolean; +}; + +const DOWNLOADS: DownloadTarget[] = [ + { path: "enhanced-cv.docx", filename: "enhanced_cv.docx", label: "↓ Enhanced CV", primary: true }, + { path: "cover-letter.docx", filename: "cover_letter.docx", label: "↓ Cover Letter" }, +]; + +export default function DownloadButton({ applicationId, accessToken, apiBaseUrl }: Props) { + const [active, setActive] = useState(null); + const [error, setError] = useState(null); + const base = `${apiBaseUrl}/v1/applications/${applicationId}/download`; + + async function handleDownload(target: DownloadTarget) { + setError(null); + setActive(target.path); + + try { + const res = await fetch(`${base}/${target.path}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const detail = await res.text(); + throw new Error(detail || `Download failed (${res.status})`); + } + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = target.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e: any) { + setError(e?.message ?? "Download failed"); + } finally { + setActive(null); + } + } + + const btnBase: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 8, + padding: "10px 18px", + borderRadius: 10, + fontSize: 13, + fontWeight: 500, + border: "1px solid transparent", + fontFamily: "var(--font-body)", + transition: "all 0.2s", + cursor: "pointer", + }; return ( -
- - Download enhanced CV - - - Download cover letter - - - Download ZIP - +
+
+ {DOWNLOADS.map((target) => ( + + ))} +
+ {error &&
{error}
}
); } diff --git a/frontend/src/components/JobDescInput.tsx b/frontend/src/components/JobDescInput.tsx index d64c1e1..1b8c4a7 100644 --- a/frontend/src/components/JobDescInput.tsx +++ b/frontend/src/components/JobDescInput.tsx @@ -7,25 +7,54 @@ export function JobDescInput({ onChange: (v: string) => void; maxChars?: number; }) { - const words = value.trim() ? value.trim().split(/\\s+/).length : 0; + const words = value.trim() ? value.trim().split(/\s+/).length : 0; + const pct = value.length / maxChars; return (
-