From f86a21d00c06f9b7ebabf63de7cd1e795bcc59fc Mon Sep 17 00:00:00 2001 From: Krish Date: Mon, 24 Aug 2026 17:51:59 +0530 Subject: [PATCH 1/4] feat: gate job postings behind a company quiz The candidate side interrogates a repo before it counts for anything. This does the same to a job posting: the employer answers for the role they just wrote, under the same clock and the same single adaptive follow-up, and the posting is published only if the answers clear 70/100. Same engine rather than a copy. The clock, the paste and timing ranking in pick_suspect_answer, and the follow-up generator are all shared with the repo quiz; only the prompts differ, plus a `framing` argument whose default keeps the candidate wording byte-identical. Two gaming holes closed by construction: * The draft is held server-side from generation and the published posting is built from that copy, so a company cannot defend an honest draft and publish a rosier one. * `status: "graded"` is terminal and carries the job id it produced, so a replayed final call returns the stored outcome instead of minting a second posting. Grading also refuses to run if the follow-up round was skipped. Removes POST /jobs/, which created postings with no auth at all and would have left the gate decorative. Nothing called it. GET /jobs/ and POST /jobs/apply are unchanged. Company attempts get their own collection rather than a discriminator on quiz_attempts: has_graded_attempt_scoring_at_least() backs the candidate reveal threshold and matches any graded attempt by user, so a shared collection would let an employer quiz count as comprehension a candidate never demonstrated. Frontend adds the employer-only Post a Job tab, reusing the quiz feature QuestionCard so both sides of the market run the same clock and the same silent paste recording. 38 new backend tests; suite is 151 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XDgAeL6TE9UCqkWUy6azim --- README.md | 45 ++- backend/app/api/v1/endpoints/jobs.py | 81 +++++- backend/app/core/dependencies.py | 14 + backend/app/integrations/gemini_client.py | 147 +++++++++- backend/app/models/job.py | 28 ++ .../repositories/company_quiz_repository.py | 52 ++++ backend/app/schemas/job.py | 61 +++++ backend/app/services/company_quiz_service.py | 193 +++++++++++++ backend/tests/conftest.py | 48 ++++ backend/tests/test_company_quiz_api.py | 257 ++++++++++++++++++ backend/tests/test_company_quiz_service.py | 246 +++++++++++++++++ docs/ARCHITECTURE.md | 42 ++- frontend/src/App.jsx | 58 ++-- frontend/src/features/jobs/PostJobPage.jsx | 201 ++++++++++++++ frontend/src/features/jobs/api.js | 30 ++ .../features/jobs/components/JobDraftForm.jsx | 50 ++++ .../jobs/components/PostingResult.jsx | 44 +++ frontend/src/shared/api/token.js | 25 +- frontend/src/styles/globals.css | 14 + 19 files changed, 1586 insertions(+), 50 deletions(-) create mode 100644 backend/app/repositories/company_quiz_repository.py create mode 100644 backend/app/services/company_quiz_service.py create mode 100644 backend/tests/test_company_quiz_api.py create mode 100644 backend/tests/test_company_quiz_service.py create mode 100644 frontend/src/features/jobs/PostJobPage.jsx create mode 100644 frontend/src/features/jobs/components/JobDraftForm.jsx create mode 100644 frontend/src/features/jobs/components/PostingResult.jsx diff --git a/README.md b/README.md index a3f9530..a8fc327 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,51 @@ 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, 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): unified reputation score, bug-hunt mode, community threads. ## Running locally diff --git a/backend/app/api/v1/endpoints/jobs.py b/backend/app/api/v1/endpoints/jobs.py index bb4f585..d99b0c3 100644 --- a/backend/app/api/v1/endpoints/jobs.py +++ b/backend/app/api/v1/endpoints/jobs.py @@ -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("/") @@ -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.") diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 7c41e3e..39791b2 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -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 diff --git a/backend/app/integrations/gemini_client.py b/backend/app/integrations/gemini_client.py index 838077c..2bbb89b 100644 --- a/backend/app/integrations/gemini_client.py +++ b/backend/app/integrations/gemini_client.py @@ -31,6 +31,13 @@ def _strip_code_fence(text: str) -> str: } +def _parse_questions(data) -> list[dict]: + """Questions out of either shape the model returns: a bare array or {"questions": [...]}.""" + if isinstance(data, list): + return data + return data.get("questions") or [] + + def _parse_quiz_payload(text: str) -> tuple[list[dict], dict]: """ Split the model's response into (questions, complexity). @@ -42,9 +49,9 @@ def _parse_quiz_payload(text: str) -> tuple[list[dict], dict]: data = json.loads(text) if isinstance(data, list): # model ignored the object wrapper - return data, dict(UNKNOWN_COMPLEXITY) + return _parse_questions(data), dict(UNKNOWN_COMPLEXITY) - questions = data.get("questions") or [] + questions = _parse_questions(data) raw = data.get("complexity") if not isinstance(raw, dict): return questions, dict(UNKNOWN_COMPLEXITY) @@ -98,15 +105,30 @@ async def generate_quiz_questions(files: list[dict], n_questions: int = 5) -> tu return _parse_quiz_payload(_strip_code_fence(response.text)) -async def generate_followup_question(question: dict, answer: str) -> str: +# How the follow-up addresses whoever is answering. The generator itself is identical +# for both sides of the market - only the framing changes, so a company defending its +# own posting is pushed on exactly as hard as a candidate defending their code. +CANDIDATE_FRAMING = { + "opening": "A developer was asked this about a project they claim to have built", + "topic": "the project", +} +COMPANY_FRAMING = { + "opening": "The person who wrote a job posting was asked this about the role they are hiring for", + "topic": "the role", +} + + +async def generate_followup_question( + question: dict, answer: str, framing: dict = CANDIDATE_FRAMING +) -> str: """ - One sharp follow-up that pushes on the candidate's own wording. + One sharp follow-up that pushes on the answerer's own wording. The point is not to ask something harder - it is to ask something that is only answerable by whoever actually meant what they wrote. A pasted answer has no author behind it to defend it. """ - prompt = f"""A developer was asked this about a project they claim to have built: + prompt = f"""{framing["opening"]}: QUESTION: {question.get("question", "")} @@ -120,7 +142,7 @@ async def generate_followup_question(question: dict, answer: str) -> str: throws?" It must be unanswerable by someone who did not mean what they wrote. Do not ask a -general question about the project. Do not ask them to quote code. +general question about {framing["topic"]}. Do not ask them to quote code. Return ONLY the question text. No prose, no JSON, no quotes around it. """ @@ -183,3 +205,116 @@ async def grade_answers( """ response = await _model().generate_content_async(prompt) return json.loads(_strip_code_fence(response.text)) + + +def _posting_block(draft: dict) -> str: + stack = ", ".join(draft.get("tech_stack") or []) or "(none listed)" + return f"""COMPANY: {draft.get("company_name", "")} +ROLE: {draft.get("role_title", "")} +TECH STACK LISTED: {stack} +DESCRIPTION: +{draft.get("description", "")}""" + + +async def generate_company_quiz_questions(draft: dict, n_questions: int = 5) -> list[dict]: + """ + Interrogate a job posting the way the repo quiz interrogates a repo. + + Same engine, mirrored subject: instead of asking a developer whether they + understand the code they submitted, this asks the poster whether they know the + role they are advertising. A posting assembled from a template is exactly as + cheap as a resume assembled from one, and fails here for the same reason - there + is no lived detail behind it to produce under a clock. + """ + prompt = f"""You are an experienced engineer who just read this job posting and is +deciding whether to apply. You get to ask the hiring manager {n_questions} questions +first. + +Ask what someone who actually owns this role could answer instantly and someone who +pasted together a template could not. Push on vague recruiter language rather than +accepting it - if the posting says "fast-paced" or "rockstar" or lists ten +technologies, make them say what that concretely means here. + +Cover these four categories, at least one question each: + role - what this person actually does day to day, and what "doing well" looks like in the first 90 days + stack - which of the listed technologies the hire actually touches, and why each one is in the stack + team - who they work with, who decides what they build, how big the team is + reality - the unglamorous part: the constraints, the legacy, what makes this role genuinely hard, why the seat is open + +Ground each question in something specific from the posting - a phrase they used, a +technology they listed, a claim they made. Do NOT ask about salary, benefits, or +interview logistics. Do NOT ask questions answerable by re-reading the posting aloud. + +Return ONLY a JSON object, no prose: +{{"questions": [{{"question": "...", "category": "role" | "stack" | "team" | "reality"}}]}} + +POSTING: +{_posting_block(draft)} +""" + response = await _model().generate_content_async(prompt) + return _parse_questions(json.loads(_strip_code_fence(response.text))) + + +async def grade_company_answers( + draft: dict, questions: list[dict], answers: list[dict], followup: dict | None = None +) -> dict: + """ + Score whether the posting reflects a role this person actually knows. + + The mirror of grade_answers: there, a candidate's claim is their code; here it is + their posting. The failure being caught is the same one - a confident description + of something the author has no real contact with. + """ + qa_pairs = "\n\n".join( + "Q: {q}\nA: {a}".format( + q=q["question"], + a=next((a["answer"] for a in answers if a["question_id"] == q["id"]), "(no answer)"), + ) + for q in questions + ) + + followup_block = "" + if followup and followup.get("question"): + followup_block = f""" + +FOLLOW-UP ROUND - this is the strongest signal you have. +After answering, they were pushed on their own wording and replied under time pressure +with no chance to prepare: + + Follow-up asked : {followup.get("question")} + They replied : {followup.get("answer") or "(no answer)"} + +Weigh this heavily. If they cannot defend or even engage with wording they themselves +used, treat the original answer it came from as very likely not describing a role they +know, and score that answer down hard regardless of how polished it looked. +""" + + prompt = f"""You are scoring a hiring manager on whether the job posting below +reflects a role they actually know, rather than a template they assembled. + +Score WELL: concrete, specific answers - named systems, real trade-offs, honest +constraints, an admission that part of the job is tedious or unresolved. Someone +describing a real seat on a real team says things that could not be said about any +other job. + +Score POORLY: answers that would fit any company hiring any engineer, buzzwords with +nothing under them, restating the posting back, or dodging the question. Also score +down when an answer contradicts the posting - a listed technology nobody touches, or +a scope that turns out to be far smaller or larger than advertised - because the +posting failing to match the role is exactly the thing being tested. + +Do NOT reward polish, length, or enthusiasm. Do NOT penalise blunt or unflattering +honesty about the role; that is evidence they know it. + +Return ONLY JSON, no prose: +{{"overall_score": 0-100, "breakdown": [{{"question": "...", "score": 0-10, "note": "..."}}]}} + +THE POSTING THEY WROTE: +{_posting_block(draft)} + +THEIR ANSWERS: +{qa_pairs} +{followup_block} +""" + response = await _model().generate_content_async(prompt) + return json.loads(_strip_code_fence(response.text)) diff --git a/backend/app/models/job.py b/backend/app/models/job.py index edc5bae..45493cf 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -6,9 +6,17 @@ "role_title": str, "description": str, "tech_stack": list[str], + "posted_by": str, # user_id of the employer who defended the quiz + "company_quiz_id": str, # the attempt that unlocked this posting "posted_at": datetime, } +Every document here is written by services/company_quiz_service.py on a passing +grade, from the draft that was stored when the questions were generated. There is +no route that inserts a job any other way, so a posting in this collection has by +construction been defended by the account named in `posted_by`. Documents created +before the gate existed have neither key. + applications collection: { "_id": str (uuid4), @@ -17,4 +25,24 @@ "quiz_score_id": str | None, "status": "applied" | "reviewed" | "rejected" | "accepted", } + +company_quiz_attempts collection (see repositories/company_quiz_repository.py for +why this is not merged into quiz_attempts): +{ + "_id": str (uuid4), + "user_id": str, # the employer; ownership is checked on every step + "draft": {company_name, role_title, description, tech_stack}, + "questions": [{"id": str, "question": str, "category": str}], + "answers": [{"question_id": str, "answer": str, "seconds_left": float | None, + "flagged_paste": bool, "paste_delta": int}], + "followup": {"id": str, "question": str, "targets_question_id": str, + "answer": str | None, "seconds_left": float | None}, + "result": {"overall_score": float, "breakdown": [...]}, + "passed": bool, + "job_id": str | None, # the posting this attempt produced, None if it failed + "status": "generated" | "awaiting_followup" | "graded", +} + +`status: "graded"` is terminal: the attempt keeps whatever job_id it produced and +is never re-graded, so it cannot be replayed into a second posting. """ diff --git a/backend/app/repositories/company_quiz_repository.py b/backend/app/repositories/company_quiz_repository.py new file mode 100644 index 0000000..e20251c --- /dev/null +++ b/backend/app/repositories/company_quiz_repository.py @@ -0,0 +1,52 @@ +""" +Only file allowed to query the company_quiz_attempts collection directly. + +Deliberately a separate collection from quiz_attempts rather than a `kind` field +on the same one. quiz_repository.has_graded_attempt_scoring_at_least() matches any +graded attempt owned by a user, and it backs the candidate reveal threshold — a +company quiz sharing that collection would silently count as comprehension a +candidate never demonstrated. Separate collections make that impossible rather +than merely unlikely. +""" +from typing import Optional + +from app.db.mongodb import get_collection + +collection = get_collection("company_quiz_attempts") + + +async def save_attempt(doc: dict) -> None: + await collection.insert_one(doc) + + +async def get_attempt(quiz_id: str) -> Optional[dict]: + return await collection.find_one({"_id": quiz_id}) + + +async def update_followup(quiz_id: str, answers: list[dict], followup: dict) -> None: + await collection.update_one( + {"_id": quiz_id}, + {"$set": {"status": "awaiting_followup", "answers": answers, "followup": followup}}, + ) + + +async def update_result( + quiz_id: str, result: dict, followup: dict, passed: bool, job_id: Optional[str] +) -> None: + """ + Close the attempt out. `job_id` is the posting this quiz unlocked, or None when + it did not clear the bar — recorded either way so a graded attempt can never be + replayed to mint a second posting. + """ + await collection.update_one( + {"_id": quiz_id}, + { + "$set": { + "status": "graded", + "result": result, + "followup": followup, + "passed": passed, + "job_id": job_id, + } + }, + ) diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index 94fd1e1..78953ea 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -4,6 +4,13 @@ from pydantic import BaseModel +from app.schemas.quiz import ( + TIME_LIMIT_SECONDS, + FollowUpQuestion, + QuizAnswer, + QuizQuestion, +) + class JobCreateRequest(BaseModel): company_name: str @@ -25,3 +32,57 @@ class ApplicationRequest(BaseModel): job_id: str user_id: str quiz_score_id: Optional[str] = None + + +# --- company quiz ---------------------------------------------------------- +# +# The DTOs below deliberately reuse QuizAnswer, QuizQuestion and FollowUpQuestion +# from schemas/quiz.py rather than redeclaring them. The two sides of the market +# run the same engine, so the anti-gaming fields (seconds_left, flagged_paste, +# paste_delta) must stay one definition — a company-side copy would drift. + + +class CompanyQuizGenerateRequest(JobCreateRequest): + """ + The draft posting itself. Questions are generated from it, and it is stored on + the attempt — the job is later created from that stored copy, never from a + second body sent at the end, so a passing quiz cannot be redeemed against a + different posting than the one that was defended. + """ + + +class CompanyQuizGenerateResponse(BaseModel): + quiz_id: str + role_title: str + questions: List[QuizQuestion] + time_limit_seconds: int = TIME_LIMIT_SECONDS + + +class CompanyQuizSubmitRequest(BaseModel): + quiz_id: str + answers: List[QuizAnswer] + + +class CompanyQuizSubmitResponse(BaseModel): + """Like the candidate flow, submitting opens the follow-up rather than grading.""" + + quiz_id: str + followup: FollowUpQuestion + time_limit_seconds: int = TIME_LIMIT_SECONDS + + +class CompanyQuizFollowUpRequest(BaseModel): + quiz_id: str + answer: str + seconds_left: Optional[float] = None + + +class CompanyQuizResultResponse(BaseModel): + quiz_id: str + score: float + pass_score: float + passed: bool + # The posting that was created, or None when the quiz did not clear the bar. + # This is the whole gate: no other route creates a job. + job_id: Optional[str] = None + feedback: List[dict] diff --git a/backend/app/services/company_quiz_service.py b/backend/app/services/company_quiz_service.py new file mode 100644 index 0000000..3eccf3e --- /dev/null +++ b/backend/app/services/company_quiz_service.py @@ -0,0 +1,193 @@ +""" +Business logic for the company-side quiz — the employer mirror of quiz_service. + +The thesis cuts both ways. A candidate's claim is their repo, and the repo quiz +tests whether they understand it. A company's claim is its posting, and this tests +whether the posting describes a role the poster actually knows. Both are cheap to +generate and expensive to defend, which is the whole point. + +It is the same engine: the same clock, the same paste ranking +(quiz_service.pick_suspect_answer), the same one adaptive follow-up before anything +is graded. Only the prompts differ — see integrations/gemini_client.py. + +The gate: a job posting is created here, on a passing grade, from the draft stored +at generation time. No other code path creates a job. Two consequences worth being +explicit about: + + * The posting that gets created is the one the questions were generated from, so + a company cannot defend an honest draft and redeem the pass against a different, + rosier one. + * A graded attempt is closed forever. It carries the job_id it produced (or None), + so replaying the final call returns what already happened instead of minting a + second posting. +""" +import uuid + +from app.integrations import gemini_client +from app.repositories import company_quiz_repository +from app.schemas.quiz import TIME_LIMIT_SECONDS +from app.services import job_service +from app.services.quiz_service import pick_suspect_answer + + +class QuizClosed(Exception): + """The attempt is not in a state where the requested step is meaningful.""" + + +# The bar a posting must clear to go live. A module constant for the same reason as +# reputation_service.REVEAL_MIN_SCORE: the tests assert against this rather than a +# literal, so moving the bar cannot quietly leave the suite asserting the old one. +PASS_SCORE = 70.0 + +# Fields of the stored draft that describe the posting. Anything else on the attempt +# (ownership, answers, grading) is bookkeeping and must not reach the jobs collection. +DRAFT_FIELDS = ("company_name", "role_title", "description", "tech_stack") + + +async def create_quiz(draft: dict, user_id: str) -> dict: + """Generate the interrogation for a draft posting and hold the draft for later.""" + raw_questions = await gemini_client.generate_company_quiz_questions(draft) + if not raw_questions: + raise ValueError("no_questions") + + questions = [{"id": str(uuid.uuid4()), **q} for q in raw_questions] + stored_draft = {k: draft.get(k) for k in DRAFT_FIELDS} + + quiz_id = str(uuid.uuid4()) + await company_quiz_repository.save_attempt( + { + "_id": quiz_id, + "draft": stored_draft, + "user_id": user_id, + "questions": questions, + "status": "generated", + } + ) + return { + "quiz_id": quiz_id, + "role_title": stored_draft.get("role_title") or "", + "questions": questions, + "time_limit_seconds": TIME_LIMIT_SECONDS, + } + + +async def _load_owned_attempt(quiz_id: str, user_id: str) -> dict: + """ + Fetch an attempt, but only for the account it belongs to. + + Mirrors quiz_service._load_owned_attempt: a quiz that exists but belongs to + someone else raises the same LookupError as one that does not exist, so the + endpoint answers 404 either way and cannot be used to discover which quiz ids + are real. Kept here rather than shared because the two read different + collections, and the ownership rule is short enough that a wrong-collection + abstraction would cost more than the duplication. + """ + attempt = await company_quiz_repository.get_attempt(quiz_id) + if not attempt or attempt.get("user_id") != user_id: + raise LookupError("quiz_not_found") + return attempt + + +async def start_followup(quiz_id: str, answers: list[dict], user_id: str) -> dict: + """Record the answers and open the follow-up round. Does not grade, does not post.""" + attempt = await _load_owned_attempt(quiz_id, user_id) + if attempt.get("status") == "graded": + # Re-opening a closed attempt would let a company answer again after seeing + # how it scored. + raise QuizClosed("already_graded") + + suspect = pick_suspect_answer(answers) + if suspect is None: + raise ValueError("no_answers") + + target = next( + (q for q in attempt["questions"] if q["id"] == suspect.get("question_id")), + attempt["questions"][0], + ) + question_text = await gemini_client.generate_followup_question( + target, suspect.get("answer") or "", framing=gemini_client.COMPANY_FRAMING + ) + + followup = { + "id": str(uuid.uuid4()), + "question": question_text, + "targets_question_id": target["id"], + "answer": None, + } + await company_quiz_repository.update_followup(quiz_id, answers, followup) + + return { + "quiz_id": quiz_id, + "followup": followup, + "time_limit_seconds": TIME_LIMIT_SECONDS, + } + + +def _result_payload(quiz_id: str, score: float, passed: bool, job_id, breakdown) -> dict: + return { + "quiz_id": quiz_id, + "score": score, + "pass_score": PASS_SCORE, + "passed": passed, + "job_id": job_id, + "feedback": breakdown or [], + } + + +async def grade_and_post( + quiz_id: str, followup_answer: str, user_id: str, seconds_left: float | None = None +) -> dict: + """ + Grade the whole round and, only on a pass, create the posting. + + Replaying this against an already-graded attempt returns the stored outcome + rather than grading again, so a retried request cannot produce a second posting. + """ + attempt = await _load_owned_attempt(quiz_id, user_id) + + if attempt.get("status") == "graded": + stored = attempt.get("result") or {} + return _result_payload( + quiz_id, + float(stored.get("overall_score") or 0), + bool(attempt.get("passed")), + attempt.get("job_id"), + stored.get("breakdown"), + ) + + followup = dict(attempt.get("followup") or {}) + if not followup.get("question"): + # The follow-up is the round that actually holds; grading without it would + # let a company post by calling this endpoint directly and skipping the + # interrogation entirely. + raise QuizClosed("followup_not_started") + + followup["answer"] = followup_answer + followup["seconds_left"] = seconds_left + + result = await gemini_client.grade_company_answers( + attempt.get("draft") or {}, + attempt["questions"], + attempt.get("answers") or [], + followup=followup, + ) + score = float(result.get("overall_score") or 0) + passed = score >= PASS_SCORE + + # Posted before the attempt is closed out, so a failure here leaves the attempt + # re-gradable rather than burning a quiz that never produced a posting. The cost + # is a narrow window: if the write below fails after this insert, a retry can + # post twice. Recording the outcome first would trade that for the opposite + # failure, and losing an honest company's defended quiz is the worse one. + job_id = None + if passed: + job_id = await job_service.post_job( + { + **(attempt.get("draft") or {}), + "posted_by": user_id, + "company_quiz_id": quiz_id, + } + ) + + await company_quiz_repository.update_result(quiz_id, result, followup, passed, job_id) + return _result_payload(quiz_id, score, passed, job_id, result.get("breakdown")) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 877c35d..3bf678a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -50,3 +50,51 @@ def attempt(questions, complexity): }, "status": "awaiting_followup", } + + +COMPANY_QUESTIONS = [ + {"id": "cq1", "question": "What does this hire do in their first week?", "category": "role"}, + {"id": "cq2", "question": "Which of the six listed technologies do they touch daily?", "category": "stack"}, + {"id": "cq3", "question": "Who decides what they build?", "category": "team"}, +] + +DRAFT = { + "company_name": "Acme", + "role_title": "Backend Engineer", + "description": "Own the ingest pipeline end to end.", + "tech_stack": ["Python", "MongoDB"], +} + + +@pytest.fixture +def draft(): + return dict(DRAFT) + + +@pytest.fixture +def company_questions(): + return [dict(q) for q in COMPANY_QUESTIONS] + + +@pytest.fixture +def company_attempt(company_questions, draft): + """A stored company_quiz_attempts document mid-flow, awaiting its follow-up.""" + return { + "_id": "cquiz-1", + "user_id": "e1", + "draft": draft, + "questions": company_questions, + "answers": [ + {"question_id": "cq1", "answer": "typed reply", "seconds_left": 6.0, + "flagged_paste": False, "paste_delta": 0}, + {"question_id": "cq2", "answer": "x" * 400, "seconds_left": 70.0, + "flagged_paste": True, "paste_delta": 400}, + ], + "followup": { + "id": "cf1", + "question": "You said they own ingest — who is on call for it today?", + "targets_question_id": "cq2", + "answer": None, + }, + "status": "awaiting_followup", + } diff --git a/backend/tests/test_company_quiz_api.py b/backend/tests/test_company_quiz_api.py new file mode 100644 index 0000000..6437980 --- /dev/null +++ b/backend/tests/test_company_quiz_api.py @@ -0,0 +1,257 @@ +""" +HTTP contract for the company-side quiz. + +Two things are being pinned here. First the flow, which mirrors the candidate +quiz: generate -> submit -> followup, with /submit returning a follow-up rather +than a score. Second the gate itself: these routes are employer-only, and there +is no other way to create a job posting. +""" +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.security import create_access_token +from app.main import app +from app.services import company_quiz_service + +EMPLOYER = {"Authorization": f"Bearer {create_access_token('test-employer', 'employer')}"} +CANDIDATE = {"Authorization": f"Bearer {create_access_token('test-user', 'candidate')}"} + +client = TestClient(app, headers=EMPLOYER) +anon = TestClient(app) + +DRAFT_BODY = { + "company_name": "Acme", + "role_title": "Backend Engineer", + "description": "Own the ingest pipeline end to end.", + "tech_stack": ["Python", "MongoDB"], +} + + +def generated(questions): + return {"quiz_id": "cquiz-1", "role_title": "Backend Engineer", + "questions": questions, "time_limit_seconds": 75} + + +def followup_payload(targets="cq2"): + return {"quiz_id": "cquiz-1", + "followup": {"id": "cf1", "question": "Who is on call?", "targets_question_id": targets}, + "time_limit_seconds": 75} + + +# --- the gate -------------------------------------------------------------- + +def test_there_is_no_ungated_way_to_create_a_job(): + """ + The whole feature is worthless if a plain POST /jobs/ still inserts a posting. + This asserts the route is gone, not merely unused by the frontend. + """ + resp = client.post("/api/v1/jobs/", json=DRAFT_BODY) + assert resp.status_code in (404, 405) + + +def test_listing_jobs_still_works(): + """Reading postings is public; only creating one is gated.""" + with pytest.MonkeyPatch.context() as mp: + from app.services import job_service + mp.setattr(job_service, "get_jobs", AsyncMock(return_value=[])) + assert anon.get("/api/v1/jobs/").status_code == 200 + + +# --- auth ------------------------------------------------------------------ + +@pytest.mark.parametrize("path, body", [ + ("generate", DRAFT_BODY), + ("submit", {"quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}]}), + ("followup", {"quiz_id": "cquiz-1", "answer": "a"}), +]) +def test_every_company_quiz_route_rejects_an_anonymous_caller(path, body): + assert anon.post(f"/api/v1/jobs/company-quiz/{path}", json=body).status_code == 401 + + +@pytest.mark.parametrize("path, body", [ + ("generate", DRAFT_BODY), + ("submit", {"quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}]}), + ("followup", {"quiz_id": "cquiz-1", "answer": "a"}), +]) +def test_candidate_accounts_cannot_post_jobs(path, body): + """The role is read off the signed token, so this cannot be forged in a body.""" + resp = TestClient(app, headers=CANDIDATE).post(f"/api/v1/jobs/company-quiz/{path}", json=body) + assert resp.status_code == 403 + + +def test_generate_is_refused_before_any_api_call_is_spent(monkeypatch): + spy = AsyncMock() + monkeypatch.setattr(company_quiz_service, "create_quiz", spy) + TestClient(app, headers=CANDIDATE).post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + spy.assert_not_called() + + +# --- generate -------------------------------------------------------------- + +def test_generate_returns_questions_and_the_time_limit(monkeypatch, company_questions): + monkeypatch.setattr(company_quiz_service, "create_quiz", + AsyncMock(return_value=generated(company_questions))) + + resp = client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + + assert resp.status_code == 200 + body = resp.json() + assert body["quiz_id"] == "cquiz-1" + assert body["time_limit_seconds"] == 75 + assert [q["category"] for q in body["questions"]] == ["role", "stack", "team"] + + +def test_generate_forwards_the_draft_and_the_token_identity(monkeypatch, company_questions): + spy = AsyncMock(return_value=generated(company_questions)) + monkeypatch.setattr(company_quiz_service, "create_quiz", spy) + + client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + + draft, user_id = spy.call_args.args + assert draft["role_title"] == "Backend Engineer" + assert draft["tech_stack"] == ["Python", "MongoDB"] + assert user_id == "test-employer" + + +def test_generate_requires_the_posting_fields(): + resp = client.post("/api/v1/jobs/company-quiz/generate", json={"company_name": "Acme"}) + assert resp.status_code == 422 + + +def test_generate_maps_an_unusable_posting_to_400(monkeypatch): + monkeypatch.setattr(company_quiz_service, "create_quiz", + AsyncMock(side_effect=ValueError("no_questions"))) + resp = client.post("/api/v1/jobs/company-quiz/generate", json=DRAFT_BODY) + assert resp.status_code == 400 + + +# --- submit ---------------------------------------------------------------- + +def test_submit_returns_a_followup_and_no_score(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", + AsyncMock(return_value=followup_payload())) + + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + + assert resp.status_code == 200 + body = resp.json() + assert body["followup"]["targets_question_id"] == "cq2" + assert "score" not in body + assert "job_id" not in body + + +def test_submit_forwards_paste_flags_and_timing(monkeypatch): + """The anti-gaming signals are useless if they do not survive the wire.""" + spy = AsyncMock(return_value=followup_payload("cq1")) + monkeypatch.setattr(company_quiz_service, "start_followup", spy) + + client.post("/api/v1/jobs/company-quiz/submit", json={"quiz_id": "cquiz-1", "answers": [ + {"question_id": "cq1", "answer": "a", "seconds_left": 61.5, + "flagged_paste": True, "paste_delta": 220}, + ]}) + + sent = spy.call_args.args[1][0] + assert sent["flagged_paste"] is True + assert sent["paste_delta"] == 220 + assert sent["seconds_left"] == 61.5 + + +def test_submit_defaults_paste_flags_when_the_client_omits_them(monkeypatch): + spy = AsyncMock(return_value=followup_payload("cq1")) + monkeypatch.setattr(company_quiz_service, "start_followup", spy) + + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + + assert resp.status_code == 200 + sent = spy.call_args.args[1][0] + assert sent["flagged_paste"] is False + assert sent["paste_delta"] == 0 + assert sent["seconds_left"] is None + + +def test_submit_unknown_quiz_is_404(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", AsyncMock(side_effect=LookupError())) + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "nope", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + assert resp.status_code == 404 + + +def test_submit_on_a_graded_quiz_is_409(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", + AsyncMock(side_effect=company_quiz_service.QuizClosed())) + resp = client.post("/api/v1/jobs/company-quiz/submit", json={ + "quiz_id": "cquiz-1", "answers": [{"question_id": "cq1", "answer": "a"}], + }) + assert resp.status_code == 409 + + +def test_submit_with_no_answers_is_400(monkeypatch): + monkeypatch.setattr(company_quiz_service, "start_followup", AsyncMock(side_effect=ValueError())) + resp = client.post("/api/v1/jobs/company-quiz/submit", json={"quiz_id": "cquiz-1", "answers": []}) + assert resp.status_code == 400 + + +# --- followup / grading ---------------------------------------------------- + +def test_a_pass_returns_the_posting_it_created(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={ + "quiz_id": "cquiz-1", "score": 84.0, "pass_score": 70.0, "passed": True, + "job_id": "job-1", "feedback": [{"question": "q", "score": 9, "note": "specific"}], + })) + + resp = client.post("/api/v1/jobs/company-quiz/followup", json={ + "quiz_id": "cquiz-1", "answer": "our defence", "seconds_left": 12.0, + }) + + assert resp.status_code == 200 + body = resp.json() + assert body["passed"] is True + assert body["job_id"] == "job-1" + assert body["score"] == 84.0 + assert body["feedback"][0]["note"] == "specific" + + +def test_a_fail_returns_no_posting(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={ + "quiz_id": "cquiz-1", "score": 31.0, "pass_score": 70.0, "passed": False, + "job_id": None, "feedback": [], + })) + + body = client.post("/api/v1/jobs/company-quiz/followup", + json={"quiz_id": "cquiz-1", "answer": "vague"}).json() + + assert body["passed"] is False + assert body["job_id"] is None + + +def test_followup_accepts_a_blank_answer(monkeypatch): + """A timed-out follow-up submits blank - that is a result, not an error.""" + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(return_value={ + "quiz_id": "cquiz-1", "score": 0.0, "pass_score": 70.0, "passed": False, + "job_id": None, "feedback": [], + })) + resp = client.post("/api/v1/jobs/company-quiz/followup", + json={"quiz_id": "cquiz-1", "answer": ""}) + assert resp.status_code == 200 + assert resp.json()["score"] == 0.0 + + +def test_followup_unknown_quiz_is_404(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", AsyncMock(side_effect=LookupError())) + resp = client.post("/api/v1/jobs/company-quiz/followup", json={"quiz_id": "nope", "answer": "a"}) + assert resp.status_code == 404 + + +def test_grading_without_the_followup_round_is_409(monkeypatch): + monkeypatch.setattr(company_quiz_service, "grade_and_post", + AsyncMock(side_effect=company_quiz_service.QuizClosed())) + resp = client.post("/api/v1/jobs/company-quiz/followup", + json={"quiz_id": "cquiz-1", "answer": "a"}) + assert resp.status_code == 409 diff --git a/backend/tests/test_company_quiz_service.py b/backend/tests/test_company_quiz_service.py new file mode 100644 index 0000000..b2572c1 --- /dev/null +++ b/backend/tests/test_company_quiz_service.py @@ -0,0 +1,246 @@ +""" +The company-side gate. + +Everything here is about one property: a job posting exists only as the output of +a quiz its owner defended, and it is the posting that was defended. The scoring +itself is the model's business; what these tests hold down is that no other path +reaches the jobs collection. +""" +from unittest.mock import AsyncMock + +import pytest + +from app.services import company_quiz_service +from app.services.company_quiz_service import PASS_SCORE, QuizClosed + + +def patch_generation(monkeypatch, questions=None): + gen = AsyncMock(return_value=questions if questions is not None else [ + {"question": "What does this hire do in week one?", "category": "role"}, + ]) + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_company_quiz_questions", gen) + saved = AsyncMock() + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "save_attempt", saved) + return gen, saved + + +def patch_grading(monkeypatch, attempt, score, breakdown=None): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=attempt)) + monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", + AsyncMock(return_value={"overall_score": score, + "breakdown": breakdown or []})) + recorded = AsyncMock() + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_result", recorded) + posted = AsyncMock(return_value="job-1") + monkeypatch.setattr(company_quiz_service.job_service, "post_job", posted) + return posted, recorded + + +# --- generate -------------------------------------------------------------- + +async def test_create_quiz_assigns_ids_and_stores_the_draft(monkeypatch, draft): + _, saved = patch_generation(monkeypatch) + + out = await company_quiz_service.create_quiz(draft, "e1") + + assert out["questions"][0]["id"], "questions must be given ids" + assert out["time_limit_seconds"] == 75 + doc = saved.call_args.args[0] + assert doc["draft"] == draft + assert doc["user_id"] == "e1" + assert doc["status"] == "generated" + + +async def test_create_quiz_stores_only_posting_fields(monkeypatch, draft): + """A caller cannot smuggle extra keys into the jobs document via the draft.""" + _, saved = patch_generation(monkeypatch) + + await company_quiz_service.create_quiz( + {**draft, "posted_by": "someone-else", "verified": True}, "e1" + ) + + assert set(saved.call_args.args[0]["draft"]) == set(company_quiz_service.DRAFT_FIELDS) + + +async def test_create_quiz_rejects_a_posting_that_yields_no_questions(monkeypatch, draft): + patch_generation(monkeypatch, questions=[]) + with pytest.raises(ValueError): + await company_quiz_service.create_quiz(draft, "e1") + + +# --- follow-up round ------------------------------------------------------- + +async def test_start_followup_targets_the_flagged_answer(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + gen = AsyncMock(return_value="Who is on call for ingest today?") + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", gen) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock()) + + out = await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + assert out["followup"]["targets_question_id"] == "cq2" + assert gen.call_args.args[1] == company_attempt["answers"][1]["answer"] + + +async def test_followup_is_asked_in_the_company_framing(monkeypatch, company_attempt): + """Same generator as the candidate side - it must not address them as a developer.""" + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + gen = AsyncMock(return_value="q?") + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", gen) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock()) + + await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + assert gen.call_args.kwargs["framing"] is company_quiz_service.gemini_client.COMPANY_FRAMING + + +async def test_start_followup_does_not_grade_or_post(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + monkeypatch.setattr(company_quiz_service.gemini_client, "generate_followup_question", + AsyncMock(return_value="q?")) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_followup", AsyncMock()) + posted = AsyncMock() + monkeypatch.setattr(company_quiz_service.job_service, "post_job", posted) + graded = AsyncMock() + monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", graded) + + out = await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + posted.assert_not_called() + graded.assert_not_called() + assert "score" not in out + + +async def test_start_followup_refuses_a_graded_attempt(monkeypatch, company_attempt): + """Re-opening a closed attempt would mean answering again after seeing the score.""" + company_attempt["status"] = "graded" + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + with pytest.raises(QuizClosed): + await company_quiz_service.start_followup("cquiz-1", company_attempt["answers"], "e1") + + +# --- the gate -------------------------------------------------------------- + +async def test_passing_publishes_the_posting_that_was_defended(monkeypatch, company_attempt, draft): + posted, recorded = patch_grading(monkeypatch, company_attempt, PASS_SCORE + 12) + + out = await company_quiz_service.grade_and_post("cquiz-1", "our defence", "e1", 8.0) + + assert out["passed"] is True + assert out["job_id"] == "job-1" + assert out["pass_score"] == PASS_SCORE + sent = posted.call_args.args[0] + for field in draft: + assert sent[field] == draft[field], "the posting must be the stored draft" + assert sent["posted_by"] == "e1" + assert sent["company_quiz_id"] == "cquiz-1" + assert recorded.call_args.args[4] == "job-1" + + +async def test_failing_publishes_nothing(monkeypatch, company_attempt): + posted, recorded = patch_grading(monkeypatch, company_attempt, PASS_SCORE - 1) + + out = await company_quiz_service.grade_and_post("cquiz-1", "vague defence", "e1") + + assert out["passed"] is False + assert out["job_id"] is None + posted.assert_not_called() + assert recorded.call_args.args[3] is False + + +async def test_exactly_the_pass_score_passes(monkeypatch, company_attempt): + """The bar is inclusive - asserted against the constant, not a literal.""" + posted, _ = patch_grading(monkeypatch, company_attempt, PASS_SCORE) + + out = await company_quiz_service.grade_and_post("cquiz-1", "defence", "e1") + + assert out["passed"] is True + posted.assert_called_once() + + +async def test_the_defence_and_the_posting_both_reach_the_grader(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + grade = AsyncMock(return_value={"overall_score": 81, "breakdown": []}) + monkeypatch.setattr(company_quiz_service.gemini_client, "grade_company_answers", grade) + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "update_result", AsyncMock()) + monkeypatch.setattr(company_quiz_service.job_service, "post_job", AsyncMock(return_value="job-1")) + + await company_quiz_service.grade_and_post("cquiz-1", "on-call is me", "e1", 9.0) + + sent = grade.call_args.kwargs["followup"] + assert sent["answer"] == "on-call is me" + assert sent["targets_question_id"] == "cq2" + # The posting is graded alongside the answers, so a contradiction between the two + # is visible to the grader rather than invisible. + assert grade.call_args.args[0] == company_attempt["draft"] + + +async def test_grading_requires_the_followup_round(monkeypatch, company_attempt): + """Calling the API directly must not let a company skip the interrogation.""" + company_attempt["status"] = "generated" + company_attempt["followup"] = None + posted, _ = patch_grading(monkeypatch, company_attempt, 95) + + with pytest.raises(QuizClosed): + await company_quiz_service.grade_and_post("cquiz-1", "", "e1") + posted.assert_not_called() + + +async def test_a_graded_attempt_cannot_mint_a_second_posting(monkeypatch, company_attempt): + """A retried request returns what already happened rather than posting again.""" + company_attempt.update({ + "status": "graded", + "passed": True, + "job_id": "job-1", + "result": {"overall_score": 88.0, "breakdown": [{"question": "q", "score": 9, "note": "n"}]}, + }) + posted, recorded = patch_grading(monkeypatch, company_attempt, 100) + + out = await company_quiz_service.grade_and_post("cquiz-1", "again", "e1") + + assert out == {"quiz_id": "cquiz-1", "score": 88.0, "pass_score": PASS_SCORE, + "passed": True, "job_id": "job-1", + "feedback": [{"question": "q", "score": 9, "note": "n"}]} + posted.assert_not_called() + recorded.assert_not_called() + + +async def test_a_failed_attempt_cannot_be_regraded_into_a_pass(monkeypatch, company_attempt): + company_attempt.update({"status": "graded", "passed": False, "job_id": None, + "result": {"overall_score": 31.0, "breakdown": []}}) + posted, _ = patch_grading(monkeypatch, company_attempt, 99) + + out = await company_quiz_service.grade_and_post("cquiz-1", "better defence", "e1") + + assert out["passed"] is False and out["job_id"] is None + posted.assert_not_called() + + +# --- ownership ------------------------------------------------------------- + +async def test_another_account_cannot_grade_or_post(monkeypatch, company_attempt): + posted, _ = patch_grading(monkeypatch, company_attempt, 95) + + with pytest.raises(LookupError): + await company_quiz_service.grade_and_post("cquiz-1", "defence", "another-employer") + posted.assert_not_called() + + +async def test_a_missing_quiz_and_someone_elses_are_indistinguishable(monkeypatch, company_attempt): + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=None)) + with pytest.raises(LookupError) as missing: + await company_quiz_service.grade_and_post("nope", "d", "e1") + + monkeypatch.setattr(company_quiz_service.company_quiz_repository, "get_attempt", + AsyncMock(return_value=company_attempt)) + with pytest.raises(LookupError) as foreign: + await company_quiz_service.grade_and_post("cquiz-1", "d", "another-employer") + + assert str(missing.value) == str(foreign.value) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cbc5e9..148d65d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -69,6 +69,44 @@ response carries `"Anonymous Candidate"` and a null email, so an unrevealed name never reaches the browser at all. `features/profile/display.js` re-checks the flag as a second lock. +## Request flow example - posting a job + +A posting is the output of a defended quiz, not an input to one. There is no +ungated create route: `job_service.post_job()` has exactly one caller. + +``` +POST /api/v1/jobs/company-quiz/generate (employer token required) + -> api/v1/endpoints/jobs.py::generate_company_quiz() + -> services/company_quiz_service.py::create_quiz() + -> integrations/gemini_client.py::generate_company_quiz_questions() (Gemini) + -> repositories/company_quiz_repository.py::save_attempt() (Mongo, holds the draft) + +POST /api/v1/jobs/company-quiz/submit + -> services/company_quiz_service.py::start_followup() + -> services/quiz_service.py::pick_suspect_answer() (same ranking as the repo quiz) + -> integrations/gemini_client.py::generate_followup_question(framing=COMPANY_FRAMING) + +POST /api/v1/jobs/company-quiz/followup + -> services/company_quiz_service.py::grade_and_post() + -> integrations/gemini_client.py::grade_company_answers() (Gemini) + -> services/job_service.py::post_job() (Mongo, ONLY on a pass, + from the stored draft) + -> repositories/company_quiz_repository.py::update_result() + <- CompanyQuizResultResponse {score, passed, job_id} +``` + +Two properties the layering exists to protect: + +* The posting that goes live is the draft the questions were generated from, so a + company cannot defend an honest draft and publish a different one. +* `status: "graded"` is terminal and carries the job_id it produced, so a replayed + final call returns the stored outcome instead of minting a second posting. + +Company attempts live in their own collection rather than in `quiz_attempts` with a +discriminator: `quiz_repository.has_graded_attempt_scoring_at_least()` backs the +candidate reveal threshold and matches any graded attempt by user, so a shared +collection would let an employer quiz count as candidate comprehension. + ## Frontend structure ``` @@ -93,5 +131,5 @@ should ever import from a `features/` folder — dependencies point inward. interview rounds elsewhere) replaces the body of that one function — it reads from quiz + application outcomes and writes to a `scores` collection via a new `score_repository.py`. Nothing else in the funnel changes when it lands. -- `services/company_quiz_service.py` — same quiz engine, different prompt, - gates job posting creation. +- Company-side bug-hunt and community threads, per the README status list. + Nothing about them is designed yet. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index df66544..2dafe68 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,20 @@ import { useState } from "react"; import QuizPage from "./features/quiz/QuizPage"; import ProfilePage from "./features/profile/ProfilePage"; +import PostJobPage from "./features/jobs/PostJobPage"; import LoginPage from "./features/auth/LoginPage"; import RegisterPage from "./features/auth/RegisterPage"; import { isLoggedIn, logout } from "./features/auth/api"; +import { getRole } from "./shared/api/token"; -// Swap for a router (react-router) once the jobs/community pages exist. +// Swap for a router (react-router) once the community pages exist. export default function App() { // Seeded from storage so a reload does not log you out. const [authed, setAuthed] = useState(isLoggedIn); const [showRegister, setShowRegister] = useState(false); - const [tab, setTab] = useState("quiz"); + // Employers land on the posting flow; candidates on the repo quiz. Both are the + // thing that account type came here to do. + const [tab, setTab] = useState(() => (getRole() === "employer" ? "post" : "quiz")); if (!authed) { const Page = showRegister ? RegisterPage : LoginPage; @@ -18,28 +22,39 @@ export default function App() {

