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
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,70 @@ many candidates before it can be calibrated, which does not exist yet. The
complexity tier is a deliberate stopgap — a second, independent signal — not a
substitute for it.

## The other side: the posting quiz

A resume is a claim about a person; a job posting is a claim about a role. Both are
cheap to generate and both are usually written by someone other than the person who
will live with them. So the company side runs the same interrogation in reverse.

1. An employer writes the posting — company, role, stack, what the job actually is.
2. The same engine generates questions grounded in that posting, in four categories:
- **role** — what this person does day to day, and what "doing well" looks like at 90 days
- **stack** — which of the listed technologies the hire actually touches, and why each is there
- **team** — who they work with, who decides what gets built
- **reality** — the constraints, the legacy, what makes the role genuinely hard
3. Same 75-second clock, same silent paste recording, same single adaptive follow-up
before anything is graded.
4. A posting is published **only** if the answers clear 70/100. Nothing else in the
codebase creates a job: `job_service.post_job()` has exactly one caller, and it is
the grading step.

The posting that goes live is the draft the questions were generated from, held
server-side for the whole round — so a company cannot answer honestly about the real
role and then publish a rosier version of it.

What this catches is a posting nobody behind it can account for. Asked about a
posting listing Kafka and Kubernetes next to a description that only mentions
FastAPI and Mongo, the first question generated back was where Kafka actually sits
in that flow — the kind of question a template cannot survive and the engineer who
owns the pipeline answers without thinking. A round answered with specifics and a
defended follow-up scored 98/100 and published; a round whose answers repeated
themselves and whose follow-up went undefended scored 52 and published nothing.

**Same seam as the candidate side.** The clock and the paste detector are
client-side, and the pass mark is a single model judgement. What holds is the
follow-up: it is generated from the employer's own wording at response time, so it
cannot be prepared in advance. Grading also cannot be replayed — an attempt is
graded once, keeps the job id it produced, and a retried call returns that instead of
publishing again.

## Status

**Built:** repo quiz end to end (generate → answer → grade), minimal job
posting/application CRUD.
**Built:** repo quiz end to end (generate → answer → grade), the company-side
quiz gating job postings, anonymous-first candidate profiles, the reputation
score (quiz depth + round history, shown as a breakdown), connections and a
text-only community feed, job listing/application CRUD.

**Designed, not yet built** (see `docs/ARCHITECTURE.md` for where these
slot in): company-side quiz gating job postings, anonymous-first candidate
profiles, unified reputation score, bug-hunt mode, community threads.
slot in): reputation feeding the reveal threshold, difficulty-calibrated
scoring, bug-hunt mode.

### Community: what is deliberately absent

Connections are instant and mutual — one document per pair, no request, no
approval, no pending state — and a post is text that gets created and listed.
Left out on purpose, each one a schema change rather than a flag so the feed
cannot drift into a social network by default:

- direct messages
- threaded replies and comments
- likes, reactions, any engagement counter
- approval-required connections (requests, accept/decline, blocking)
- media in posts

An unrevealed candidate is a pseudonym in the feed and in a connections list for
exactly as long as they are one on their profile: names are resolved at read time
from `users`, and an unrevealed one is never in the payload at all.

## Running locally

Expand Down
37 changes: 37 additions & 0 deletions backend/app/api/v1/endpoints/connections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Thin HTTP layer for connections. No business logic here — only request/response
translation and HTTP error mapping. Logic lives in
app/services/community_service.py.

Instant and mutual: there is no request to send, accept, or decline, so there is
no pending state and no endpoint to advance one.
"""
from fastapi import APIRouter, Depends, HTTPException

from app.core.dependencies import get_current_user
from app.schemas.community import ConnectionsResponse, ConnectResponse
from app.services import community_service

router = APIRouter()


@router.post("/{user_id}/connect", response_model=ConnectResponse)
async def connect(user_id: str, user: dict = Depends(get_current_user)):
"""
Connect the caller to `user_id`. Idempotent — a repeat returns the existing
connection with `created: false` rather than failing.
"""
try:
return await community_service.connect(user["user_id"], user_id)
except LookupError:
raise HTTPException(404, "Profile not found")
except ValueError:
raise HTTPException(400, "You cannot connect to yourself.")


@router.get("/{user_id}/connections", response_model=ConnectionsResponse)
async def list_connections(user_id: str):
try:
return await community_service.list_connections(user_id)
except LookupError:
raise HTTPException(404, "Profile not found")
81 changes: 72 additions & 9 deletions backend/app/api/v1/endpoints/jobs.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
"""Thin HTTP layer for job postings and applications."""
from fastapi import APIRouter
"""
Thin HTTP layer for job postings, applications, and the company-side quiz.
No business logic here — logic lives in app/services/.

from app.schemas.job import ApplicationRequest, JobCreateRequest
from app.services import job_service
There is deliberately no plain "create a job" route. A posting exists only as the
output of a defended company quiz (generate -> submit -> followup), which is what
"the quiz gates job posting creation" has to mean to be worth anything: an ungated
create endpoint alongside it would make the gate decorative. Job creation itself
still lives in job_service.post_job(); company_quiz_service is its only caller.

