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
46 changes: 46 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,49 @@ NEXT_PUBLIC_API_URL=http://localhost:8000
# --- Runtime metadata (optional; never hardcode per-environment values in source) ---
# Examples: dev | staging | prod | local
# DEPLOYMENT_ENVIRONMENT=local

# --- Auth (Clerk) ---
# For local dev you can leave AUTH_MODE=disabled.
# In deployed envs, set AUTH_MODE=clerk_jwks and provide the JWKS URL + issuer.
AUTH_MODE=disabled
# Example: https://<your-clerk-domain>/.well-known/jwks.json
# CLERK_JWKS_URL=
# Example: https://<your-clerk-domain>
# CLERK_ISSUER=
# Optional audience validation (depends on your Clerk configuration)
# CLERK_AUDIENCE=

# --- Guardrails / limits ---
# MAX_UPLOAD_BYTES=5242880
# MAX_TEXT_CHARS=80000
# MAX_OUTPUT_CHARS=120000

# --- Persistence (SQLite + local uploads; production should move uploads to S3 and DB to Aurora) ---
# SQLITE_PATH=.data/talentstreamai.sqlite3
# UPLOAD_DIR=.data/uploads
# SQLITE_BUSY_TIMEOUT_MS=5000
# SQLITE_ENABLE_WAL=true

# --- Upload storage ---
# UPLOAD_STORAGE=none|local|s3
# UPLOAD_STORAGE=none
# For S3:
# S3_BUCKET=your-private-bucket
# S3_PREFIX=uploads/
# S3_SSE=AES256
# S3_KMS_KEY_ID= # optional if S3_SSE=aws:kms

# --- Agent / LLM ---
# Local: AGENT_MODE=stub (no external calls). Deployed: AGENT_MODE=llm.
AGENT_MODE=stub
# OpenAI-compatible base URL. For OpenRouter, you might use https://openrouter.ai/api
LLM_BASE_URL=https://api.openai.com
# LLM_API_KEY=... # preferred
# OPENROUTER_API_KEY=... # accepted alias
# LLM_MODEL=...
# LLM_TIMEOUT_SECONDS=45
# LLM_MAX_TOKENS=1800
# LLM_TEMPERATURE=0.2
# OpenRouter optional headers (recommended)
# OPENROUTER_REFERER=https://your-app.example
# OPENROUTER_TITLE=TalentStreamAI
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ terraform.rc
coverage/
htmlcov/
.coverage

# Backend local data (sqlite, uploads)
backend/.data/
8 changes: 5 additions & 3 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from fastapi import APIRouter

from app.api.v1 import health
from app.api.v1 import endpoints
from app.api.v1 import auth, generation, health, job_descriptions, resumes

api_router = APIRouter()
api_router.include_router(health.router, prefix="/v1", tags=["health"])
api_router.include_router(endpoints.router, prefix="/v1", tags=["talentstream"])
api_router.include_router(auth.router, prefix="/v1/auth", tags=["auth"])
api_router.include_router(resumes.router, prefix="/v1", tags=["resumes"])
api_router.include_router(job_descriptions.router, prefix="/v1", tags=["job_descriptions"])
api_router.include_router(generation.router, prefix="/v1", tags=["generation"])
10 changes: 10 additions & 0 deletions backend/app/api/v1/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import APIRouter, Depends

from app.core.auth import AuthenticatedUser, get_current_user

router = APIRouter()


@router.get("/me")
def me(user: AuthenticatedUser = Depends(get_current_user)) -> dict[str, str]:
return {"user_id": user.user_id}
75 changes: 75 additions & 0 deletions backend/app/api/v1/generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import logging

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool

from app.core.auth import AuthenticatedUser, get_current_user
from app.core.db import get_document
from app.services.langgraph.streaming_agent import stream_generation
from app.services.text_guardrails import normalize_user_text

router = APIRouter()
logger = logging.getLogger(__name__)


class GenerateRequest(BaseModel):
resume_id: str | None = None
job_description_id: str | None = None
resume_text: str | None = Field(default=None, max_length=500_000)
job_description_text: str | None = Field(default=None, max_length=500_000)


@router.post("/generate/stream")
async def generate_stream(
payload: GenerateRequest,
user: AuthenticatedUser = Depends(get_current_user),
):
resume_text = payload.resume_text
if payload.resume_id:
doc = await run_in_threadpool(
get_document,
doc_id=payload.resume_id,
owner_user_id=user.user_id,
)
if not doc or doc.kind != "resume":
raise HTTPException(status_code=404, detail="Resume not found")
resume_text = doc.text

jd_text = payload.job_description_text
if payload.job_description_id:
doc = await run_in_threadpool(
get_document,
doc_id=payload.job_description_id,
owner_user_id=user.user_id,
)
if not doc or doc.kind != "job_description":
raise HTTPException(status_code=404, detail="Job description not found")
jd_text = doc.text

