From 00452214fecc420b23c2955b181bffce37e68e0c Mon Sep 17 00:00:00 2001 From: SmartGridsML Date: Tue, 24 Feb 2026 00:19:52 +0000 Subject: [PATCH 01/20] fix(docker): fix backend module path and add frontend service --- backend/Dockerfile | 5 +++-- backend/__init__.py | 0 infra/docker-compose.yml | 18 +++++++++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 backend/__init__.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 649a81b..a64551d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,8 +8,9 @@ ENV PYTHONUNBUFFERED=1 COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt -COPY app /app/app +COPY __init__.py /app/backend/__init__.py +COPY app /app/backend/app 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/infra/docker-compose.yml b/infra/docker-compose.yml index cf40ccc..565ef9a 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,14 +1,24 @@ services: + frontend: + build: + context: ../frontend + args: + VITE_API_BASE_URL: http://localhost:8000 + container_name: cv-helper-frontend + ports: + - "3000:80" + depends_on: + - backend + backend: build: - context: .. + context: ../backend container_name: cv-helper-backend ports: - "8000:8000" environment: - REDIS_URL=redis://redis:6379/0 - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres - - LLM_BASE_URL= depends_on: - redis - postgres @@ -18,7 +28,7 @@ services: tests: build: - context: .. + context: ../backend working_dir: /app volumes: - ..:/app @@ -26,7 +36,6 @@ services: - PYTHONPATH=/app - REDIS_URL=redis://redis:6379/0 - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres - - LLM_BASE_URL= depends_on: - redis - postgres @@ -53,4 +62,3 @@ services: volumes: postgres_data: - From b757cbb3efe8fb5d5b09ef49219a8dff420d31db Mon Sep 17 00:00:00 2001 From: SmartGridsML Date: Tue, 24 Feb 2026 00:20:09 +0000 Subject: [PATCH 02/20] refactor(llm): remove dead LLM_BASE_URL config --- .github/workflows/ci.yml | 2 -- backend/app/config.py | 1 - backend/app/services/llm_client.py | 11 ++--------- 3 files changed, 2 insertions(+), 12 deletions(-) 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/backend/app/config.py b/backend/app/config.py index d6b5424..e2ede2a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,6 @@ 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" # LLM Configuration # NOTE: keep it optional for import-time, enforce at call-time. diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py index ecab776..99cb972 100644 --- a/backend/app/services/llm_client.py +++ b/backend/app/services/llm_client.py @@ -9,13 +9,7 @@ # 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() if not settings.gemini_api_key: raise RuntimeError("GEMINI_API_KEY is not set") @@ -111,6 +105,5 @@ async def generate_cover_letter(self, *, facts: dict, jd: dict, tone: str = "pro from backend.app.config import get_settings def get_llm_client(): - settings = get_settings() - return LLMClient(base_url=settings.llm_base_url) + return LLMClient() From 9ca7b78beaf8240cb94e289aaae1183bd4311213 Mon Sep 17 00:00:00 2001 From: SmartGridsML Date: Tue, 24 Feb 2026 00:20:42 +0000 Subject: [PATCH 03/20] fix(frontend): switch to Tailwind v4 import syntax, delete artifact file --- frontend/src/index 2.css | 5 ----- frontend/src/index.css | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 frontend/src/index 2.css diff --git a/frontend/src/index 2.css b/frontend/src/index 2.css deleted file mode 100644 index 567019a..0000000 --- a/frontend/src/index 2.css +++ /dev/null @@ -1,5 +0,0 @@ -cat > src/index.css <<'EOF' -@tailwind base; -@tailwind components; -@tailwind utilities; -EOF diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..f1d8c73 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; From 07f43310653cd1ba8603a53fc5adb8f4bce1e762 Mon Sep 17 00:00:00 2001 From: SmartGridsML Date: Tue, 24 Feb 2026 00:20:55 +0000 Subject: [PATCH 04/20] feat(terraform): add frontend ECS service, ECR repos with lifecycle policies, ALB routing --- frontend/Dockerfile | 30 ++++++++++++ infra/terraform/alb.tf | 37 +++++++++++++++ infra/terraform/ecr.tf | 91 ++++++++++++++++++++++++++++++++++++ infra/terraform/ecs.tf | 72 ++++++++++++++++++++++++++++ infra/terraform/outputs.tf | 10 ++++ infra/terraform/variables.tf | 12 +++++ 6 files changed, 252 insertions(+) create mode 100644 frontend/Dockerfile create mode 100644 infra/terraform/ecr.tf 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/infra/terraform/alb.tf b/infra/terraform/alb.tf index cba9e95..0642287 100644 --- a/infra/terraform/alb.tf +++ b/infra/terraform/alb.tf @@ -72,12 +72,49 @@ resource "aws_lb_target_group" "app" { tags = local.tags } +resource "aws_lb_target_group" "frontend" { + name = "${var.project_name}-frontend-tg" + port = 80 + protocol = "HTTP" + vpc_id = local.vpc_id + target_type = "ip" + + health_check { + path = "/" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + timeout = 5 + matcher = "200-399" + } + + tags = local.tags +} + resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.app.arn port = 80 protocol = "HTTP" + # Default: serve frontend default_action { + type = "forward" + target_group_arn = aws_lb_target_group.frontend.arn + } +} + +# Route /api/* and /docs/* to the backend +resource "aws_lb_listener_rule" "backend" { + listener_arn = aws_lb_listener.http.arn + priority = 10 + + condition { + path_pattern { + values = ["/api/*", "/docs", "/openapi.json", "/health"] + } + } + + action { type = "forward" target_group_arn = aws_lb_target_group.app.arn } diff --git a/infra/terraform/ecr.tf b/infra/terraform/ecr.tf new file mode 100644 index 0000000..8f0fa5e --- /dev/null +++ b/infra/terraform/ecr.tf @@ -0,0 +1,91 @@ +resource "aws_ecr_repository" "backend" { + name = "${var.project_name}-backend" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } + + tags = local.tags +} + +resource "aws_ecr_lifecycle_policy" "backend" { + repository = aws_ecr_repository.backend.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Remove untagged images after 1 day" + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 1 + } + action = { type = "expire" } + }, + { + rulePriority = 2 + description = "Keep only last 5 tagged images" + selection = { + tagStatus = "tagged" + tagPatternList = ["*"] + countType = "imageCountMoreThan" + countNumber = 5 + } + action = { type = "expire" } + } + ] + }) +} + +resource "aws_ecr_repository" "frontend" { + name = "${var.project_name}-frontend" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } + + tags = local.tags +} + +resource "aws_ecr_lifecycle_policy" "frontend" { + repository = aws_ecr_repository.frontend.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Remove untagged images after 1 day" + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 1 + } + action = { type = "expire" } + }, + { + rulePriority = 2 + description = "Keep only last 5 tagged images" + selection = { + tagStatus = "tagged" + tagPatternList = ["*"] + countType = "imageCountMoreThan" + countNumber = 5 + } + action = { type = "expire" } + } + ] + }) +} diff --git a/infra/terraform/ecs.tf b/infra/terraform/ecs.tf index 50aa1c0..0e5507b 100644 --- a/infra/terraform/ecs.tf +++ b/infra/terraform/ecs.tf @@ -87,3 +87,75 @@ resource "aws_ecs_service" "app" { tags = local.tags } + +# ── Frontend ────────────────────────────────────────────────────────────────── + +resource "aws_cloudwatch_log_group" "frontend" { + name = "/ecs/${var.project_name}-frontend" + retention_in_days = var.log_retention_in_days + + tags = local.tags +} + +resource "aws_ecs_task_definition" "frontend" { + count = var.frontend_container_image != "" ? 1 : 0 + family = "${var.project_name}-frontend-task" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = 256 + memory = 512 + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "frontend" + image = var.frontend_container_image + essential = true + portMappings = [ + { + containerPort = 80 + hostPort = 80 + protocol = "tcp" + } + ] + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.frontend.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = local.tags +} + +resource "aws_ecs_service" "frontend" { + count = var.frontend_container_image != "" ? 1 : 0 + name = "${var.project_name}-frontend-service" + cluster = aws_ecs_cluster.app.id + task_definition = aws_ecs_task_definition.frontend[0].arn + desired_count = var.frontend_desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = local.private_subnet_ids + security_groups = [aws_security_group.ecs.id] + assign_public_ip = local.assign_public_ip + } + + load_balancer { + target_group_arn = aws_lb_target_group.frontend.arn + container_name = "frontend" + container_port = 80 + } + + depends_on = [ + aws_lb_listener.http + ] + + tags = local.tags +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 395742e..76250cd 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -13,6 +13,16 @@ output "ecs_cluster_name" { value = aws_ecs_cluster.app.name } +output "ecr_backend_url" { + description = "ECR repository URL for the backend image." + value = aws_ecr_repository.backend.repository_url +} + +output "ecr_frontend_url" { + description = "ECR repository URL for the frontend image." + value = aws_ecr_repository.frontend.repository_url +} + output "prometheus_workspace_id" { description = "Amazon Managed Prometheus workspace ID." value = aws_prometheus_workspace.main.id diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index a3dac06..e0725f4 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -91,3 +91,15 @@ variable "alarm_sns_topic_arn" { description = "SNS topic ARN for alarm notifications. Leave empty to disable actions." default = "" } + +variable "frontend_container_image" { + type = string + description = "Container image for the frontend service." + default = "" +} + +variable "frontend_desired_count" { + type = number + description = "Desired number of frontend ECS tasks." + default = 1 +} From 45a827c1d24bb641247fd6c477762807f3fd7339 Mon Sep 17 00:00:00 2001 From: DuesselbergAdrian Date: Wed, 25 Feb 2026 21:51:38 +0100 Subject: [PATCH 05/20] fix: docker build path, frontend redesign, state flow fixes --- backend/Dockerfile | 3 +- frontend/src/components/CVUploader.tsx | 114 +++-- frontend/src/components/DiffViewer.tsx | 123 +++-- frontend/src/components/DownloadButton.tsx | 31 +- frontend/src/components/JobDescInput.tsx | 47 +- frontend/src/components/ProgressIndicator.tsx | 86 +++- frontend/src/components/ResultsViewer.tsx | 153 +++--- frontend/src/pages/Home.tsx | 475 ++++++++++++++---- manifest.json | 31 ++ secrets-policy.json | 14 + 10 files changed, 782 insertions(+), 295 deletions(-) create mode 100644 manifest.json create mode 100644 secrets-policy.json diff --git a/backend/Dockerfile b/backend/Dockerfile index a64551d..d4f80fa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,8 +8,7 @@ ENV PYTHONUNBUFFERED=1 COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt -COPY __init__.py /app/backend/__init__.py -COPY app /app/backend/app +COPY . /app/backend EXPOSE 8000 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..dcb9ed3 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -8,16 +8,33 @@ export default function DownloadButton({ applicationId, apiBaseUrl }: Props) { const clUrl = `${apiBaseUrl}/applications/${applicationId}/download?type=cover_letter`; const zipUrl = `${apiBaseUrl}/applications/${applicationId}/download?type=zip`; + const btnBase: React.CSSProperties = { + display: "inline-flex", alignItems: "center", gap: 8, + padding: "10px 18px", borderRadius: 10, + fontSize: 13, fontWeight: 500, textDecoration: "none", + fontFamily: "var(--font-body)", transition: "all 0.2s", + cursor: "pointer", + }; + return ( -
- - Download enhanced CV + ); 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 (
-