OneStop

Log in to take a repo quiz.

- setAuthed(true)} onSwitch={() => setShowRegister((v) => !v)} /> + { + setTab(getRole() === "employer" ? "post" : "quiz"); + setAuthed(true); + }} + onSwitch={() => setShowRegister((v) => !v)} + />
); } const onUnauthorized = () => setAuthed(false); + // Hiding the tab is a convenience, not the gate: every company-quiz route is + // employer-only on the backend, checked against the signed token. + const tabs = [ + ...(getRole() === "employer" ? [["post", "Post a Job"]] : []), + ["quiz", "Quiz"], + ["profile", "Profile"], + ]; + return ( <>
- - + {tabs.map(([id, label]) => ( + + ))}
- {tab === "quiz" ? ( - - ) : ( - // Re-fetched on every visit, so a reveal earned in the quiz tab shows up - // as soon as the candidate looks. - - )} + + {tab === "post" && } + {tab === "quiz" && } + {/* Re-fetched on every visit, so a reveal earned in the quiz tab shows up as + soon as the candidate looks. */} + {tab === "profile" && } ); } diff --git a/frontend/src/features/jobs/PostJobPage.jsx b/frontend/src/features/jobs/PostJobPage.jsx new file mode 100644 index 0000000..b827e87 --- /dev/null +++ b/frontend/src/features/jobs/PostJobPage.jsx @@ -0,0 +1,201 @@ +import { useEffect, useRef, useState } from "react"; +import JobDraftForm from "./components/JobDraftForm"; +import PostingResult from "./components/PostingResult"; +// The timed, paste-recording question card is shared with the candidate quiz on +// purpose: both sides of the market must run under the same clock and the same +// silent paste detection, and two copies would drift. If a third feature needs it, +// move it to shared/components rather than copying it again. +import QuestionCard from "../quiz/components/QuestionCard"; +import { + generateCompanyQuiz, + submitCompanyFollowUp, + submitCompanyQuiz, +} from "./api"; + +const EMPTY_DRAFT = { company_name: "", role_title: "", description: "", tech_stack: "" }; + +export default function PostJobPage({ onUnauthorized }) { + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [quiz, setQuiz] = useState(null); + const [answers, setAnswers] = useState({}); + const [followup, setFollowup] = useState(null); + const [followupAnswer, setFollowupAnswer] = useState(""); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [expired, setExpired] = useState(() => new Set()); + + // Live countdown per question, kept in a ref so ticking never re-renders the page. + const timeLeft = useRef({}); + // Paste signals per question. Also a ref — recording must stay invisible. + const inputSignal = useRef({}); + // Guards against the auto-submit firing twice (expiry racing a manual click). + const sent = useRef({ answers: false, followup: false }); + + const limit = quiz?.time_limit_seconds ?? 75; + + function markExpired(id) { + setExpired((prev) => (prev.has(id) ? prev : new Set(prev).add(id))); + } + + function reset() { + setQuiz(null); + setAnswers({}); + setFollowup(null); + setFollowupAnswer(""); + setResult(null); + setError(""); + setExpired(new Set()); + timeLeft.current = {}; + inputSignal.current = {}; + sent.current = { answers: false, followup: false }; + } + + async function handleGenerate() { + setLoading(true); + reset(); + try { + // The stack is typed as one line and split here; the backend stores the list + // it was given and asks which of those the hire actually touches. + const data = await generateCompanyQuiz({ + company_name: draft.company_name.trim(), + role_title: draft.role_title.trim(), + description: draft.description.trim(), + tech_stack: draft.tech_stack + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }); + setQuiz(data); + } catch (e) { + if (e.status === 401) return onUnauthorized?.(); + setError(e.message); + } finally { + setLoading(false); + } + } + + async function handleSubmit() { + if (sent.current.answers) return; + sent.current.answers = true; + setLoading(true); + setError(""); + try { + // Every question is sent, answered or not — a blank answer is itself a result. + const payload = quiz.questions.map((q) => ({ + question_id: q.id, + answer: answers[q.id] || "", + seconds_left: timeLeft.current[q.id] ?? null, + flagged_paste: inputSignal.current[q.id]?.flagged_paste ?? false, + paste_delta: inputSignal.current[q.id]?.paste_delta ?? 0, + })); + setFollowup(await submitCompanyQuiz(quiz.quiz_id, payload)); + } catch (e) { + if (e.status === 401) return onUnauthorized?.(); + setError(e.message); + sent.current.answers = false; + } finally { + setLoading(false); + } + } + + async function handleFollowUp() { + if (sent.current.followup) return; + sent.current.followup = true; + setLoading(true); + setError(""); + try { + const id = followup.followup.id; + setResult( + await submitCompanyFollowUp(quiz.quiz_id, followupAnswer, timeLeft.current[id] ?? null) + ); + } catch (e) { + if (e.status === 401) return onUnauthorized?.(); + setError(e.message); + sent.current.followup = false; + } finally { + setLoading(false); + } + } + + // When every question's clock has run out, submit whatever is there. + useEffect(() => { + if (quiz && !followup && !result && expired.size >= quiz.questions.length) { + handleSubmit(); + } + }, [expired, quiz, followup, result]); + + const followupExpired = followup && expired.has(followup.followup.id); + useEffect(() => { + if (followupExpired && !result) handleFollowUp(); + }, [followupExpired, result]); + + const drafting = !quiz && !result; + + return ( +
+

Post a Job

+

+ Write the posting, then answer for it. Postings go live only if they describe a + role you can actually account for. +

+ + {drafting && ( + + )} + + {error &&

{error}

} + + {quiz && !followup && !result && ( +
+

+ {limit}s per question · answers lock when the timer runs out · your draft is + held until this is graded +

+ {quiz.questions.map((q) => ( + (timeLeft.current[id] = s)} + onExpire={markExpired} + onInputSignal={(id, sig) => (inputSignal.current[id] = sig)} + onAnswerChange={(id, val) => setAnswers((prev) => ({ ...prev, [id]: val }))} + /> + ))} + +
+ )} + + {followup && !result && ( +
+

+ One follow-up on what you just wrote. Same {followup.time_limit_seconds}s, same + rules. +

+ (timeLeft.current[id] = s)} + onExpire={markExpired} + onAnswerChange={(_, val) => setFollowupAnswer(val)} + /> + +
+ )} + + {result && } +
+ ); +} diff --git a/frontend/src/features/jobs/api.js b/frontend/src/features/jobs/api.js index 4fca711..267beac 100644 --- a/frontend/src/features/jobs/api.js +++ b/frontend/src/features/jobs/api.js @@ -10,3 +10,33 @@ export function applyToJob(jobId, userId) { body: JSON.stringify({ job_id: jobId, user_id: userId }), }); } + +// --- company quiz --------------------------------------------------------- +// +// There is no createJob() here on purpose: the backend has no ungated route to +// call. A posting is created by the backend when submitFollowUp comes back with +// passed: true, from the draft sent to generateCompanyQuiz — which is why the +// draft is not sent again at the end. + +export function generateCompanyQuiz(draft) { + return request("/jobs/company-quiz/generate", { + method: "POST", + body: JSON.stringify(draft), + }); +} + +// Records answers and opens the follow-up round — does not grade or post. +export function submitCompanyQuiz(quizId, answers) { + return request("/jobs/company-quiz/submit", { + method: "POST", + body: JSON.stringify({ quiz_id: quizId, answers }), + }); +} + +// Final grading. Publishes the posting if it clears the bar. +export function submitCompanyFollowUp(quizId, answer, secondsLeft) { + return request("/jobs/company-quiz/followup", { + method: "POST", + body: JSON.stringify({ quiz_id: quizId, answer, seconds_left: secondsLeft }), + }); +} diff --git a/frontend/src/features/jobs/components/JobDraftForm.jsx b/frontend/src/features/jobs/components/JobDraftForm.jsx new file mode 100644 index 0000000..f14258c --- /dev/null +++ b/frontend/src/features/jobs/components/JobDraftForm.jsx @@ -0,0 +1,50 @@ +/** + * The draft posting. + * + * This is the last point at which the posting can be edited. Once the quiz is + * generated the backend holds this draft and publishes that copy on a pass, so + * the posting that goes live is the one the questions were written about. + */ +export default function JobDraftForm({ draft, onChange, onSubmit, loading }) { + const set = (field) => (e) => onChange({ ...draft, [field]: e.target.value }); + + const ready = + draft.company_name.trim() && draft.role_title.trim() && draft.description.trim(); + + return ( +
+ + + + + + +