Skip to content

Commit 70263b4

Browse files
committed
Final Commit - App Deploy
1 parent c5ee804 commit 70263b4

13 files changed

Lines changed: 74 additions & 160 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# AI
22
GROQ_API_KEY=
33

4+
# Supabase
5+
SUPABASE_URL=
6+
SUPABASE_KEY=
7+
48
# AWS
59
AWS_REGION=eu-west-1
610
S3_OUTPUT_BUCKET=

backend/main.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414

1515
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
1616

17+
from supabase import create_client, Client as SupabaseClient
1718
from services.parser import parse_pdf
1819
from services.extractor import extract_profile
1920
from services.generator import generate_cv
2021
from services.tailor import tailor_cv
2122
from services.keywords import extract_keywords
23+
from services.utils import detect_lang
2224
from models.schema import CVProfile
2325

2426
# ---------------------------------------------------------------------------
@@ -39,11 +41,26 @@
3941
MAX_RAW_TEXT_CHARS = 15_000
4042

4143
ALLOWED_ORIGINS = [
42-
"https://readytoapply.skander.cc",
44+
"https://readytoapply.work",
45+
"https://www.readytoapply.work",
4346
"https://readytoapply-frontend.vercel.app",
4447
"http://localhost:3000",
4548
]
4649

50+
# ---------------------------------------------------------------------------
51+
# Supabase client
52+
# ---------------------------------------------------------------------------
53+
_supabase: SupabaseClient | None = None
54+
55+
def _get_supabase() -> SupabaseClient | None:
56+
global _supabase
57+
if _supabase is None:
58+
url = os.environ.get("SUPABASE_URL")
59+
key = os.environ.get("SUPABASE_KEY")
60+
if url and key:
61+
_supabase = create_client(url, key)
62+
return _supabase
63+
4764
# ---------------------------------------------------------------------------
4865
# Rate limiter — per IP
4966
# /extract is expensive (3 LLM calls), cap tightly
@@ -116,25 +133,22 @@ def health():
116133

117134
@app.post("/review")
118135
async def submit_review(request: Request, payload: dict):
119-
"""Store anonymous review (stars + comment) to a local JSON file."""
136+
"""Store anonymous review (stars + comment) to Supabase."""
120137
stars = payload.get("stars")
121138
comment = str(payload.get("comment", "")).strip()[:500]
122139
if not isinstance(stars, int) or not (1 <= stars <= 5):
123140
raise HTTPException(status_code=422, detail="stars must be 1–5")
124141

125-
import datetime
126-
entry = {
127-
"stars": stars,
128-
"comment": comment,
129-
"ts": datetime.datetime.utcnow().isoformat(),
130-
}
131-
reviews_path = Path(__file__).parent / "reviews.json"
142+
db = _get_supabase()
143+
if db is None:
144+
logger.warning("Supabase not configured — review dropped")
145+
return {"ok": True}
146+
132147
try:
133-
reviews = json.loads(reviews_path.read_text()) if reviews_path.exists() else []
134-
reviews.append(entry)
135-
reviews_path.write_text(json.dumps(reviews, ensure_ascii=False, indent=2))
148+
db.table("reviews").insert({"stars": stars, "comment": comment}).execute()
136149
except Exception as e:
137150
logger.error("Failed to save review: %s", e)
151+
138152
return {"ok": True}
139153

140154

