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
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,56 @@ Squad Five capstone for the Andela AI Engineering Bootcamp. The goal is straight
Product direction (for context while you build):

- Ingest a resume plus a job posting URL.
- Diff the candidates story against the role (ATS-oriented gap analysis).
- Generate refreshed resume copy, a cover letter with narrative structure, and a Gmail-ready draft.
- Diff the candidate's story against the role (ATS-oriented gap analysis).
- Generate refreshed resume copy, a cover letter with narrative structure, and a Gmail-ready draft.

Implementation details for LangGraph, Gmail, and persistence are intentionally left open so feature teams can own them.
## Implementation Status

The backend is now fully implemented with a LangGraph-based workflow for generating job application materials.

### API Endpoints

| Endpoint | Method | Description |
| --- | --- | --- |
| `/api/v1/apply` | POST | Run the complete TalentStreamAI workflow |
| `/api/v1/fetch-job` | POST | Fetch and parse a job description from URL |
| `/api/v1/parse-resume` | POST | Parse a resume file (PDF or DOCX) |
| `/api/v1/score-ats` | POST | Score resume against job description for ATS compatibility |

#### Apply Endpoint (Main)

The main `/api/v1/apply` endpoint accepts:
- `job_url` (str): URL of the job posting
- `resume` (UploadFile): Resume file (PDF or DOCX)

Returns job data, resume data, ATS score, gap analysis, tailored resume, cover letter, and email draft.

### LangGraph Workflow

The workflow consists of these nodes executed in sequence:

1. **fetch_job**: Fetches job description from URL using web scraping
2. **parse_resume**: Parses PDF/DOCX resume into structured data
3. **score_ats**: Scores resume against job requirements
4. **analyze_gaps**: Identifies keyword and skill gaps
5. **generate_resume**: Generates ATS-optimized tailored resume (LLM)
6. **generate_cover_letter**: Generates narrative cover letter (LLM)
7. **generate_email**: Generates Gmail-ready email draft (LLM)

### Tools

Located in `backend/app/tools/`:
- **job_fetcher.py**: Fetches and parses job descriptions from URLs
- **resume_parser.py**: Parses PDF and DOCX resumes
- **ats_scorer.py**: Scores resumes against job requirements for ATS compatibility
- **models.py**: Pydantic models for all tool inputs and outputs

### Environment Variables

Required in `.env`:
| Key | Description |
| --- | --- |
| `OPENAI_API_KEY` | OpenAI API key for LLM calls (GPT-4o) |

## Prerequisites