router = APIRouter()
The quiz routes are employer-only and each attempt belongs to the account that
generated it.
"""
from fastapi import APIRouter, Depends, HTTPException

from app.core.dependencies import get_current_employer
from app.schemas.job import (
ApplicationRequest,
CompanyQuizFollowUpRequest,
CompanyQuizGenerateRequest,
CompanyQuizGenerateResponse,
CompanyQuizResultResponse,
CompanyQuizSubmitRequest,
CompanyQuizSubmitResponse,
)
from app.services import company_quiz_service, job_service

@router.post("/")
async def create_job(job: JobCreateRequest):
job_id = await job_service.post_job(job.dict())
return {"id": job_id}
router = APIRouter()


@router.get("/")
Expand All @@ -22,3 +37,51 @@ async def list_jobs():
async def apply(application: ApplicationRequest):
app_id = await job_service.apply_to_job(application.dict())
return {"id": app_id}


# --- company quiz ----------------------------------------------------------


@router.post("/company-quiz/generate", response_model=CompanyQuizGenerateResponse)
async def generate_company_quiz(
draft: CompanyQuizGenerateRequest, user: dict = Depends(get_current_employer)
):
"""Interrogates the draft posting. The draft is held server-side until grading."""
try:
return await company_quiz_service.create_quiz(draft.model_dump(), user["user_id"])
except ValueError:
raise HTTPException(400, "Couldn't build questions from that posting — add more detail about the role.")


@router.post("/company-quiz/submit", response_model=CompanyQuizSubmitResponse)
async def submit_company_quiz(
req: CompanyQuizSubmitRequest, user: dict = Depends(get_current_employer)
):
"""Records answers and returns the adaptive follow-up. Does not grade or post."""
try:
return await company_quiz_service.start_followup(
req.quiz_id, [a.model_dump() for a in req.answers], user["user_id"]
)
except LookupError:
# Also the answer when the quiz exists but belongs to somebody else, so this
# cannot be used to discover which quiz ids are real.
raise HTTPException(404, "Quiz not found")
except company_quiz_service.QuizClosed:
raise HTTPException(409, "This quiz has already been graded.")
except ValueError:
raise HTTPException(400, "No answers submitted")


@router.post("/company-quiz/followup", response_model=CompanyQuizResultResponse)
async def company_quiz_followup(
req: CompanyQuizFollowUpRequest, user: dict = Depends(get_current_employer)
):
"""Grades the round and, on a pass, publishes the posting that was defended."""
try:
return await company_quiz_service.grade_and_post(
req.quiz_id, req.answer, user["user_id"], req.seconds_left
)
except LookupError:
raise HTTPException(404, "Quiz not found")
except company_quiz_service.QuizClosed:
raise HTTPException(409, "Answer the follow-up round before grading.")
36 changes: 36 additions & 0 deletions backend/app/api/v1/endpoints/posts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
Thin HTTP layer for the community feed. No business logic here — only
request/response translation and HTTP error mapping. Logic lives in
app/services/community_service.py.

Create and list, text only. No comments, likes, reactions, or media: the feed is
a place to say something, not a social network.
"""
from fastapi import APIRouter, Depends, HTTPException, Query

from app.core.dependencies import get_current_user
from app.schemas.community import PostCreateRequest, PostListResponse, PostResponse
from app.services import community_service

router = APIRouter()


@router.post("/", response_model=PostResponse, status_code=201)
async def create_post(req: PostCreateRequest, user: dict = Depends(get_current_user)):
try:
return await community_service.create_post(
user["user_id"], req.text, req.job_id, req.company_name
)
except LookupError:
raise HTTPException(404, "That job posting does not exist.")
except ValueError:
raise HTTPException(400, "A post needs text.")


@router.get("/", response_model=PostListResponse)
async def list_posts(
limit: int = Query(community_service.DEFAULT_PAGE, ge=1, le=community_service.MAX_PAGE),
skip: int = Query(0, ge=0),
):
"""Most recent first. Open to read, like the profile and reputation views."""
return await community_service.list_posts(limit=limit, skip=skip)
24 changes: 24 additions & 0 deletions backend/app/api/v1/endpoints/reputation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Thin HTTP layer for the reputation score. No business logic here — only
request/response translation and HTTP error mapping. Logic lives in
app/services/reputation_service.py.

Open for the same reason profiles are: the funnel exists so an employer can weigh
a candidate before either side has committed to anything, and the payload carries
no identity — only what the candidate has demonstrated.
"""
from fastapi import APIRouter, HTTPException

from app.schemas.reputation import ReputationResponse
from app.services import reputation_service

router = APIRouter()


@router.get("/{user_id}/reputation", response_model=ReputationResponse)
async def get_reputation(user_id: str):
try:
breakdown = await reputation_service.compute_reputation(user_id)
except LookupError:
raise HTTPException(404, "Profile not found")
return {"user_id": user_id, **breakdown}
14 changes: 13 additions & 1 deletion backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
"""Aggregates every v1 route. main.py only ever imports this one router."""
from fastapi import APIRouter

from app.api.v1.endpoints import auth, jobs, profile, quiz
from app.api.v1.endpoints import (
auth,
connections,
jobs,
posts,
profile,
quiz,
reputation,
)

api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(quiz.router, prefix="/quiz", tags=["quiz"])
api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
api_router.include_router(profile.router, prefix="/profile", tags=["profile"])
api_router.include_router(reputation.router, prefix="/users", tags=["reputation"])
# Shares the /users prefix with reputation: both hang off a person.
api_router.include_router(connections.router, prefix="/users", tags=["community"])
api_router.include_router(posts.router, prefix="/posts", tags=["community"])
14 changes: 14 additions & 0 deletions backend/app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,17 @@ async def get_current_user(
if not user_id:
raise HTTPException(401, "Invalid or expired token")
return {"user_id": user_id, "role": claims.get("role")}


async def get_current_employer(user: dict = Depends(get_current_user)) -> dict:
"""
Same as get_current_user, but only for employer accounts.

The role is read off the signed token rather than a request body, so a
candidate account cannot post jobs by claiming to be a company. 403 rather
than 404: which routes exist is not a secret, and a candidate who wound up
here should be told why the door is shut.
"""
if user.get("role") != "employer":
raise HTTPException(403, "Only employer accounts can post jobs.")
return user
Loading
Loading