@@ -153,11 +167,7 @@ async def extract(
153167
raw_text = parse_pdf(file_bytes)
154168
raw_text = raw_text[:MAX_RAW_TEXT_CHARS]
155169

156-
# Detect job description language before generating
157-
fr_words = ["le ", "la ", "les ", "de ", "du ", "des ", "et ", "en ", "pour ",
158-
"avec ", "dans ", "sur ", "une ", "est ", "sont ", "par ", "au "]
159-
jd_lower = job_description.lower()
160-
target_lang = "fr" if sum(1 for w in fr_words if w in jd_lower) >= 4 else "en"
170+
target_lang = detect_lang(job_description)
161171

162172
profile = extract_profile(raw_text)
163173
generated = generate_cv(profile, target_lang=target_lang)

backend/requirements.txt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
fastapi
2-
uvicorn
3-
pdfplumber
4-
python-docx
5-
groq
6-
pydantic
7-
python-multipart
8-
python-dotenv
9-
slowapi
1+
fastapi==0.135.2
2+
uvicorn==0.42.0
3+
pdfplumber==0.11.9
4+
python-docx==1.2.0
5+
groq==1.1.2
6+
pydantic==2.12.5
7+
python-multipart==0.0.22
8+
python-dotenv==1.2.2
9+
slowapi==0.1.9
10+
supabase==2.15.0

backend/services/extractor.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import os
22
from groq import Groq
33
from services.validator import validate_cv
4+
from services.utils import load_prompt
45
from models.schema import CVProfile
56

67
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
78

8-
def load_prompt(filename: str) -> str:
9-
prompt_path = os.path.join(os.path.dirname(__file__), "..", "prompts", filename)
10-
with open(prompt_path, "r", encoding="utf-8") as f:
11-
return f.read()
12-
139
def extract_profile(raw_text: str, max_retries: int = 3) -> CVProfile:
1410
"""
1511
Takes raw CV text, returns a validated CVProfile object.

backend/services/generator.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@
22
import json
33
from groq import Groq
44
from services.validator import validate_cv
5+
from services.utils import load_prompt
56
from models.schema import CVProfile
67

78
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
89

9-
def load_prompt(filename: str) -> str:
10-
prompt_path = os.path.join(os.path.dirname(__file__), "..", "prompts", filename)
11-
with open(prompt_path, "r", encoding="utf-8") as f:
12-
return f.read()
13-
1410
def generate_cv(profile: CVProfile, target_lang: str = "en", max_retries: int = 3) -> CVProfile:
1511
"""
1612
Takes a CVProfile, rewrites it to be strong and clean.

backend/services/tailor.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@
22
import json
33
from groq import Groq
44
from services.validator import validate_cv
5+
from services.utils import load_prompt
56
from models.schema import CVProfile
67

78
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
89

9-
def load_prompt(filename: str) -> str:
10-
prompt_path = os.path.join(os.path.dirname(__file__), "..", "prompts", filename)
11-
with open(prompt_path, "r", encoding="utf-8") as f:
12-
return f.read()
13-
1410
def tailor_cv(profile: CVProfile, job_description: str, keywords: dict = None, max_retries: int = 3) -> CVProfile:
1511
"""
1612
Takes a CVProfile, job description, and optional extracted keywords dict.

backend/services/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import os
2+
3+
_FR_WORDS = ["le ", "la ", "les ", "de ", "du ", "des ", "et ", "en ", "pour ",
4+
"avec ", "dans ", "sur ", "une ", "est ", "sont ", "par ", "au "]
5+
6+
7+
def load_prompt(filename: str) -> str:
8+
"""Load a prompt file from the prompts directory."""
9+
prompt_path = os.path.join(os.path.dirname(__file__), "..", "prompts", filename)
10+
with open(prompt_path, "r", encoding="utf-8") as f:
11+
return f.read()
12+
13+
14+
def detect_lang(text: str) -> str:
15+
"""Return 'fr' if text looks French, else 'en'."""
16+
lower = text.lower()
17+
hits = sum(1 for w in _FR_WORDS if w in lower)
18+
return "fr" if hits >= 4 else "en"

frontend/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export default function LandingPage() {
110110
<span className="text-xs text-[#9A9A9A]">{u("noDataStored")}</span>
111111
<span className="text-xs text-[#444444]">
112112
{u("madeBy")}{" "}
113-
<a href="https://github.com/Skanderba8" target="_blank" rel="noopener noreferrer"
113+
<a href="https://github.com/Skanderba8/ReadyToApply" target="_blank" rel="noopener noreferrer"
114114
className="text-[#555555] hover:text-[#FF4D00] transition-colors">
115115
Skander Ben Abdallah
116116
</a>

frontend/components/StepDownload.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export default function StepDownload({ cvData, jobDescription, template, keyword
166166
<button onClick={onRestart}
167167
className="px-6 py-3 text-sm text-[#9A9A9A] hover:text-[#F5F0EB] transition-colors border border-[#2E2E2E] hover:border-[#9A9A9A]"
168168
style={{ fontFamily: "var(--font-body)" }}>
169-
{lang === "fr" ? "Recommencer" : "Start over"}
169+
{u("startOver")}
170170
</button>
171171
</div>
172172
<ReviewWidget lang={lang} />

frontend/components/StepReview.tsx

Lines changed: 8 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"use client";
1+
"use client";
22

33
import { useEffect, useState, useCallback } from "react";
44
import {
@@ -11,7 +11,7 @@ import {
1111
import { CSS } from "@dnd-kit/utilities";
1212
import { RefreshCw, AlertCircle, Plus, X, ChevronDown, ChevronUp, GripVertical } from "lucide-react";
1313
import { extractCV, CVData, Keywords } from "@/lib/api";
14-
import { useLang, detectJobLang } from "@/lib/i18n";
14+
import { useLang } from "@/lib/i18n";
1515

1616
// ─── Types ───────────────────────────────────────────────────────────────────
1717

@@ -39,63 +39,6 @@ const LANGUAGES_FR = [
3939
"Danois", "Norvégien", "Finnois", "Coréen", "Hindi", "Autre",
4040
];
4141

42-
// ─── i18n labels ─────────────────────────────────────────────────────────────
43-
44-
function t(key: string, lang: "en" | "fr"): string {
45-
const labels: Record<string, Record<string, string>> = {
46-
reviewing: { en: "Review your CV", fr: "Vérifiez votre CV" },
47-
reviewSub: { en: "Check everything looks right. Edit any field before generating.", fr: "Vérifiez que tout est correct. Modifiez avant de générer." },
48-
basics: { en: "Basics", fr: "Informations générales" },
49-
contact: { en: "Contact", fr: "Contact" },
50-
experience: { en: "Experience", fr: "Expérience" },
51-
education: { en: "Education", fr: "Formation" },
52-
skills: { en: "Skills", fr: "Compétences" },
53-
certifications: { en: "Certifications", fr: "Certifications" },
54-
languages: { en: "Languages", fr: "Langues" },
55-
projects: { en: "Projects", fr: "Projets" },
56-
keywords: { en: "Job Keywords", fr: "Mots-clés du poste" },
57-
fullName: { en: "Full name", fr: "Nom complet" },
58-
profTitle: { en: "Professional title", fr: "Titre professionnel" },
59-
summary: { en: "Summary", fr: "Résumé" },
60-
email: { en: "Email", fr: "Email" },
61-
phone: { en: "Phone", fr: "Téléphone" },
62-
location: { en: "Location", fr: "Localisation" },
63-
linkedin: { en: "LinkedIn URL", fr: "URL LinkedIn" },
64-
company: { en: "Company", fr: "Entreprise" },
65-
title: { en: "Title", fr: "Titre" },
66-
start: { en: "Start", fr: "Début" },
67-
end: { en: "End", fr: "Fin" },
68-
bullets: { en: "Bullets", fr: "Points" },
69-
addBullet: { en: "Add bullet", fr: "Ajouter un point" },
70-
addExperience: { en: "Add experience", fr: "Ajouter une expérience" },
71-
addEducation: { en: "Add formation", fr: "Ajouter une formation" },
72-
addCert: { en: "Add certification", fr: "Ajouter une certification" },
73-
addLanguage: { en: "Add language", fr: "Ajouter une langue" },
74-
addProject: { en: "Add project", fr: "Ajouter un projet" },
75-
addSkill: { en: "Add skill", fr: "Ajouter une compétence" },
76-
remove: { en: "Remove", fr: "Supprimer" },
77-
institution: { en: "Institution", fr: "Établissement" },
78-
degree: { en: "Degree", fr: "Diplôme" },
79-
field: { en: "Field of study", fr: "Domaine d'études" },
80-
year: { en: "Year", fr: "Année" },
81-
issuer: { en: "Issuer", fr: "Organisme" },
82-
language: { en: "Language", fr: "Langue" },
83-
level: { en: "Level", fr: "Niveau" },
84-
projName: { en: "Project name", fr: "Nom du projet" },
85-
projDesc: { en: "Description", fr: "Description" },
86-
projUrl: { en: "URL", fr: "URL" },
87-
looksGood: { en: "Looks good — choose template", fr: "C'est bon — choisir le modèle" },
88-
back: { en: "← Back", fr: "← Retour" },
89-
keywordsSub: { en: "Keywords extracted from the job description. The AI uses these when tailoring.", fr: "Mots-clés extraits de l'offre d'emploi. L'IA les utilise pour adapter votre CV." },
90-
technical: { en: "Technical", fr: "Technique" },
91-
soft: { en: "Soft skills", fr: "Savoir-être" },
92-
industry: { en: "Industry", fr: "Secteur" },
93-
add: { en: "Add", fr: "Ajouter" },
94-
dragHint: { en: "Drag sections to reorder", fr: "Glissez les sections pour les réorganiser" },
95-
enableSection: { en: "Enable section", fr: "Activer la section" },
96-
};
97-
return labels[key]?.[lang] ?? labels[key]?.["en"] ?? key;
98-
}
9942

10043
// ─── Small UI components ──────────────────────────────────────────────────────
10144

@@ -206,16 +149,6 @@ function SortableSection({ id, title, children, defaultOpen = false, rightSlot }
206149
);
207150
}
208151

209-
// ─── Language detection ───────────────────────────────────────────────────────
210-
211-
function detectLang(jobDescription: string): "en" | "fr" {
212-
const frWords = ["le ", "la ", "les ", "de ", "du ", "des ", "et ", "en ", "pour ", "avec ",
213-
"nous ", "vous ", "dans ", "sur ", "une ", "un ", "est ", "sont ", "être ", "avoir ",
214-
"poste ", "emploi ", "entreprise ", "équipe ", "expérience ", "compétences "];
215-
const text = jobDescription.toLowerCase();
216-
const frCount = frWords.filter(w => text.includes(w)).length;
217-
return frCount >= 4 ? "fr" : "en";
218-
}
219152

220153
// ─── Props ────────────────────────────────────────────────────────────────────
221154

@@ -340,15 +273,15 @@ export default function StepReview({ file, pastedText, jobDescription, initialCv
340273
if (loadState === "loading") return (
341274
<div className="animate-fade-up">
342275
<h2 className="text-3xl md:text-4xl font-bold mb-2" style={{ fontFamily: "var(--font-display)" }}>
343-
{lang === "fr" ? "Analyse de votre profil…" : "Analysing your profile…"}
276+
{u("analysing")}
344277
</h2>
345278
<p className="text-[#9A9A9A] mb-10 text-sm">
346-
{lang === "fr" ? "Extraction de votre expérience. Environ 15 secondes." : "Extracting your experience and matching it to the job. Takes about 15 seconds."}
279+
{u("analysingTime")}
347280
</p>
348281
<div className="flex items-center gap-4 px-8 py-4 border border-[#2E2E2E]">
349282
<RefreshCw size={16} className="animate-spin" style={{ color: "#FF4D00" }} />
350283
<span className="text-sm text-[#F5F0EB] animate-pulse">
351-
{lang === "fr" ? "Lecture de votre CV et de l'offre d'emploi…" : "Reading your CV and job description…"}
284+
{u("analysingMsg")}
352285
</span>
353286
</div>
354287
</div>
@@ -362,7 +295,7 @@ export default function StepReview({ file, pastedText, jobDescription, initialCv
362295
<div className="flex items-start gap-3 p-4 border border-red-900 bg-red-950/30">
363296
<AlertCircle size={18} className="text-red-400 mt-0.5 shrink-0" />
364297
<div>
365-
<p className="text-sm font-medium text-red-400 mb-1">{lang === "fr" ? "Échec de l'extraction" : "Extraction failed"}</p>
298+
<p className="text-sm font-medium text-red-400 mb-1">{u("extractFailed")}</p>
366299
<p className="text-xs text-red-400/70">{errorMessage}</p>
367300
</div>
368301
</div>
@@ -553,7 +486,7 @@ export default function StepReview({ file, pastedText, jobDescription, initialCv
553486
<p className="text-xs text-[#9A9A9A]">{u("keywordsSub")}</p>
554487
{(["technical", "soft", "industry"] as const).map(bucket => (
555488
<div key={bucket}>
556-
<p className="text-xs font-semibold uppercase tracking-widest text-[#9A9A9A] mb-2">{t(bucket, lang)}</p>
489+
<p className="text-xs font-semibold uppercase tracking-widest text-[#9A9A9A] mb-2">{u(bucket)}</p>
557490
<div className="flex flex-wrap gap-2">
558491
{keywords[bucket].map((k, i) => <Pill key={i} label={k} onRemove={() => removeKw(bucket, i)} />)}
559492
<AddPill placeholder={u("add")} onAdd={v => addKw(bucket, v)} />
@@ -612,7 +545,7 @@ export default function StepReview({ file, pastedText, jobDescription, initialCv
612545
{isEnabled
613546
? sectionContent[section.id]
614547
: <p className="text-xs text-[#9A9A9A] italic">
615-
{lang === "fr" ? "Section désactivée. Activez-la pour l'inclure dans votre CV." : "Section disabled. Toggle on to include in your CV."}
548+
{u("sectionDisabled")}
616549
</p>
617550
}
618551
</SortableSection>

0 commit comments

Comments
 (0)