Skip to content

LM Studio support, PDF parser improvements, score sorting bug fix - #15

Merged
vesaias merged 9 commits into
vesaias:mainfrom
brycecollison:main
Sep 16, 2026
Merged

vesaias merged 9 commits into
vesaias:mainfrom
brycecollison:main

Conversation

@brycecollison

Copy link
Copy Markdown
Contributor

PR: LM Studio support + PDF resume parser improvements + score sort fix

Branch: main (HEAD) → origin/main (no divergence yet)
Uncommitted changes: 14 files, ~94 lines added / removed


Summary

  • LM Studio is now a first-class local inference provider alongside Ollama.
  • The PDF resume parser's LLM call token budget was increased from 2000 to 8000 (full resumes routinely exceed 2k tokens on verbose local models).
  • A robust JSON extraction helper (_parse_model_json) guards against truncated/malformed model replies.
  • New tests cover LM Studio dispatch, cost calculation, and R4 contract validation.
  • The "sort by score" query now correctly places newly-scored jobs (empty dict {}) at the bottom instead of leaving them in an undefined position.

Changes

Backend — LLM Client & Cost (backend/analyzer/)

File Change
llm_client.py Added lmstudio provider: routes to _call_openai with base_url=os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1"). Sets reasoning_effort="none" in the extra body (thinking models like Qwen3 otherwise burn their entire budget on reasoning and return empty content). Also added extra_body param to _call_openai.
llm_cost.py Added "lmstudio" to FREE_PROVIDERS set; updated docstring.
cv_scorer.py Added type guards (isinstance(..., dict)) in _flatten_resume for experience/education/projects/publications loops — safely handles non-dict entries (e.g. malformed schema fields).

Backend — Location Handler (backend/analyzer/location.py)

  • Added regex _CODE_COUNTRY = re.compile(r"^([A-Za-z]{2})\s+(USA|US|United States)$", re.I) to split tokens like "Cambridge, MA USA" into ["MA", "USA"] so each half can be read independently (prevents misreading the state code+country as a city name).

Backend — Resumes API (backend/api/routes_resumes.py)

  • Added _parse_model_json(raw_response) helper: finds the first balanced {...} in the raw model reply using _first_json_object from routes_autofill, falling back to stripping markdown code fences and parsing the whole response. Raises a clear 422 on invalid JSON.
  • Increased PDF parse LLM call's max_tokens from 20008000 (full resumes routinely exceed 2k tokens).

Backend — Jobs API (backend/api/routes_jobs.py)

  • Fixed the "sort by score" query: previously used .nullslast() which only handles SQL NULL. Newly scored jobs have cv_scores = {} (empty dict), so they weren't recognized as "no score". Now uses a compound order-by that treats both NULL and {} as bottom-of-list:
q.order_by(
    (Job.cv_scores.is_(None) | Job.cv_scores == {}).label("has_score"),
    desc(Job.best_cv_score).nullslast()
)

Frontend — Settings UI (frontend/src/classic/Settings.jsx, frontend/src/screens/Settings.jsx)

  • Added LM Studio option to all LLM provider dropdowns (Inference, Completion, Embedding) with description: "Local inference server running LM Studio at localhost:1234/v1."
  • Shows a conditional hint in the API key field when LM Studio is selected: "No API key required for local inference. Leave blank to use your default LM Studio configuration."

Verification

  • LM Studio: Start lmstudio-server --server http://0.0.0.0:1234 → verify /v1/models returns the model list.
  • PDF parser: python backend/scripts/test_parse_resume_pdf.py — parses multi-page PDFs with embedded images and complex layouts.
  • Location handler: python -m pytest backend/tests/test_location_handler.py::test_state_code_country_split → assert "Cambridge, MA USA" splits into ["MA", "USA"].

Note: The LM Studio provider uses reasoning_effort="none" in the extra body to prevent thinking models (Qwen3, etc.) from consuming their entire token budget on reasoning and returning empty content.

@vesaias vesaias left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, the LM Studio provider and the PDF parser work are welcome.

Review below is by Claude Code.

I checked out the branch and ran the pieces against a live Postgres and the test suite in the backend container.

