Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 18 additions & 30 deletions backend/app/api/routes/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,37 +68,23 @@ async def parse_application_cv(file: UploadFile = File(...)):
"raw_text_preview": parsed.raw_text[:1000], # preview only; avoids huge responses
}

from backend.app.models.schemas import ApplicationGenerateRequest
@router.post("/generate")
async def generate_application(
request: Request,
file: UploadFile = File(...),
job_description: str = Form(..., min_length=20),
tone: str = Form("professional"),
body: ApplicationGenerateRequest,
llm: LLMClient = Depends(get_llm_client),
):
filename = (file.filename or "").lower()

if not (filename.endswith(".pdf") or filename.endswith(".docx")):
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Only .pdf and .docx files are supported.",
)
cv_text = body.cv_text
job_description = body.job_description
tone = body.tone or "professional"
filename = "cv_text.txt"

content = await file.read()
if len(content) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large. Max size is 5MB.",
)

# 1) Parse CV
try:
parsed = parse_cv(content, filename=filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Build "sections" input expected by extractor
sections = {"raw": cv_text}

# cache keys
cv_hash = _sha256(content)
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}"
Expand All @@ -107,7 +93,7 @@ async def generate_application(
cache = get_cache()
facts = cache.get_json(facts_cache_key)
if facts is None:
facts = await llm.extract_facts(parsed.sections)
facts = await llm.extract_facts(sections)
cache.set_json(facts_cache_key, facts, ttl_seconds=settings.cache_ttl_seconds)

# 3) jd analysis (cached)
Expand All @@ -116,6 +102,7 @@ async def generate_application(
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)
Expand Down Expand Up @@ -148,8 +135,7 @@ async def generate_application(
t0 = time.perf_counter()
try:
enhancer = CVEnhancer(llm_service=get_llm_service())
# Use full raw text for "before" snippets to match exactly
original_cv_text = parsed.raw_text
original_cv_text = cv_text
cv_patches = enhancer.enhance(
original_cv_text=original_cv_text,
fact_table=facts,
Expand All @@ -172,33 +158,35 @@ async def generate_application(

_log_event({"event": "stage_complete", "stage": "cv_enhance", "request_id": request_id, "ms": round((time.perf_counter()-t0)*1000, 2)})


# 7) store results
t0 = time.perf_counter()
result_blob = {
"request_id": request_id,
"filename": file.filename,
"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": parsed.raw_text,
"cv_raw_text": cv_text,
}


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)})

# Keep response small; fetch full payload via /applications/{id}/results
return {
"request_id": request_id,
"filename": file.filename,
"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):
cache = get_cache()
Expand Down
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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 = "http://localhost:8000"
llm_base_url: str = "https://api.openai.com/v1"

# LLM Configuration
# NOTE: keep it optional for import-time, enforce at call-time.
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ 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)
tone: Optional[str] = Field(default="professional")
# later: cv_file upload handled via multipart/form-data


class ApplicationGenerateResponse(BaseModel):
Expand Down
143 changes: 104 additions & 39 deletions backend/app/services/llm_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from typing import Any, Dict
import httpx
import json
from google import genai
from google.genai import types
from backend.app.config import get_settings


# Note: The endpoints /fact-extract, /jd-analyze, /cover-letter are placeholders. When Person A finishes their FastAPI routes, you align names.

Expand All @@ -10,42 +15,102 @@ class LLMClient:
"""

def __init__(self, base_url: str | None):
self.base_url = base_url.rstrip("/") if base_url else None

async def extract_facts(self, cv_sections: Dict[str, str]) -> Dict[str, Any]:
if not self.base_url:
# stub: return minimal structure
return {"experiences": [], "education": [], "skills": [], "raw_sections": cv_sections}

async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(f"{self.base_url}/fact-extract", json={"sections": cv_sections})
r.raise_for_status()
return r.json()

async def analyze_jd(self, job_description: str) -> Dict[str, Any]:
if not self.base_url:
return {"requirements": [], "keywords": [], "raw": job_description}

async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(f"{self.base_url}/jd-analyze", json={"job_description": job_description})
r.raise_for_status()
return r.json()

async def generate_cover_letter(self, facts: Dict[str, Any], jd: Dict[str, Any], tone: str) -> Dict[str, Any]:
if not self.base_url:
return {
"cover_letter": "STUB COVER LETTER\n\n(LLM_BASE_URL not configured)",
"tone": tone,
}

async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
f"{self.base_url}/cover-letter",
json={"facts": facts, "job": jd, "tone": tone},
)
r.raise_for_status()
return r.json()

def get_llm_client() -> "LLMClient":
# Single place that decides how the client is constructed in production
return LLMClient()
self.base_url = base_url
settings = get_settings()
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.model = settings.gemini_model # e.g. "gemini-2.5-flash"

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(
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": {
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"category": {"type": "string"},
"fact": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["category", "fact", "confidence"],
},
}
},
"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)

async def analyze_jd(self, job_description: str) -> dict:
schema = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"required_skills": {"type": "array", "items": {"type": "string"}},
"experience_level": {"type": "string"},
"remote_policy": {"type": "string"},
},
"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)


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."
user = (
f"TONE: {tone}\n\n"
f"FACTS (JSON):\n{json.dumps(facts, ensure_ascii=False)}\n\n"
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(
model=self.model,
contents=user,
config=types.GenerateContentConfig(
system_instruction=system,
temperature=0.4,
),
)
return {"cover_letter": resp.text}


from backend.app.config import get_settings

def get_llm_client():
settings = get_settings()
return LLMClient(base_url=settings.llm_base_url)

1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ langchain-openai>=0.2.0
langchain-google-genai>=2.0.0
langsmith>=0.1.0
reportlab
google-genai

10 changes: 4 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Home } from "./pages/Home";

export default function App() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Frontend is working ✅</h1>
<p className="mt-2 text-gray-600">If you see this, React rendering is fixed.</p>
</div>
);
return <Home />;
}

87 changes: 87 additions & 0 deletions frontend/src/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useMemo, useState } from "react";
import type { CvSuggestion } from "../types/application";

type Decision = "accepted" | "rejected";

type Props = {
suggestions: CvSuggestion[];
onChange?: (decisions: Record<string, Decision>) => void;
};

export default function DiffViewer({ suggestions, onChange }: Props) {
const [decisions, setDecisions] = useState<Record<string, Decision>>({});

const acceptanceRate = useMemo(() => {
const total = suggestions.length;
if (total === 0) return 0;
const accepted = Object.values(decisions).filter((d) => d === "accepted").length;
return accepted / total;
}, [decisions, suggestions.length]);

function setDecision(id: string, d: Decision) {
setDecisions((prev) => {
const next = { ...prev, [id]: d };
onChange?.(next);
return next;
});
}

return (
<div className="rounded-2xl border p-4 shadow-sm">
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">CV Suggestions</h2>
<div className="text-sm opacity-80">
Acceptance rate: {Math.round(acceptanceRate * 100)}%
</div>
</div>

<div className="mt-4 grid gap-3">
{suggestions.length === 0 ? (
<p className="text-sm opacity-70">No suggestions available.</p>
) : (
suggestions.map((s) => (
<div key={s.id} className="rounded-xl border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{s.section}</div>

<div className="flex gap-2">
<button
className="rounded-lg border px-3 py-1 text-sm hover:opacity-80"
onClick={() => setDecision(s.id, "accepted")}
>
Accept
</button>
<button
className="rounded-lg border px-3 py-1 text-sm hover:opacity-80"
onClick={() => setDecision(s.id, "rejected")}
>
Reject
</button>
</div>
</div>

<div className="mt-3 grid gap-2 md:grid-cols-2">
<div>
<div className="text-xs font-semibold opacity-70">Before</div>
<div className="mt-1 whitespace-pre-wrap rounded-lg border p-2 text-sm">
{s.before}
</div>
</div>
<div>
<div className="text-xs font-semibold opacity-70">After</div>
<div className="mt-1 whitespace-pre-wrap rounded-lg border p-2 text-sm">
{s.after}
</div>
</div>
</div>

{decisions[s.id] ? (
<div className="mt-2 text-xs opacity-70">Decision: {decisions[s.id]}</div>
) : null}
</div>
))
)}
</div>
</div>
);
}
Loading
Loading