Expand Down Expand Up @@ -56,6 +101,7 @@ Both FastAPI (`pydantic-settings`) and Next.js (via `dotenv-cli` in `frontend/pa
| `CORS_ORIGINS` | API | Comma-separated browser origins allowed to call the API. |
| `NEXT_PUBLIC_API_URL` | UI (build + browser) | Public API base URL the browser calls. Leave empty for production builds that should call same-origin `/api/*` through CloudFront. |
| `DEPLOYMENT_ENVIRONMENT` | API (`/api/v1/health` metadata) | Optional label such as `local`, `dev`, `staging`, or `prod`. |
| `OPENAI_API_KEY` | API | OpenAI API key for LLM calls (GPT-4o). Required for `/api/v1/apply` endpoint. |

## Run the full stack in Docker

Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import APIRouter

from app.api.v1 import health
from app.api.v1 import endpoints

api_router = APIRouter()
api_router.include_router(health.router, prefix="/v1", tags=["health"])
api_router.include_router(endpoints.router, prefix="/v1", tags=["talentstream"])
162 changes: 162 additions & 0 deletions backend/app/api/v1/endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""API v1 endpoints for TalentStreamAI."""

from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

from app.services.langgraph import run_talentstream_workflow
from app.tools.job_fetcher import fetch_job_description
from app.tools.resume_parser import parse_resume
from app.tools.ats_scorer import ats_score_resume

router = APIRouter()


def validate_resume_file(resume: UploadFile) -> str:
"""Validate resume file format and return the extension."""
ext = resume.filename.rsplit(".", 1)[-1].lower() if resume.filename else ""
if not ext or ext not in ["pdf", "docx", "doc"]:
raise HTTPException(
status_code=400,
detail="Unsupported file format. Use PDF or DOCX.",
)
return ext


class ApplyRequest(BaseModel):
job_url: str = Field(..., description="URL of the job posting")


class ApplyResponse(BaseModel):
status: str
job_data: dict | None = None
resume_data: dict | None = None
ats_score: dict | None = None
gap_analysis: dict | None = None
tailored_resume: str | None = None
cover_letter: str | None = None
email_draft: str | None = None


class FetchJobResponse(BaseModel):
status: str
job_data: dict


class ParseResumeResponse(BaseModel):
status: str
resume_data: dict


class ScoreATSRequest(BaseModel):
job_url: str = Field(..., description="URL of the job posting")


class ScoreATSResponse(BaseModel):
status: str
ats_score: dict


@router.post("/apply", response_model=ApplyResponse)
async def apply_to_job(
job_url: str = Form(..., description="URL of the job posting"),
resume: UploadFile = File(..., description="Resume file (PDF or DOCX)"),
) -> ApplyResponse:
"""Run complete TalentStreamAI workflow to generate application materials."""
import base64

ext = validate_resume_file(resume)

file_content = await resume.read()
file_b64 = base64.b64encode(file_content).decode("utf-8")

try:
result = await run_talentstream_workflow(
job_url=job_url,
resume_file=file_b64,
resume_ext=ext,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Workflow failed: {str(e)}")

if result.get("error"):
raise HTTPException(status_code=500, detail=result["error"])

return ApplyResponse(
status="success",
job_data=result.get("job_data"),
resume_data=result.get("resume_data"),
ats_score=result.get("ats_score"),
gap_analysis=result.get("gap_analysis"),
tailored_resume=result.get("tailored_resume"),
cover_letter=result.get("cover_letter"),
email_draft=result.get("email_draft"),
)


@router.post("/fetch-job", response_model=FetchJobResponse)
async def fetch_job(job_url: str = Form(...)) -> FetchJobResponse:
"""Fetch and parse a job description from URL."""
try:
result = fetch_job_description.invoke({"url": job_url})
return FetchJobResponse(status="success", job_data=result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch job: {str(e)}")


@router.post("/parse-resume", response_model=ParseResumeResponse)
async def parse_resume_endpoint(
resume: UploadFile = File(...),
) -> ParseResumeResponse:
"""Parse a resume file (PDF or DOCX)."""
import base64

ext = validate_resume_file(resume)

file_content = await resume.read()
file_b64 = base64.b64encode(file_content).decode("utf-8")

try:
result = parse_resume.invoke(
{
"file_content": file_b64,
"file_extension": ext,
}
)
return ParseResumeResponse(status="success", resume_data=result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to parse resume: {str(e)}")


@router.post("/score-ats", response_model=ScoreATSResponse)
async def score_ats(
job_url: str = Form(...),
resume: UploadFile = File(...),
) -> ScoreATSResponse:
"""Score resume against job description for ATS compatibility."""
import base64

ext = validate_resume_file(resume)

Comment thread
karosi12 marked this conversation as resolved.
file_content = await resume.read()
file_b64 = base64.b64encode(file_content).decode("utf-8")

try:
job_data = fetch_job_description.invoke({"url": job_url})
resume_data = parse_resume.invoke(
{
"file_content": file_b64,
"file_extension": ext,
}
)
score = ats_score_resume.invoke(
{
"resume_data": resume_data,
"job_data": job_data,
}
)
return ScoreATSResponse(status="success", ats_score=score)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to score: {str(e)}")
5 changes: 4 additions & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ class Settings(BaseSettings):
api_port: int = 8000
cors_origins: str = "http://localhost:3000"
deployment_environment: str | None = None
openai_api_key: str | None = None

@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
return [
origin.strip() for origin in self.cors_origins.split(",") if origin.strip()
]


settings = Settings()
5 changes: 5 additions & 0 deletions backend/app/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""MCP server module for TalentStreamAI."""

from app.mcp.server import mcp_server

__all__ = ["mcp_server"]
Loading
Loading