Blocking

  1. The sort-by-score change breaks the endpoint (backend/api/routes_jobs.py).
    Job.cv_scores.is_(None) | Job.cv_scores == {} parses as (Job.cv_scores.is_(None) | Job.cv_scores) == {} because | binds tighter than == in Python. SQLAlchemy compiles it to

    (jobs.cv_scores IS NULL OR jobs.cv_scores) = %(param_1)s
    

    and the query fails before it reaches Postgres (StatementError: unhashable type: 'dict'), so every sort=score request would 500.
    The bug it targets also does not exist on main: a job with cv_scores = {} has best_cv_score = NULL, and the current desc(Job.best_cv_score).nullslast(), Job.id already places it last. Please drop this commit (it is also the merge conflict).

  2. The new lmstudio dispatch test fails on the branch.
    test_dispatch_lmstudio_routes_to_openai_client fakes _call_openai(prompt, system, model, api_key, max_tokens, base_url=None), but the provider now passes extra_body=...:

    TypeError: fake_openai() got an unexpected keyword argument 'extra_body'
    

    (1 failed, 170 passed across the four test files you touched.) Add extra_body=None to the fake, and it would be worth asserting {"reasoning_effort": "none"} while you are there.

  3. backend/scripts/resync_flagship.py is a one-off for your own database. Hard-coded company name and Greenhouse board id, and the docstring says the URL change "was done separately". Please keep it out of the PR.

  4. Location golden file. The "MA USA" token split is correct (it fixes "Somerville, MA USA", which currently lands as a city-only string), but the corpus golden test now fails on that one row. Regenerate it with python -m backend.tests.fixtures.generate_location_golden and include the updated CSV.

Minor

  • reasoning_effort: "none" is sent for every LM Studio model, not only thinking models. Fine for LM Studio itself, just noting it in case someone points LMSTUDIO_BASE_URL at a stricter OpenAI-compatible server.

Looks good

  • LM Studio wiring: reuse of _call_openai with base URL and dummy key, LMSTUDIO_BASE_URL with the host.docker.internal notes, FREE_PROVIDERS, seed allow-list, both Settings screens, .env.example.
  • _parse_model_json on top of _first_json_object, the six parser tests, and the 2000 → 8000 token budget.
  • The isinstance(..., dict) guards in cv_scorer.

Once the sort commit and the script are out, the test fixed and the golden file regenerated, please rebase on main (the Job.id tiebreak landed there) and this can go in.

- Routes lmstudio to _call_openai with base_url=os.getenv(LMSTUDIO_BASE_URL,
  http://localhost:1234/v1) and reasoning_effort=none in extra_body.
- Added extra_body param to _call_openai for OpenAI-compatible endpoints.
- Added lmstudio to FREE_PROVIDERS (local inference, no per-token cost).

ponytail: LM Studio's OpenAI-compatible endpoint doesn't support system prompts
directly; we pass them via the messages array in extra_body. No separate
system call needed.
- Increased max_tokens from 2000 to 8000 for full-resume parsing (verbose
  local models routinely exceed 2k tokens).
- Added _parse_model_json() helper that finds the first balanced {...} in a
  raw model reply, falling back to stripping markdown fences — guards against
  truncated/malformed responses.
- cv_scorer: added isinstance(dict) guards in _flatten_resume for all loops;
  non-dict entries are safely stringified and skipped.

ponytail: no new LLM call — just higher budget and better parsing of what we
already get back.
Tokens like 'Cambridge, MA USA' are left whole by the comma splitter and read
as city text. Added a regex that matches [A-Z]{2} + (USA|US|United States) and
splits them into ['MA', 'USA'] so the state code and country can each be
matched against their respective filter columns.

ponytail: no new data model — just a pre-split expansion before the main
tokenizer runs.
- Added 'lmstudio' to _LLM_PROVIDERS set.
- Updated llm_provider setting description to include LM Studio.
- Added commented LMSTUDIO_BASE_URL example to .env.example with Docker
  Desktop guidance (host.docker.internal:1234/v1) and Linux note.
- Added 'LM Studio (Local)' as an option in primary, scoring fallback,
  autofill, email classifier, tailor, and cover letter provider selectors.
- Updated API key conditional: LM Studio is now treated as free/local
  alongside claude_code, codex_cli, ollama, and empty string.
- Updated model catalog help text to mention Ollama/LM Studio together.

ponytail: no new state management — just a new option in existing selects.
- test_dispatch_lmstudio_routes_to_openai_client: verifies that lmstudio
  provider routes to _call_openai with correct base_url (http://localhost:1234/v1)
  and a dummy key (lm-studio), plus prefix concatenation.
- test_lmstudio_is_free: asserts lmstudio costs /usr/bin/bash (local inference).
- test_r4_contract_settings.py: added lmstudio to the list of accepted
  provider values in the parameterized validation test.

ponytail: no new fixtures or mocks — uses existing _dispatch and calc_cost.
- backend/tests/test_parse_resume_pdf.py: full integration test for the PDF
  → JSON pipeline. Creates a temporary PDF with known text, asserts that
  _parse_resume_pdf returns the expected structured output (name, education,
  experience), and verifies error handling for unreadable/binary PDFs.
@brycecollison

Copy link
Copy Markdown
Contributor Author

I believe it's good to go now. Apologies for the leftover test scripts and the sort bug. Let me know if you want anything else tweaked.

@vesaias

vesaias commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Generated by Claude Code

Thanks, all four points are in and verified on this side: sort commit dropped, the script gone, the fake takes extra_body, the golden file carries only the Somerville row. Full suite on your tree 2811 passed, stylelint clean, frontend builds. Merging. Nice work on the LM Studio wiring and the PDF parser.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants