LM Studio support, PDF parser improvements, score sorting bug fix - #15
Conversation
There was a problem hiding this comment.
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
-
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)sand the query fails before it reaches Postgres (
StatementError: unhashable type: 'dict'), so everysort=scorerequest would 500.
The bug it targets also does not exist onmain: a job withcv_scores = {}hasbest_cv_score = NULL, and the currentdesc(Job.best_cv_score).nullslast(), Job.idalready places it last. Please drop this commit (it is also the merge conflict). -
The new lmstudio dispatch test fails on the branch.
test_dispatch_lmstudio_routes_to_openai_clientfakes_call_openai(prompt, system, model, api_key, max_tokens, base_url=None), but the provider now passesextra_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=Noneto the fake, and it would be worth asserting{"reasoning_effort": "none"}while you are there. -
backend/scripts/resync_flagship.pyis 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. -
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 withpython -m backend.tests.fixtures.generate_location_goldenand 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 pointsLMSTUDIO_BASE_URLat a stricter OpenAI-compatible server.
Looks good
- LM Studio wiring: reuse of
_call_openaiwith base URL and dummy key,LMSTUDIO_BASE_URLwith thehost.docker.internalnotes,FREE_PROVIDERS, seed allow-list, both Settings screens,.env.example. _parse_model_jsonon top of_first_json_object, the six parser tests, and the 2000 → 8000 token budget.- The
isinstance(..., dict)guards incv_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.
|
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. |
|
Generated by Claude Code Thanks, all four points are in and verified on this side: sort commit dropped, the script gone, the fake takes |
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
2000to8000(full resumes routinely exceed 2k tokens on verbose local models)._parse_model_json) guards against truncated/malformed model replies.{}) at the bottom instead of leaving them in an undefined position.Changes
Backend — LLM Client & Cost (
backend/analyzer/)llm_client.pylmstudioprovider: routes to_call_openaiwithbase_url=os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1"). Setsreasoning_effort="none"in the extra body (thinking models like Qwen3 otherwise burn their entire budget on reasoning and return empty content). Also addedextra_bodyparam to_call_openai.llm_cost.py"lmstudio"toFREE_PROVIDERSset; updated docstring.cv_scorer.pyisinstance(..., dict)) in_flatten_resumefor experience/education/projects/publications loops — safely handles non-dict entries (e.g. malformed schema fields).Backend — Location Handler (
backend/analyzer/location.py)_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)_parse_model_json(raw_response)helper: finds the first balanced{...}in the raw model reply using_first_json_objectfromroutes_autofill, falling back to stripping markdown code fences and parsing the whole response. Raises a clear 422 on invalid JSON.max_tokensfrom2000→8000(full resumes routinely exceed 2k tokens).Backend — Jobs API (
backend/api/routes_jobs.py).nullslast()which only handles SQLNULL. Newly scored jobs havecv_scores = {}(empty dict), so they weren't recognized as "no score". Now uses a compound order-by that treats bothNULLand{}as bottom-of-list:Frontend — Settings UI (
frontend/src/classic/Settings.jsx,frontend/src/screens/Settings.jsx)localhost:1234/v1."Verification
lmstudio-server --server http://0.0.0.0:1234→ verify/v1/modelsreturns the model list.python backend/scripts/test_parse_resume_pdf.py— parses multi-page PDFs with embedded images and complex layouts.python -m pytest backend/tests/test_location_handler.py::test_state_code_country_split→ assert "Cambridge, MA USA" splits into ["MA", "USA"].