diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0cde0ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.venv +__pycache__ +.pytest_cache +.ruff_cache +.mypy_cache +.langgraph_api +tests +docs +*.pyc +*.pyo +*.pyd +*.log +.env +.env.* +README.md +TEMPLATE_README.md +drawkit.xml diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml new file mode 100644 index 0000000..9751d7a --- /dev/null +++ b/.github/workflows/publish-ghcr.yml @@ -0,0 +1,55 @@ +name: Build and Publish Container + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set image name + id: image + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..47d29ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +You are working inside a LangGraph-based Python project. + +Create project planning documentation for a new AI service named "InterviewGraph". + +Your task: +Generate two markdown documents: + +1) README.md (high-level overview) +2) docs/PLAN.md (detailed product & technical planning document) + +The project name is: +InterviewGraph + +The core idea: +InterviewGraph takes a resume PDF as input and generates structured, difficulty-rated interview questions based on the resume content. + +==================================== +README.md REQUIREMENTS +==================================== + +README.md must include: + +1. Project Title +2. Short Description (3~5 lines) +3. Core Features (bullet points) +4. Architecture Overview (high-level explanation of LangGraph pipeline) +5. Example Flow (PDF โ†’ Questions) +6. Tech Stack (Python, LangGraph, FastAPI, LLM provider) +7. MVP Scope +8. Future Roadmap (short bullet list) +9. How to Run (placeholder instructions acceptable) + +Tone: + +- Professional +- Clear +- Developer-focused +- No marketing exaggeration + +==================================== +docs/PLAN.md REQUIREMENTS +==================================== + +PLAN.md must include structured sections: + +# 1. Project Vision + +- Why this project exists +- Target users + +# 2. User Scenarios + +- Primary scenario: resume upload โ†’ question generation +- Failure scenario: text extraction failure + +# 3. Functional Requirements + +Include: + +- PDF input handling +- Resume section parsing +- Signal extraction (skills, projects, keywords) +- Interview question generation (15 questions) +- Difficulty rating (1~5) +- Structured JSON output +- Markdown output + +# 4. Non-Functional Requirements + +Include: + +- Privacy considerations (no raw resume logging) +- LLM output schema validation +- Error handling & retry +- Stateless default design + +# 5. LangGraph Architecture Design + +List required nodes: + +- extract_text +- parse_sections +- extract_signals +- generate_questions +- rate_difficulty +- format_output + +Describe: + +- State design (raw_text, sections, signals, questions, markdown, errors) +- Linear pipeline for MVP +- Possible conditional branch for error handling + +# 6. Data Model Design + +Define structured interview question format: + +- id +- category (tech | project | system | deep-dive) +- difficulty (1~5) +- question +- expected_points +- followups + +# 7. MVP Definition of Done + +Clearly define what counts as completed MVP. + +# 8. Out of Scope (for MVP) + +Explicitly list: + +- OCR +- Vector DB / RAG +- Multi-agent system +- Mock interview answer evaluation + +# 9. Development Phases + +Phase 1: Schema & PDF extraction +Phase 2: Section parsing & signal extraction +Phase 3: Question generation & rating +Phase 4: API integration + +==================================== + +Formatting Rules: + +- Use clean markdown formatting +- Use clear headers +- No emojis +- No casual tone +- No unnecessary verbosity + +Do not generate code. +Only generate the two markdown documents. + +If docs/ directory does not exist, create it logically in output structure. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c306e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml uv.lock ./ +COPY casts ./casts +COPY app ./app + +RUN uv sync --frozen --no-dev --all-packages + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 3a9c6d1..41e6e27 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,107 @@ -# Act: Interview Graph +# ๐Ÿ™‹โ€โ™‚๏ธ InterviewGraph -A LangGraph-based Act project scaffolded with Act Operator. +InterviewGraph is a LangGraph-based service that generates structured interview questions from resume content. -## Quick Start +It accepts either resume text or a PDF file, extracts relevant signals, and returns 15 questions with difficulty ratings. + +The response includes both structured JSON and Markdown for interviewer-friendly review. + +> This project was developed through Act-Operator. +> +> [๐Ÿ˜บ Act-Operator Github](https://github.com/Proact0/act-operator) + +## What It Does + +- Accepts resume text or PDF input +- Parses resume sections (summary, skills, experience, projects, education) +- Extracts signals (skills, projects, keywords) +- Generates 15 interview questions +- Rates question difficulty (1-5) +- Formats output as JSON and Markdown + +## Architecture (MVP) + +Pipeline: + +`extract_text -> parse_sections -> extract_signals -> generate_questions -> rate_difficulty -> format_output` + +## Quick Start (Local) 1. Install dependencies: - ```bash - uv sync --all-packages - ``` -2. Run the development server: - ```bash - uv run langgraph dev - ``` +```bash +uv sync --all-packages +``` + +1. Run API server: + +```bash +uv run uvicorn app.main:app --reload +``` + +1. Open API docs: + +- `http://127.0.0.1:8000/docs` + +## API Usage + +- `POST /api/v1/interview-questions` for text input +- `POST /api/v1/interview-questions/upload` for PDF upload (multipart/form-data) + +Example JSON payload: + +```json +{ + "resume_text": "Summary ... Skills ... Projects ..." +} +``` + +## Container Usage + +Build image: + +```bash +docker build -t interviewgraph:local . +``` + +Run container: + +```bash +docker run --rm -p 8000:8000 interviewgraph:local +``` + +Open docs: + +- `http://127.0.0.1:8000/docs` + +## GHCR Publishing + +This repository includes `.github/workflows/publish-ghcr.yml`. + +- On push to `main`, the workflow builds and publishes to `ghcr.io//`. +- On push tag `v*`, it also publishes versioned tags. +- You can manually trigger publishing with `workflow_dispatch`. + +Pull from GHCR: + +```bash +docker pull ghcr.io//:latest +docker run --rm -p 8000:8000 ghcr.io//:latest +``` + +## Current Scope (MVP) + +- Text-extractable PDF support +- Resume-grounded question generation pipeline +- Structured error payloads + +## Out of Scope (MVP) -3. Access Studio UI: - - Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 - - API: http://127.0.0.1:2024 - - API Docs: http://127.0.0.1:2024/docs +- OCR for scanned PDFs +- Vector DB / RAG +- Multi-agent orchestration +- Mock interview answer scoring -For detailed documentation, see [TEMPLATE_README.md](TEMPLATE_README.md). +## License +Apache License 2.0 - see [LICENSE](https://www.apache.org/licenses/LICENSE-2.0) for details. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f8afa8f --- /dev/null +++ b/app/main.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from io import BytesIO +from typing import Annotated + +from fastapi import FastAPI, File, HTTPException, UploadFile +from pydantic import BaseModel, Field +from pypdf import PdfReader + +from casts.resume_ingestor.graph import resume_ingestor_graph + +app = FastAPI(title="InterviewGraph API", version="0.1.0") + + +class GenerateRequest(BaseModel): + resume_text: str | None = Field(default=None) + resume_path: str | None = Field(default=None) + + +class GenerateResponse(BaseModel): + questions: list[dict[str, object]] + markdown: str + errors: list[dict[str, object]] + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/api/v1/interview-questions", response_model=GenerateResponse) +def generate_interview_questions(payload: GenerateRequest) -> GenerateResponse: + if not payload.resume_text and not payload.resume_path: + raise HTTPException( + status_code=400, + detail="Provide either resume_text or resume_path.", + ) + + graph = resume_ingestor_graph() + result = graph.invoke(payload.model_dump(exclude_none=True)) + + return GenerateResponse( + questions=result.get("questions", []), + markdown=result.get("markdown", ""), + errors=result.get("errors", []), + ) + + +@app.post("/api/v1/interview-questions/upload", response_model=GenerateResponse) +async def generate_from_pdf(file: Annotated[UploadFile, File(...)]) -> GenerateResponse: + if file.content_type != "application/pdf" and not file.filename.lower().endswith( + ".pdf" + ): + raise HTTPException(status_code=400, detail="Only PDF uploads are supported.") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="Uploaded file is empty.") + + try: + reader = PdfReader(BytesIO(content)) + extracted_pages = [page.extract_text() or "" for page in reader.pages] + except Exception as exc: # pypdf raises library-specific parse errors + raise HTTPException(status_code=400, detail="Failed to parse PDF.") from exc + + resume_text = "\n".join(extracted_pages).strip() + if not resume_text: + raise HTTPException( + status_code=422, + detail="No extractable text found in uploaded PDF.", + ) + + graph = resume_ingestor_graph() + result = graph.invoke({"resume_text": resume_text}) + + return GenerateResponse( + questions=result.get("questions", []), + markdown=result.get("markdown", ""), + errors=result.get("errors", []), + ) diff --git a/casts/resume_ingestor/graph.py b/casts/resume_ingestor/graph.py index e6dc694..3792a01 100644 --- a/casts/resume_ingestor/graph.py +++ b/casts/resume_ingestor/graph.py @@ -10,7 +10,7 @@ 2. Connect nodes via ``builder.add_edge()`` or ``builder.add_conditional_edges()`` when branching. 3. Return the compiled graph to orchestrate LangGraph execution. -Official document URL: +Official document URL: - Graph API: https://docs.langchain.com/oss/python/langgraph/graph-api - StateGraph: https://docs.langchain.com/oss/python/langgraph/graph-api#stategraph - Nodes: https://docs.langchain.com/oss/python/langgraph/graph-api#nodes @@ -21,7 +21,14 @@ from langgraph.graph import END, START, StateGraph from casts.base_graph import BaseGraph -from casts.resume_ingestor.modules.nodes import SampleNode +from casts.resume_ingestor.modules.nodes import ( + ExtractSignalsNode, + ExtractTextNode, + FormatOutputNode, + GenerateQuestionsNode, + ParseSectionsNode, + RateDifficultyNode, +) from casts.resume_ingestor.modules.state import InputState, OutputState, State @@ -50,10 +57,19 @@ def build(self): self.state, input_schema=self.input, output_schema=self.output ) - # Register node as an INSTANCE so it returns a dict update, not the class object - builder.add_node("SampleNode", SampleNode()) - builder.add_edge(START, "SampleNode") - builder.add_edge("SampleNode", END) + builder.add_node("extract_text", ExtractTextNode()) + builder.add_node("parse_sections", ParseSectionsNode()) + builder.add_node("extract_signals", ExtractSignalsNode()) + builder.add_node("generate_questions", GenerateQuestionsNode()) + builder.add_node("rate_difficulty", RateDifficultyNode()) + builder.add_node("format_output", FormatOutputNode()) + builder.add_edge(START, "extract_text") + builder.add_edge("extract_text", "parse_sections") + builder.add_edge("parse_sections", "extract_signals") + builder.add_edge("extract_signals", "generate_questions") + builder.add_edge("generate_questions", "rate_difficulty") + builder.add_edge("rate_difficulty", "format_output") + builder.add_edge("format_output", END) graph = builder.compile() graph.name = self.name diff --git a/casts/resume_ingestor/modules/nodes.py b/casts/resume_ingestor/modules/nodes.py index 7e1f320..ffac8c3 100644 --- a/casts/resume_ingestor/modules/nodes.py +++ b/casts/resume_ingestor/modules/nodes.py @@ -1,65 +1,507 @@ -"""[Required] Node implementations for the Resume Ingestor graph. +"""Node implementations for the Resume Ingestor graph.""" -Guidelines: - - Derive each node from :class:`BaseNode` or :class:`AsyncBaseNode`. - - Implement :meth:`execute` to process state and return updates. - - Choose your node signature based on what you need: - * Simple: `def execute(self, state)` - Only needs state - * With config: `def execute(self, state, config)` - Needs thread_id, tags - * With runtime: `def execute(self, state, runtime)` - Needs store, stream - * Full: `def execute(self, state, config, runtime)` - Needs everything - - Use `self.log()` for debugging when `verbose=True`. +from __future__ import annotations -Official document URL: - - Nodes: https://docs.langchain.com/oss/python/langgraph/graph-api#nodes -""" +import re +from collections import Counter +from pathlib import Path -from langchain_core.messages import AIMessage +from casts.base_node import BaseNode -from casts.base_node import AsyncBaseNode, BaseNode +def _error_item( + node: str, code: str, message: str, retryable: bool +) -> dict[str, object]: + return { + "node": node, + "code": code, + "message": message, + "retryable": retryable, + } -class SampleNode(BaseNode): - """Simple sync node - only uses state. - Attributes: - name: Canonical name of the node (class name by default). - verbose: Flag indicating whether detailed logging is enabled. +class ExtractTextNode(BaseNode): + """Phase 1 node that loads raw resume text from input. + + MVP behavior: + - Uses `resume_text` directly when provided. + - Falls back to reading text from `resume_path`. + - Returns structured error metadata when extraction cannot proceed. """ - def __init__(self): - super().__init__() + def execute(self, state): + resume_text = state.get("resume_text") + resume_path = state.get("resume_path") + + if isinstance(resume_text, str) and resume_text.strip(): + return { + "raw_text": resume_text.strip(), + "sections": {}, + "signals": {"skills": [], "projects": [], "keywords": []}, + "questions": [], + "markdown": "", + "errors": [], + } + + if isinstance(resume_path, str) and resume_path.strip(): + path = Path(resume_path) + if not path.exists() or not path.is_file(): + return { + "raw_text": "", + "sections": {}, + "signals": {"skills": [], "projects": [], "keywords": []}, + "questions": [], + "markdown": "", + "errors": [ + _error_item( + node="extract_text", + code="FILE_NOT_FOUND", + message="Provided resume_path does not exist.", + retryable=False, + ) + ], + } + + try: + loaded_text = path.read_text(encoding="utf-8").strip() + except OSError: + return { + "raw_text": "", + "sections": {}, + "signals": {"skills": [], "projects": [], "keywords": []}, + "questions": [], + "markdown": "", + "errors": [ + _error_item( + node="extract_text", + code="READ_FAILED", + message="Failed to read resume_path as UTF-8 text.", + retryable=True, + ) + ], + } + + if not loaded_text: + return { + "raw_text": "", + "sections": {}, + "signals": {"skills": [], "projects": [], "keywords": []}, + "questions": [], + "markdown": "", + "errors": [ + _error_item( + node="extract_text", + code="EMPTY_TEXT", + message="No text content was extracted from resume input.", + retryable=True, + ) + ], + } + + return { + "raw_text": loaded_text, + "sections": {}, + "signals": {"skills": [], "projects": [], "keywords": []}, + "questions": [], + "markdown": "", + "errors": [], + } + + return { + "raw_text": "", + "sections": {}, + "signals": {"skills": [], "projects": [], "keywords": []}, + "questions": [], + "markdown": "", + "errors": [ + _error_item( + node="extract_text", + code="MISSING_INPUT", + message="Provide either resume_text or resume_path.", + retryable=False, + ) + ], + } + + +class ParseSectionsNode(BaseNode): + """Phase 2 node that maps raw resume text to logical sections.""" + + _HEADER_MAP: dict[str, str] = { + "summary": "summary", + "profile": "summary", + "about": "summary", + "skills": "skills", + "technical skills": "skills", + "experience": "experience", + "work experience": "experience", + "professional experience": "experience", + "projects": "projects", + "project": "projects", + "education": "education", + "academic background": "education", + } + + _EXPECTED_SECTIONS: tuple[str, ...] = ( + "summary", + "skills", + "experience", + "projects", + "education", + ) def execute(self, state): - """Execute the sample node. + raw_text = state.get("raw_text") + existing_errors = list(state.get("errors", [])) - Args: - state: Current graph state. + if existing_errors: + return {"sections": {}} - Returns: - dict: State updates (must be a dict) - """ - return {"messages": [AIMessage(content="Welcome to the Act! by Sync Node")]} + if not isinstance(raw_text, str) or not raw_text.strip(): + return { + "sections": {}, + "errors": existing_errors + + [ + _error_item( + node="parse_sections", + code="MISSING_RAW_TEXT", + message="Cannot parse sections without raw_text.", + retryable=False, + ) + ], + } + section_buffers: dict[str, list[str]] = { + key: [] for key in self._EXPECTED_SECTIONS + } + current_section = "summary" -class AsyncSampleNode(AsyncBaseNode): - """Simple async node - only uses state. + for line in raw_text.splitlines(): + cleaned = line.strip() + if not cleaned: + continue - Attributes: - name: Canonical name of the node (class name by default). - verbose: Flag indicating whether detailed logging is enabled. - """ + normalized_header = re.sub(r"[:\-]+$", "", cleaned).strip().lower() + if normalized_header in self._HEADER_MAP: + current_section = self._HEADER_MAP[normalized_header] + continue + + section_buffers[current_section].append(cleaned) + + parsed_sections = { + name: "\n".join(lines).strip() + for name, lines in section_buffers.items() + if lines + } + + return {"sections": parsed_sections} + + +class ExtractSignalsNode(BaseNode): + """Phase 2 node that extracts skills, projects, and keywords from sections.""" + + _STOPWORDS: set[str] = { + "a", + "an", + "and", + "as", + "at", + "be", + "by", + "for", + "from", + "in", + "into", + "is", + "of", + "on", + "or", + "the", + "to", + "with", + "using", + "years", + "year", + "experience", + } + + def execute(self, state): + existing_errors = list(state.get("errors", [])) + if existing_errors: + return {"signals": {"skills": [], "projects": [], "keywords": []}} + + sections = state.get("sections") + if not isinstance(sections, dict) or not sections: + return { + "signals": {"skills": [], "projects": [], "keywords": []}, + "errors": existing_errors + + [ + _error_item( + node="extract_signals", + code="MISSING_SECTIONS", + message="Cannot extract signals without parsed sections.", + retryable=False, + ) + ], + } + + skills = self._extract_skills(sections.get("skills", "")) + projects = self._extract_projects(sections.get("projects", "")) + keywords = self._extract_keywords(sections) + + return { + "signals": { + "skills": skills, + "projects": projects, + "keywords": keywords, + } + } + + def _extract_skills(self, text: str) -> list[str]: + if not isinstance(text, str) or not text.strip(): + return [] + normalized = text.replace("\n", ",") + candidates = [part.strip(" -\t") for part in normalized.split(",")] + return self._dedupe([token for token in candidates if token]) + + def _extract_projects(self, text: str) -> list[str]: + if not isinstance(text, str) or not text.strip(): + return [] + lines = [line.strip(" -*\t") for line in text.splitlines()] + candidates = [line for line in lines if line] + return self._dedupe(candidates) + + def _extract_keywords(self, sections: dict[str, object]) -> list[str]: + corpus = " ".join( + str(value) for value in sections.values() if isinstance(value, str) + ).lower() + tokens = re.findall(r"[a-zA-Z][a-zA-Z0-9+#.-]{1,}", corpus) + filtered = [token for token in tokens if token not in self._STOPWORDS] + + ranked = Counter(filtered) + # Deterministic ordering: highest frequency first, then lexical. + sorted_tokens = sorted(ranked.items(), key=lambda item: (-item[1], item[0])) + return [token for token, _count in sorted_tokens[:12]] + + def _dedupe(self, values: list[str]) -> list[str]: + seen: set[str] = set() + deduped: list[str] = [] + for value in values: + key = value.lower() + if key in seen: + continue + seen.add(key) + deduped.append(value) + return deduped + + +class GenerateQuestionsNode(BaseNode): + """Phase 3 node that creates 15 structured interview questions.""" + + _CATEGORIES: tuple[str, ...] = ("tech", "project", "system", "deep-dive") + + def execute(self, state): + existing_errors = list(state.get("errors", [])) + if existing_errors: + return {"questions": []} + + signals = state.get("signals") + if not isinstance(signals, dict): + return { + "questions": [], + "errors": existing_errors + + [ + _error_item( + node="generate_questions", + code="MISSING_SIGNALS", + message="Cannot generate questions without extracted signals.", + retryable=False, + ) + ], + } + + skills = self._as_list(signals.get("skills")) + projects = self._as_list(signals.get("projects")) + keywords = self._as_list(signals.get("keywords")) + + prompts = self._build_prompt_seeds(skills, projects, keywords) + questions = [ + self._make_question(index=idx + 1, seed=seed) + for idx, seed in enumerate(prompts[:15]) + ] + return {"questions": questions} + + def _as_list(self, value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str) and item.strip()] + + def _build_prompt_seeds( + self, skills: list[str], projects: list[str], keywords: list[str] + ) -> list[tuple[str, str]]: + seeds: list[tuple[str, str]] = [] + + for skill in skills: + seeds.append(("tech", f"{skill}")) + seeds.append(("system", f"{skill}")) + + for project in projects: + seeds.append(("project", f"{project}")) + seeds.append(("deep-dive", f"{project}")) + + for keyword in keywords: + seeds.append(("deep-dive", f"{keyword}")) + + if not seeds: + seeds = [ + ("tech", "core backend skills"), + ("project", "recent project ownership"), + ("system", "service architecture"), + ("deep-dive", "engineering trade-offs"), + ] + + while len(seeds) < 15: + seeds.extend(seeds) + return seeds + + def _make_question(self, index: int, seed: tuple[str, str]) -> dict[str, object]: + category, topic = seed + prompt_map = { + "tech": f"How have you applied {topic} in production, and what limitations did you face?", + "project": f"Walk through the project '{topic}' and explain your personal contribution.", + "system": f"If you redesign a system centered on {topic}, what architecture would you choose and why?", + "deep-dive": f"Describe a hard technical decision involving {topic} and how you validated it.", + } + question_text = prompt_map.get( + category, + f"Explain your practical experience with {topic} and key outcomes.", + ) + + return { + "id": f"q{index:02d}", + "category": category if category in self._CATEGORIES else "tech", + "difficulty": 0, + "question": question_text, + "expected_points": [ + "Problem context and constraints", + "Technical choices and trade-offs", + "Measured outcome and lessons learned", + ], + "followups": [ + "What would you do differently now?", + "How did you measure success for this decision?", + ], + } + + +class RateDifficultyNode(BaseNode): + """Phase 3 node that assigns 1-5 difficulty ratings to questions.""" + + _CATEGORY_BASE: dict[str, int] = { + "tech": 2, + "project": 3, + "system": 4, + "deep-dive": 4, + } + + def execute(self, state): + existing_errors = list(state.get("errors", [])) + if existing_errors: + return {"questions": []} + + questions = state.get("questions") + if not isinstance(questions, list) or not questions: + return { + "questions": [], + "errors": existing_errors + + [ + _error_item( + node="rate_difficulty", + code="MISSING_QUESTIONS", + message="Cannot rate difficulty without generated questions.", + retryable=False, + ) + ], + } + + rated_questions: list[dict[str, object]] = [] + for index, question in enumerate(questions): + if not isinstance(question, dict): + continue + + category = str(question.get("category", "tech")) + base = self._CATEGORY_BASE.get(category, 3) + variation = index % 3 + difficulty = max(1, min(5, base - 1 + variation)) + + updated = dict(question) + updated["difficulty"] = difficulty + rated_questions.append(updated) + + return {"questions": rated_questions} + + +class FormatOutputNode(BaseNode): + """Final node that renders markdown from structured questions.""" + + def execute(self, state): + questions = state.get("questions") + errors = state.get("errors") + + if not isinstance(errors, list): + errors = [] + + if not isinstance(questions, list): + return { + "questions": [], + "markdown": "", + "errors": errors + + [ + _error_item( + node="format_output", + code="INVALID_QUESTIONS", + message="Questions payload is not a list.", + retryable=False, + ) + ], + } + + markdown = self._render_markdown(questions) + return {"questions": questions, "markdown": markdown} + + def _render_markdown(self, questions: list[object]) -> str: + lines: list[str] = ["# Interview Questions", ""] + + for item in questions: + if not isinstance(item, dict): + continue + + question_id = str(item.get("id", "")) + category = str(item.get("category", "tech")) + difficulty = item.get("difficulty", "N/A") + question_text = str(item.get("question", "")) + expected_points = item.get("expected_points", []) + followups = item.get("followups", []) + + lines.append(f"## {question_id} [{category}] (Difficulty: {difficulty})") + lines.append(question_text) - def __init__(self): - super().__init__() + lines.append("") + lines.append("Expected points:") + if isinstance(expected_points, list) and expected_points: + for point in expected_points: + lines.append(f"- {point}") + else: + lines.append("- N/A") - async def execute(self, state): - """Execute the sample node. + lines.append("") + lines.append("Follow-ups:") + if isinstance(followups, list) and followups: + for followup in followups: + lines.append(f"- {followup}") + else: + lines.append("- N/A") - Args: - state: Current graph state. + lines.append("") - Returns: - dict: State updates (must be a dict) - """ - return {"messages": [AIMessage(content="Welcome to the Act! by Async Node")]} + return "\n".join(lines).rstrip() diff --git a/casts/resume_ingestor/modules/state.py b/casts/resume_ingestor/modules/state.py index 5e42491..4ca42db 100644 --- a/casts/resume_ingestor/modules/state.py +++ b/casts/resume_ingestor/modules/state.py @@ -1,47 +1,74 @@ -"""[Required] State definition shared across sam graphs. - -Guidelines: - - Create TypedDict classes for input, output, overall state, and any other state you need. - - Use `MessagesState` from langgraph.graph or use `Annotated[list[AnyMessage], add_messages]` for messages to enable proper message merging. - - When inheriting from MessagesState, do not override the messages field. +"""State schemas for the Resume Ingestor graph. Official document URL: - State: https://docs.langchain.com/oss/python/langgraph/graph-api#state """ from langgraph.graph import MessagesState -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict -class InputState(TypedDict): - """Input state container. +class ResumeSectionMap(TypedDict): + """Parsed resume sections used by downstream nodes.""" - Attributes: - query: User query - """ + summary: NotRequired[str] + skills: NotRequired[str] + experience: NotRequired[str] + projects: NotRequired[str] + education: NotRequired[str] - query: str +class ResumeSignals(TypedDict): + """Normalized interview signals extracted from resume content.""" -class OutputState(TypedDict): - """Output state container. + skills: list[str] + projects: list[str] + keywords: list[str] - Attributes: - messages: Additional messages (inherited from MessagesState) - """ - result: str +class InterviewQuestion(TypedDict): + """Structured question model for InterviewGraph output.""" + id: str + category: str + difficulty: int + question: str + expected_points: list[str] + followups: list[str] -class State(MessagesState): - """Graph state container. - Attributes: - query: User query - messages: Additional messages (inherited from MessagesState) - """ +class ErrorItem(TypedDict): + """Node-level error payload for safe API responses.""" + + node: str + code: str + message: str + retryable: bool + + +class InputState(TypedDict): + """Input schema for graph invocation.""" + + resume_path: NotRequired[str] + resume_text: NotRequired[str] + + +class OutputState(TypedDict): + """Output schema for graph responses.""" + + questions: list[InterviewQuestion] + markdown: str + errors: list[ErrorItem] + + +class State(MessagesState): + """Full graph state passed between nodes.""" - # messages field is inherited from MessagesState - # It is defined as: messages: Annotated[list[AnyMessage], add_messages] - result: str - query: str + resume_path: str | None + resume_text: str | None + raw_text: str + sections: ResumeSectionMap + signals: ResumeSignals + questions: list[InterviewQuestion] + markdown: str + errors: list[ErrorItem] diff --git a/pyproject.toml b/pyproject.toml index 78817a8..e343f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,12 @@ description = "Interview Graph powered by Act Operator" readme = "README.md" requires-python = ">=3.11,<3.14" dependencies = [ + "fastapi>=0.116.1", "langchain>=1.0.0", "langgraph>=1.0.0", + "pypdf>=6.0.0", + "python-multipart>=0.0.20", + "uvicorn>=0.35.0", ] [dependency-groups] @@ -65,4 +69,4 @@ lint.ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["B905"] \ No newline at end of file +"tests/*" = ["B905"] diff --git a/tests/api_tests/test_api.py b/tests/api_tests/test_api.py new file mode 100644 index 0000000..739c5de --- /dev/null +++ b/tests/api_tests/test_api.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health() -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_generate_interview_questions_from_text() -> None: + response = client.post( + "/api/v1/interview-questions", + json={ + "resume_text": ( + "Summary\n" + "Backend engineer\n" + "Skills\n" + "Python, FastAPI, AWS\n" + "Projects\n" + "Built interview tooling\n" + ) + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert len(payload["questions"]) == 15 + assert "# Interview Questions" in payload["markdown"] + assert payload["errors"] == [] + + +def test_generate_interview_questions_requires_input() -> None: + response = client.post("/api/v1/interview-questions", json={}) + assert response.status_code == 400 + + +def test_upload_requires_pdf() -> None: + response = client.post( + "/api/v1/interview-questions/upload", + files={"file": ("resume.txt", b"not pdf", "text/plain")}, + ) + assert response.status_code == 400 diff --git a/tests/cast_tests/resume_ingestor_test.py b/tests/cast_tests/resume_ingestor_test.py index e8d4a4a..3a938a9 100644 --- a/tests/cast_tests/resume_ingestor_test.py +++ b/tests/cast_tests/resume_ingestor_test.py @@ -8,12 +8,45 @@ from casts.resume_ingestor.graph import resume_ingestor_graph -def test_graph_produces_message() -> None: +def test_graph_extracts_text_from_resume_text() -> None: graph = resume_ingestor_graph() + result = graph.invoke( + {"resume_text": "Senior Backend Engineer with Python and AWS"} + ) - # ์ตœ์†Œ ์ƒํƒœ๋กœ ๊ทธ๋ž˜ํ”„ ์‹คํ–‰ - result = graph.invoke({"query": "I'm joining Act"}) + assert len(result["questions"]) == 15 + assert all(1 <= q["difficulty"] <= 5 for q in result["questions"]) + assert "# Interview Questions" in result["markdown"] + assert result["errors"] == [] - # SampleNode๊ฐ€ message ํ‚ค๋ฅผ ์ƒ์„ฑํ•˜๋Š”์ง€ ํ™•์ธ - assert "messages" in result - assert result["messages"] == "Welcome to the Act!" + +def test_graph_returns_error_when_input_missing() -> None: + graph = resume_ingestor_graph() + result = graph.invoke({}) + + assert result["questions"] == [] + assert result["markdown"] == "# Interview Questions" + assert len(result["errors"]) == 1 + assert result["errors"][0]["code"] == "MISSING_INPUT" + + +def test_graph_pipeline_completes_with_sectioned_resume_text() -> None: + graph = resume_ingestor_graph() + result = graph.invoke( + { + "resume_text": ( + "Summary\n" + "Backend engineer\n" + "Skills\n" + "Python, FastAPI, AWS\n" + "Projects\n" + "Built interview tooling\n" + ) + } + ) + + assert len(result["questions"]) == 15 + assert result["questions"][0]["id"] == "q01" + assert all(1 <= q["difficulty"] <= 5 for q in result["questions"]) + assert "# Interview Questions" in result["markdown"] + assert result["errors"] == [] diff --git a/tests/node_tests/test_node.py b/tests/node_tests/test_node.py index c8d953d..8bdfbd8 100644 --- a/tests/node_tests/test_node.py +++ b/tests/node_tests/test_node.py @@ -1,19 +1,153 @@ -"""Test the nodes for the Sam graph. - -Official document URL: https://docs.langchain.com/oss/python/langgraph/test""" +"""Test nodes for the Resume Ingestor graph.""" from __future__ import annotations -from casts.sam.modules.nodes import SampleNode, AsyncSampleNode +from casts.resume_ingestor.modules.nodes import ( + ExtractSignalsNode, + ExtractTextNode, + FormatOutputNode, + GenerateQuestionsNode, + ParseSectionsNode, + RateDifficultyNode, +) + + +def test_extract_text_node_uses_inline_resume_text() -> None: + node = ExtractTextNode() + result = node({"resume_text": "Python backend engineer"}) + + assert result["raw_text"] == "Python backend engineer" + assert result["errors"] == [] + + +def test_parse_sections_node_splits_by_headers() -> None: + node = ParseSectionsNode() + result = node( + { + "raw_text": ( + "Summary\n" + "Backend engineer with 6 years of experience\n" + "Skills\n" + "Python, FastAPI, AWS\n" + "Projects\n" + "Built interview automation service\n" + ), + "errors": [], + } + ) + + assert "sections" in result + assert ( + result["sections"]["summary"] == "Backend engineer with 6 years of experience" + ) + assert result["sections"]["skills"] == "Python, FastAPI, AWS" + assert result["sections"]["projects"] == "Built interview automation service" + + +def test_parse_sections_node_returns_error_without_raw_text() -> None: + node = ParseSectionsNode() + result = node({"raw_text": "", "errors": []}) + + assert len(result["errors"]) == 1 + assert result["errors"][0]["code"] == "MISSING_RAW_TEXT" + + +def test_extract_signals_node_extracts_skills_projects_keywords() -> None: + node = ExtractSignalsNode() + result = node( + { + "sections": { + "summary": "Backend engineer focusing on payment platforms", + "skills": "Python, FastAPI, AWS, Docker", + "projects": ( + "Built fraud detection service using FastAPI\n" + "Designed event-driven payment pipeline" + ), + }, + "errors": [], + } + ) + + assert result["signals"]["skills"] == ["Python", "FastAPI", "AWS", "Docker"] + assert result["signals"]["projects"] == [ + "Built fraud detection service using FastAPI", + "Designed event-driven payment pipeline", + ] + assert "fastapi" in result["signals"]["keywords"] + assert "payment" in result["signals"]["keywords"] + + +def test_extract_signals_node_returns_error_without_sections() -> None: + node = ExtractSignalsNode() + result = node({"sections": {}, "errors": []}) + + assert result["signals"] == {"skills": [], "projects": [], "keywords": []} + assert len(result["errors"]) == 1 + assert result["errors"][0]["code"] == "MISSING_SECTIONS" + + +def test_generate_questions_node_creates_15_structured_items() -> None: + node = GenerateQuestionsNode() + result = node( + { + "signals": { + "skills": ["Python", "FastAPI", "AWS"], + "projects": ["Built interview graph service"], + "keywords": ["backend", "api", "scalability"], + }, + "errors": [], + } + ) + + assert len(result["questions"]) == 15 + first = result["questions"][0] + assert first["id"] == "q01" + assert first["category"] in {"tech", "project", "system", "deep-dive"} + assert first["difficulty"] == 0 + assert isinstance(first["question"], str) + assert len(first["expected_points"]) >= 1 + assert len(first["followups"]) >= 1 + + +def test_rate_difficulty_node_assigns_1_to_5_scale() -> None: + node = RateDifficultyNode() + generated_questions = [ + { + "id": f"q{idx:02d}", + "category": "tech" if idx % 2 == 0 else "deep-dive", + "difficulty": 0, + "question": f"Question {idx}", + "expected_points": ["Point A"], + "followups": ["Follow-up A"], + } + for idx in range(1, 16) + ] + result = node({"questions": generated_questions, "errors": []}) + assert len(result["questions"]) == 15 + difficulties = [q["difficulty"] for q in result["questions"]] + assert all(isinstance(d, int) and 1 <= d <= 5 for d in difficulties) -def test_base_node_calls_execute() -> None: - node = SampleNode(verbose=True) - result = node() - assert result == {"message": "Welcome to the Act!"} +def test_format_output_node_renders_markdown() -> None: + node = FormatOutputNode() + result = node( + { + "questions": [ + { + "id": "q01", + "category": "tech", + "difficulty": 3, + "question": "How did you design your API error model?", + "expected_points": ["Consistency", "Client usability"], + "followups": ["How did you version errors?"], + } + ], + "errors": [], + } + ) -async def test_async_base_node_calls_execute() -> None: - node = AsyncSampleNode(verbose=True) - result = await node() - assert result == {"message": "Welcome to the Act!"} + assert result["questions"][0]["id"] == "q01" + assert "# Interview Questions" in result["markdown"] + assert "Difficulty: 3" in result["markdown"] + assert "Expected points:" in result["markdown"] diff --git a/uv.lock b/uv.lock index a29fd12..1b3e714 100644 --- a/uv.lock +++ b/uv.lock @@ -338,6 +338,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "fastapi" +version = "0.129.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/cc/1b0d90ed759ff8c9dbc4800de7475d4e9256a81b97b45bd05a1affcb350a/fastapi-0.129.2.tar.gz", hash = "sha256:e2b3637a2b47856e704dbd9a3a09393f6df48e8b9cb6c7a3e26ba44d2053f9ab", size = 368211, upload-time = "2026-02-21T17:25:49.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d0/a89a640308016c7fff8d2a47b86cc03ee7cca780b5079d0b69f466f9e1a9/fastapi-0.129.2-py3-none-any.whl", hash = "sha256:e21d9f6e8db376655187905ad0145edd6f6a4e5f2bff241c4efb8a0bffd6a540", size = 103227, upload-time = "2026-02-21T17:25:47.745Z" }, +] + [[package]] name = "filelock" version = "3.24.3" @@ -543,8 +559,12 @@ name = "interview-graph" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "fastapi" }, { name = "langchain" }, { name = "langgraph" }, + { name = "pypdf" }, + { name = "python-multipart" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -566,8 +586,12 @@ test = [ [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.116.1" }, { name = "langchain", specifier = ">=1.0.0" }, { name = "langgraph", specifier = ">=1.0.0" }, + { name = "pypdf", specifier = ">=6.0.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "uvicorn", specifier = ">=0.35.0" }, ] [package.metadata.requires-dev] @@ -1262,6 +1286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] +[[package]] +name = "pypdf" +version = "6.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b2/335465d6cff28a772ace8a58beb168f125c2e1d8f7a31527da180f4d89a1/pypdf-6.7.2.tar.gz", hash = "sha256:82a1a48de500ceea59a52a7d979f5095927ef802e4e4fac25ab862a73468acbb", size = 5302986, upload-time = "2026-02-22T11:33:30.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/df/38b06d6e74646a4281856920a11efb431559bdeb643bf1e192bff5e29082/pypdf-6.7.2-py3-none-any.whl", hash = "sha256:331b63cd66f63138f152a700565b3e0cebdf4ec8bec3b7594b2522418782f1f3", size = 331245, upload-time = "2026-02-22T11:33:29.204Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1299,6 +1332,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + [[package]] name = "python-slugify" version = "8.0.4"