resume_text = normalize_user_text(resume_text or "")
jd_text = normalize_user_text(jd_text or "")
if not resume_text:
raise HTTPException(status_code=400, detail="Missing resume text")
if not jd_text:
raise HTTPException(status_code=400, detail="Missing job description text")

async def event_stream():
try:
async for line in stream_generation(resume_text=resume_text, job_description_text=jd_text):
yield f"{line}\n\n"
except Exception:
logger.exception("Generation stream failed")
yield 'event: error\ndata: {"message":"generation_failed"}\n\n'

return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
49 changes: 49 additions & 0 deletions backend/app/api/v1/job_descriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException

from app.core.auth import AuthenticatedUser, get_current_user
from app.core.db import create_document, get_document
from app.services.text_guardrails import normalize_user_text

router = APIRouter()


class JobDescriptionCreateRequest(BaseModel):
text: str = Field(min_length=1, max_length=500_000)
source_url: str | None = None


@router.post("/job-descriptions")
def create_job_description(
payload: JobDescriptionCreateRequest,
user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, str]:
text = normalize_user_text(payload.text)
if not text:
raise HTTPException(status_code=400, detail="Empty job description")

doc = create_document(
kind="job_description",
owner_user_id=user.user_id,
text=text,
filename=None,
content_type="text/plain",
meta={"source_url": payload.source_url} if payload.source_url else {},
)
return {"job_description_id": doc.id}


@router.get("/job-descriptions/{job_description_id}")
def get_job_description(
job_description_id: str,
user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, str | None]:
doc = get_document(doc_id=job_description_id, owner_user_id=user.user_id)
if not doc or doc.kind != "job_description":
raise HTTPException(status_code=404, detail="Job description not found")
return {
"job_description_id": doc.id,
"created_at": doc.created_at,
}
93 changes: 93 additions & 0 deletions backend/app/api/v1/resumes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import annotations

import logging

from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from starlette.concurrency import run_in_threadpool

from app.core.auth import AuthenticatedUser, get_current_user
from app.core.config import settings
from app.core.db import create_document, get_document
from app.services.text_guardrails import normalize_user_text
from app.services.uploads import delete_saved_upload, extract_text, save_upload, validate_upload

router = APIRouter()
logger = logging.getLogger(__name__)


@router.post("/resumes")
async def upload_resume(
file: UploadFile = File(...),
user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, str]:
try:
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > settings.max_upload_bytes:
raise HTTPException(status_code=413, detail="File too large")
chunks.append(chunk)
raw = b"".join(chunks)

def _process():
detected_type = validate_upload(filename=file.filename or "", content_type=file.content_type, data=raw)
extracted = extract_text(detected_type=detected_type, data=raw)
extracted = normalize_user_text(extracted)
if not extracted:
return None, detected_type, ""

saved = save_upload(
detected_type=detected_type,
owner_user_id=user.user_id,
content_type=file.content_type,
data=raw,
)
return saved, detected_type, extracted

saved, detected_type, extracted = await run_in_threadpool(_process)
if not extracted:
raise HTTPException(status_code=400, detail="Could not extract text from resume")
try:
doc = await run_in_threadpool(
create_document,
kind="resume",
owner_user_id=user.user_id,
text=extracted,
filename=file.filename,
content_type=file.content_type,
file_path=saved.path if saved else None,
meta={"bytes": len(raw), "detected_type": detected_type},
)
except Exception:
try:
await run_in_threadpool(delete_saved_upload, saved.path if saved else None)
except Exception:
logger.exception("Failed to clean up uploaded resume after DB insert failure")
raise
return {"resume_id": doc.id}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e


@router.get("/resumes/{resume_id}")
def get_resume(
resume_id: str,
user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, str | None]:
doc = get_document(doc_id=resume_id, owner_user_id=user.user_id)
if not doc or doc.kind != "resume":
raise HTTPException(status_code=404, detail="Resume not found")
return {
"resume_id": doc.id,
"filename": doc.filename,
"content_type": doc.content_type,
"created_at": doc.created_at,
}
45 changes: 45 additions & 0 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core.config import settings
from app.core.jwks import ClerkJwtVerifier


@dataclass(frozen=True)
class AuthenticatedUser:
user_id: str
claims: dict[str, Any]


_bearer = HTTPBearer(auto_error=False)
_verifier = ClerkJwtVerifier()


def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> AuthenticatedUser:
if settings.auth_mode == "disabled":
return AuthenticatedUser(user_id="anonymous", claims={"auth_mode": "disabled"})

if settings.auth_mode != "clerk_jwks":
raise HTTPException(status_code=500, detail="Unsupported AUTH_MODE configuration")

if not credentials or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Missing bearer token")

token = credentials.credentials
try:
claims = _verifier.verify(token)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e)) from e

user_id = str(claims.get("sub") or "")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token (missing subject)")

return AuthenticatedUser(user_id=user_id, claims=claims)
Loading
Loading