-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add CLI for resume analysis via levelup command #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import json | ||
| import re | ||
| from pathlib import Path | ||
| from typing import Optional | ||
|
|
||
| import google.generativeai as genai | ||
| import pdfplumber | ||
| import typer | ||
|
|
||
| from levelup import config | ||
| from levelup.prompts import get_resume_analysis_prompt | ||
|
|
||
| app = typer.Typer(name="levelup", help="AI-powered CV analysis from the command line.") | ||
|
|
||
| LANGUAGES = [ | ||
| "Czech", | ||
| "Danish", | ||
| "Dutch", | ||
| "English", | ||
| "Finnish", | ||
| "French", | ||
| "German", | ||
| "Greek", | ||
| "Italian", | ||
| "Kurdish (Kurmanji)", | ||
| "Polish", | ||
| "Portuguese", | ||
| "Russian", | ||
| "Spanish", | ||
| "Swedish", | ||
| "Turkish", | ||
| "Ukrainian", | ||
| ] | ||
|
|
||
|
|
||
| def _extract_text(pdf_path: Path) -> str: | ||
| with pdfplumber.open(pdf_path) as pdf: | ||
| parts = [page.extract_text() or "" for page in pdf.pages] | ||
| return "\n".join(parts).strip() | ||
|
|
||
|
|
||
| def _extract_json(raw: str) -> dict | None: | ||
| fence = re.search(r"```(?:json)?\s*({[\s\S]*?})\s*```", raw, re.IGNORECASE) | ||
| if fence: | ||
| block = fence.group(1) | ||
| else: | ||
| match = re.search(r"\{[\s\S]*\}", raw) | ||
| block = match.group(0) if match else None | ||
| if not block: | ||
| return None | ||
| try: | ||
| result = json.loads(block) | ||
| return result if isinstance(result, dict) else None | ||
| except json.JSONDecodeError: | ||
| return None | ||
|
|
||
|
|
||
| @app.command() | ||
| def analyze( | ||
| resume: Path = typer.Argument(..., help="Path to the PDF resume file."), | ||
| language: str = typer.Option( | ||
| "English", "--language", "-l", help="Report language." | ||
| ), | ||
| role: Optional[str] = typer.Option( | ||
| None, "--role", "-r", help="Target role for the analysis." | ||
| ), | ||
| output: Optional[Path] = typer.Option( | ||
| None, "--output", "-o", help="Save JSON output to a file." | ||
| ), | ||
| ) -> None: | ||
| if not resume.exists(): | ||
| typer.echo(f"Error: file not found: {resume}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| if language not in LANGUAGES: | ||
| typer.echo( | ||
| f"Error: unsupported language '{language}'.\nAvailable: {', '.join(LANGUAGES)}", | ||
| err=True, | ||
| ) | ||
| raise typer.Exit(1) | ||
|
|
||
| if not config.GEMINI_API_KEY: | ||
| typer.echo("Error: GEMINI_API_KEY is not set.", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| typer.echo("Extracting text from PDF...") | ||
| try: | ||
| text = _extract_text(resume) | ||
| except Exception as e: | ||
| typer.echo(f"Error reading PDF: {e}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| if not text: | ||
| typer.echo("Error: could not extract text from the PDF.", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| typer.echo("Analyzing resume...") | ||
| genai.configure(api_key=config.GEMINI_API_KEY) | ||
| model = genai.GenerativeModel("gemini-2.0-flash-lite") | ||
| prompt = get_resume_analysis_prompt(text, language, role) | ||
|
|
||
| try: | ||
| response = model.generate_content(prompt) | ||
| raw = (response.text or "").strip() | ||
| except Exception as e: | ||
| typer.echo(f"Error calling LLM: {e}", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| result = _extract_json(raw) | ||
| if not result: | ||
| typer.echo("Error: could not parse the analysis response.", err=True) | ||
| raise typer.Exit(1) | ||
|
|
||
| output_json = json.dumps(result, indent=2, ensure_ascii=False) | ||
|
|
||
| if output: | ||
| output.write_text(output_json, encoding="utf-8") | ||
| typer.echo(f"Results saved to {output}") | ||
| else: | ||
| typer.echo(output_json) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| app() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Greedy regex may over-match if trailing text contains braces.
The pattern
\{[\s\S]*\}matches from the first{to the last}in the string. If the LLM response includes trailing text with braces (e.g.,{"score": 90} Note: see {docs}), this captures invalid content and causes parsing to fail or return corrupted data.Consider using a balanced-brace approach or iteratively trying to parse progressively smaller substrings:
Proposed fix: iterative JSON boundary detection
def _extract_json(raw: str) -> dict | None: fence = re.search(r"```(?:json)?\s*({[\s\S]*?})\s*```", raw, re.IGNORECASE) if fence: block = fence.group(1) else: - match = re.search(r"\{[\s\S]*\}", raw) - block = match.group(0) if match else None + # Find first '{' and try parsing from there to each subsequent '}' + start = raw.find("{") + if start == -1: + return None + block = None + for i, ch in enumerate(raw[start:], start): + if ch == "}": + candidate = raw[start : i + 1] + try: + result = json.loads(candidate) + if isinstance(result, dict): + return result + except json.JSONDecodeError: + continue + return None if not block: return None try:🤖 Prompt for AI Agents