fix(tailor): expand diff scope + preserve casing (#805); e2e-monitor JD-keyword tolerance#827
Conversation
…preserve casing (#805) The AI tailor only ever touched summary + work/project descriptions + skills, and dropped original capitalization. Issue #805. - Allow-list: education[i].description (scalar string), additional.languages, additional.certificationsTraining, additional.awards. Education degree/institution/years stay blocked (leaf-name check + scoped carve-out). - Diff prompt: add casing-preservation rule (#12) WITHOUT dropping the existing verified-skill-target rules (#10/#11) or the add_skill path; document the new targetable paths. - calculate_resume_diff: surface education-description, language, and award changes in the preview modal; add 'language'/'award' to ResumeFieldDiff field_type (backend Literal + frontend union) so building those diffs no longer raises a Pydantic ValidationError. - Frontend: render Language/Award change sections; i18n keys in all 5 locales. - Tests: new allowed paths apply; degree/institution/years still rejected; scalar-only education description (the [j] bullet form is rejected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uthfulness lens Maintainer policy: surfacing job-description keywords/skills is the product's job, so a moderate amount of JD-sourced wording the master lacked is legitimate ATS tailoring, not fabrication. Add a documented, tunable JD_KEYWORD_TOLERANCE (0.20) to the judge rubric so it stops penalizing this as a truthfulness violation up to that share of content. Hard structural guards are unchanged: flow.score_tailoring still enforces no_fabricated_employers and personal_info_unchanged, and the rubric still penalizes invented employers, fake titles/dates, and wholesale profession swaps beyond the tolerance. Update AGENT_PLAYBOOK accordingly. Baseline may warrant a maintainer refresh (scores can rise; regressions only fire on drops). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses Tailor issue #805 by expanding the diff allow-list beyond summary and a small subset of bullets, while also adding an explicit “preserve original capitalization” rule to prevent unwanted lowercasing. It also updates the e2e-monitor judge rubric to tolerate a configurable amount of JD-keyword injection as legitimate ATS tailoring.
Changes:
- Expanded backend diff allow-list to include
education[i].description(scalar only) plusadditional.languages,additional.certificationsTraining, andadditional.awards, while keeping key education identity fields blocked. - Updated diff prompt rules/targetable paths and extended diff preview generation + UI types/rendering to support new
field_types (language,award) with i18n parity across locales. - Tuned e2e-monitor judge rubric via a documented
JD_KEYWORD_TOLERANCEparameter and updated the monitor playbook accordingly.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| apps/frontend/messages/en.json | Adds i18n strings for new Language/Award diff sections in the tailor diff modal. |
| apps/frontend/messages/es.json | Same as above for Spanish locale. |
| apps/frontend/messages/ja.json | Same as above for Japanese locale. |
| apps/frontend/messages/pt-BR.json | Same as above for Portuguese (Brazil) locale. |
| apps/frontend/messages/zh.json | Same as above for Chinese locale. |
| apps/frontend/components/tailor/diff-preview-modal.tsx | Renders new grouped sections for language and award diffs in the preview modal. |
| apps/frontend/components/common/resume_previewer_context.tsx | Extends ResumeFieldDiff.field_type union to include language and award. |
| apps/backend/tests/unit/test_apply_diffs.py | Adds unit tests covering newly allowed paths and ensures blocked education fields + scalar-only education description. |
| apps/backend/e2e_monitor/judge.py | Introduces configurable JD keyword tolerance and updates rubric wording accordingly. |
| apps/backend/e2e_monitor/AGENT_PLAYBOOK.md | Documents the JD-keyword tolerance policy for monitor runs and ties it to judge.py. |
| apps/backend/app/services/improver.py | Expands allowed diff paths, refines education blocking logic, and surfaces education/language/award diffs in calculate_resume_diff. |
| apps/backend/app/schemas/models.py | Extends backend ResumeFieldDiff.field_type Literal to include language and award. |
| apps/backend/app/prompts/templates.py | Updates diff prompt rules/paths and adds casing-preservation rule. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 4b. Compare education descriptions (a single string per entry, not a list) | ||
| original_education = original.get("education", []) | ||
| improved_education = improved.get("education", []) | ||
| for idx in range(max(len(original_education), len(improved_education))): |
There was a problem hiding this comment.
WARNING: Education descriptions are compared by array index. If an education entry is added or removed at a different position (e.g., a new entry prepended at index 0), descriptions will be compared against the wrong entries — original[1] vs improved[0] — producing false "modified" diffs. The workExperience path avoids this by using SequenceMatcher in _append_list_changes for its description list comparison. Consider using a similar alignment approach or matching entries by a stable key (e.g., institution + degree) before comparing descriptions.
Code Review SummaryStatus: 1 WARNING, 1 SUGGESTION (1 FIXED) | Recommendation: Address WARNING before merge Overview
Incremental Changes (since previous review)Two new commits added:
No NEW issues found in the incremental diff. Issue Details (click to expand)WARNING (Carried Forward)
SUGGESTION (Carried Forward)
RESOLVED
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (11 files total, 2 incremental)
Reviewed by qwen3.6-plus · 607,962 tokens |
… compare Addresses kilo-code-bot review on PR #827. Education now has a dedicated description diff (step 4b), so the entry-level _append_entry_changes for education must ignore 'description' (like workExperience already does) — else a description-only edit produced two 'education' diffs: the real one plus a spurious entry-level 'education[i] modified' with identical degree/institution/ years labels. Add a regression test plus language/award diff tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @kilo-code-bot / @copilot-pull-request-reviewer — went through all three findings: ✅ SUGGESTION (improver.py ~1395) — duplicate education diff: FIXED (fba7dae). Confirmed real: with a dedicated education-description diff (step 4b), the entry-level ↩️ WARNING (improver.py ~1302) — index-based education pairing: declined (not reachable). Education entries cannot be added, removed, or reordered through the tailor pipeline — the allow-list permits only 🕒 SUGGESTION (models.py — Full suite: |
There was a problem hiding this comment.
2 issues found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/backend/app/services/improver.py">
<violation number="1" location="apps/backend/app/services/improver.py:88">
P2: These new list targets need path-specific action gating; as written, malformed diffs can append or replace values instead of only reordering them.</violation>
<violation number="2" location="apps/backend/app/services/improver.py:1323">
P2: Education description edits are now emitted twice: once in the new `education[i].description` diff and again by entry-level education diffing. Exclude `description` from education entry comparisons to avoid duplicate change records.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| change_type = "added" | ||
| else: | ||
| change_type = "modified" | ||
| changes.append(ResumeFieldDiff( |
There was a problem hiding this comment.
P2: Education description edits are now emitted twice: once in the new education[i].description diff and again by entry-level education diffing. Exclude description from education entry comparisons to avoid duplicate change records.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/services/improver.py, line 1323:
<comment>Education description edits are now emitted twice: once in the new `education[i].description` diff and again by entry-level education diffing. Exclude `description` from education entry comparisons to avoid duplicate change records.</comment>
<file context>
@@ -1286,6 +1296,91 @@ def calculate_resume_diff(
+ change_type = "added"
+ else:
+ change_type = "modified"
+ changes.append(ResumeFieldDiff(
+ field_path=f"education[{idx}].description",
+ field_type="education",
</file context>
| # so only the scalar path is allowed — not a [j]-indexed bullet form. | ||
| re.compile(r"^education\[\d+\]\.description$"), | ||
| re.compile(r"^additional\.technicalSkills$"), | ||
| re.compile(r"^additional\.languages$"), |
There was a problem hiding this comment.
P2: These new list targets need path-specific action gating; as written, malformed diffs can append or replace values instead of only reordering them.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/services/improver.py, line 88:
<comment>These new list targets need path-specific action gating; as written, malformed diffs can append or replace values instead of only reordering them.</comment>
<file context>
@@ -81,7 +81,13 @@ def _check_for_truncation(data: dict[str, Any]) -> None:
+ # so only the scalar path is allowed — not a [j]-indexed bullet form.
+ re.compile(r"^education\[\d+\]\.description$"),
re.compile(r"^additional\.technicalSkills$"),
+ re.compile(r"^additional\.languages$"),
+ re.compile(r"^additional\.certificationsTraining$"),
+ re.compile(r"^additional\.awards$"),
</file context>
* test(backend): green the suite + cover the untested I/O surface
Phase 1 of the backend testing initiative (see docs/agent/testing-strategy.md).
- Fix stale health tests: /health is a liveness probe. The old test asserted
the deleted "/health returns degraded" behavior and had been failing
silently because nothing runs the suite. New test guards the
liveness-vs-readiness split and asserts the LLM is NOT called.
- Add llm.py provider/Ollama regression tests: get_model_name prefixing
(ollama_chat/, OpenRouter nesting), _normalize_api_base /v1/v1 dedup (#751),
resolve_api_key no-env-key fallback for local providers (security),
thinking-tag stripping.
- Add parser date-restoration tests (pure logic, was 20% cover).
- Add upload-endpoint guard tests incl. empty-extracted-text rejection (#794).
- Add real-TinyDB database CRUD tests against a temp file (was 34% cover).
No production code changed - additive tests + docs only.
Result: 192 -> 265 tests, 1 silent failure -> 0, coverage 54% -> 58%
(database 34->96%, parser 20->72%, llm 47->55%, health now meaningful).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(testing): mark Phase 1 complete in strategy doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(backend): add respx + eval marker + isolated_db fixture (phases 2-5 substrate)
- respx>=0.21.1 dev dep for transport-level LLM mocking (Phase 2)
- 'eval' pytest marker for prompt-quality evals, skipped without a key (Phase 5)
- isolated_db fixture: swaps the global TinyDB singleton for a temp-file DB
across app.database + every router module, so endpoint/e2e tests run against
a real-but-disposable database (Phase 4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(backend): respx transport contract tests for LLM/Ollama (Phase 2)
Exercises the real app/llm.py request path (complete/complete_json/check_llm_health) against a fake HTTP server via respx, for ollama + openai_compatible. Pins base-URL handling (#751), JSON extraction over the wire, health error-code mapping + secret scrubbing, and thinking-tag stripping. Required disabling litellm's aiohttp transport so respx (an httpx hook) can intercept. llm.py coverage 55%->74%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(backend): Playwright PDF render smoke test (Phase 3)
Renders a self-contained data: URL with the .resume-print selector through real headless Chromium and asserts genuine %PDF bytes - the 'resume won't render' incident class. Plus pure-helper tests (format/margins) and the connection-refused -> PDFRenderError mapping. Render tests skip cleanly if Chromium is unavailable. pdf.py coverage 20%->54%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(backend): end-to-end pipeline test through real routers + DB (Phase 4)
Drives the genuine FastAPI app against the isolated_db fixture (real temp TinyDB) with every LLM boundary mocked, covering upload -> jobs -> fetch and the preview->confirm tailoring handshake. Asserts real persisted state (master invariant, parent_id linkage, improvements record), not just status codes. resumes.py coverage 18%->53%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(backend): prompt-quality eval harness - structural + LLM-judge (Phase 5)
tests/evals/: pure deterministic scorers (sections_preserved, no_fabricated_employers, jd_keywords_present, is_valid_resume, personal_info_unchanged) with 31 tests proving each on good AND bad inputs, golden fixtures, and a gated LLM-as-judge. The judge is marked @pytest.mark.eval, uses the developer's own configured key, and is excluded from the default run (addopts -m 'not eval'); run on demand with 'uv run pytest -m eval'. Skips cleanly when no key is configured.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(testing): mark Phases 2-5 complete (coverage 54%->69%)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(dev): local pre-push gate (backend tests + locale parity) instead of PR CI
A version-controlled pre-push hook that blocks pushes when the backend suite or the frontend locale-parity check is red. Replaces a GitHub Actions PR gate, which we avoid because the repo gets a high volume of external PRs (CI would run on every one, incl. untrusted code).
- .githooks/pre-push: runs 'uv run pytest' (apps/backend) + the locale check; aggregates failures; bypass with 'git push --no-verify'.
- scripts/check_locale_parity.py: pure-stdlib (no Node) check that every messages/*.json matches en.json's key structure, guarding the i18n break that 'type Messages = typeof en' enforces (the original incident).
- .githooks/README.md + testing-strategy.md Phase 6 / decisions log.
- Activate per-clone: git config core.hooksPath .githooks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): add backend pytest + frontend vitest to root Essential Commands
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): add Testing section to root context (suites, layers, pre-push gate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): require passing + anti-theater tests in Definition of Done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): fix stale locale list in project structure (add pt)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): link testing-strategy.md under Documentation by Task
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): rewrite backend Testing section (in-scope, layers, respx/isolated_db, evals, pre-push)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover i18n engine (getNestedValue dot-path + applyParams substitution)
The translation t() relies on these pure functions; pins the missing-key-returns-key contract and {placeholder} substitution incl. unknown/numeric params.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): in-suite locale parity guard (catches the next build break)
Mirrors scripts/check_locale_parity.py in vitest: every messages/*.json must structurally match en.json (type Messages = typeof en). Verified anti-theater: adding a key to en.json fails all 4 locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover keyword-matcher (extract/segment/match-stats)
Pure JD-resume keyword logic behind the tailor match highlighting: stop-word/short/number filtering, lossless segmentation, and rounded match percentage with no divide-by-zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover section-helpers (ordering, custom-section IDs, localization)
Pure resume-section metadata logic: DEFAULT fallback, visible/sorted selection, custom_N id generation, and the localize-only-untouched-defaults rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover html-sanitizer XSS whitelist (strong/em/u/a only)
Guards every dangerouslySetInnerHTML sink: keeps whitelisted formatting + anchor href, strips <script>, event-handler attrs, and dangerous tags like <img onerror>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover api client (URL resolution, JSON POST, timeout/AbortError)
Stubs fetch to pin: API_BASE/getUploadUrl, relative/bare/absolute URL resolution, JSON POST shape, AbortError->friendly timeout message, non-abort rethrow, and the timeout actually aborting (fake timers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(claude): rewrite frontend Testing section (in-scope, new i18n/utils/api specs, pre-push)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(hooks): pre-push also runs frontend vitest (guarded by Node availability)
Adds a third gate step: the frontend vitest suite, run only when node + the local vitest binary are present (git hooks may run without nvm's node), else skipped with a warning. Full tsc/next build still intentionally excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(hooks): document frontend vitest step in pre-push README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(testing): add frontend test suite section (vitest, 65->117) + update scope
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover api config (PROVIDER_INFO consistency + FeaturePromptsError 422 mapping)
Asserts every LLMProvider has a complete PROVIDER_INFO entry (local providers key-optional) and that updateFeaturePrompts surfaces the backend missing_placeholders 422 as a typed FeaturePromptsError, with generic-error and success paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover server-side translate() (print/PDF i18n path)
Pins the getMessages+getNestedValue+applyParams composition used by the server print pages: real-key resolution, missing-key fallback, unknown-locale->en fallback, and param substitution over a missing path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(locale-parity): catch malformed JSON + detect leaf-vs-object shape mismatches
key_kinds() now compares each path's node kind (branch vs leaf), so a locale that has an object where en.json has a string is caught — it previously passed a presence-only check yet still broke `next build`. _load() raises a clean error on malformed JSON instead of a traceback. New tests/unit/test_check_locale_parity.py proves all of: match, missing key, leaf<->object mismatch, and bad JSON. Raised by cubic + kilo on PR #820.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(i18n): make frontend locale-parity guard kind-aware (mirror the script)
Compare node kind, not just key paths, so a leaf-vs-object shape mismatch (which still breaks next build) is caught in-suite. Adds a proof that the kind-aware diff fires on the exact gap a presence-only set-diff misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(pdf): document why the refused-port test closes the socket
Behavior unchanged (bind ephemeral, read port, close). The docstring now records why closing is required: a bound-but-unlistening socket does NOT refuse on macOS (Chromium hangs to a 30s navigation timeout), so closing yields the deterministic cross-platform ERR_CONNECTION_REFUSED this test needs. The residual close()->connect() window is sub-millisecond and only delays the same failure, never masks it. Addresses kilo TOCTOU note (the literal 'keep it bound' fix was tried and breaks the test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(eval): skip tailoring eval if get_llm_config() raises on bad config
_needs_key() wraps get_llm_config() so a corrupt/unreadable config.json makes the opt-in LLM-judge eval skip cleanly rather than erroring the suite. Addresses kilo warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(contract): assert the specific health_check_failed error code
A provider auth (401) failure maps to the generic failure code; assert == 'health_check_failed' instead of merely truthy, so a silent rename of the code is caught. Addresses kilo suggestion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: clarify isolated_db+client fixture safety and the empty-resume canary
test_pipeline_e2e: document that isolated_db (resolved first) + request-time db lookup make the isolated db authoritative regardless of when client is constructed. test_scorers: clarify the empty-dict test is a canary that fails LOUDLY if a ResumeData field ever becomes required. Addresses kilo suggestions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(locale-parity): classify keys by full JSON type, not just branch/leaf
Addresses cubic P2 follow-up: a coarse object-vs-leaf split missed primitive/array mismatches — a string in en.json vs a number/array/bool in a locale still breaks `next build` (typeof en) but was labelled the same 'leaf'. key_kinds now records the JSON type (object/array/string/number/boolean/null), and the frontend in-suite guard matches — fixing a latent disagreement where the two classified arrays/null differently (Python list/None = leaf, JS [] /null = object). New tests cover string<->number and string<->array; real locales still pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(spec): agentic end-to-end monitor design
Approved brainstorming output for the next phase of the testing initiative:
a deterministic capture harness + an agent-in-the-loop Claude Code skill that
drives the real app (master resume -> 3-4 variations -> PDFs), captures a
durable evidence bundle (logs + intermediates), and judges output quality,
flow/render integrity, and provider reality over a committed golden baseline.
Report-never-a-gate; OSS-safe (optional extra + RM_E2E_MONITOR opt-in +
gitignored skill / committed playbook). Implementation deferred to writing-plans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(plan): agentic e2e monitor implementation plan (11 tasks, TDD)
Turns the approved design spec into a staged, no-placeholder plan: pure-logic
units (scrubber, manifest, non-blank PDF check, scorer-runner, flow-trace,
baseline-diff) are TDD'd keyless/offline; the side-effectful moves (subprocess
boot with isolated DATA_DIR, httpx seed/tailor/render, LLM judge) carry real
code with exact backend contracts and are exercised by the on-demand sweep.
Includes the gitignored-skill + committed-playbook packaging and the first-
live-sweep + golden-baseline task.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(e2e-monitor): package skeleton, opt-in gate, secret scrubber
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(e2e-monitor): evidence bundle layout + run manifest
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(e2e-monitor): non-blank PDF heuristic (browser-free, pypdf-optional)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(e2e-monitor): scorer-runner over the eval scorers
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(e2e-monitor): flow-trace + summary roll-up builders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(e2e-monitor): baseline diff (floor + drift) and baseline builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(e2e-monitor): server lifecycle + DB pre-seed + tailor/render/judge moves
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(e2e-monitor): raise_for_status in tailor, accurate config-isolation docs, close log handles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(e2e-monitor): CLI + sweep orchestration (gated, seed-before-boot)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(e2e-monitor): canonical master resume + 4 role JD fixtures
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(e2e-monitor): agent playbook, skill installer, README, strategy section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(e2e-monitor): judge schema_type=keywords (avoid wasted retries), denoise keyword proxy, soften default coverage floor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(e2e-monitor): canonicalize seeded master (fixes improve/confirm 400) + judge floor 2 + sync-with-dev docs
The first live sweep surfaced 3 real issues:
- improve/confirm returned 400 for every variation: preview hashes the raw
improved_data dict while confirm hashes its ResumeData round-trip, and the
master fixture omitted optional personalProjects.github/website that the
schema defaults to null -> hash mismatch. Fix: seed the master in canonical
ResumeData form so every optional field is present. (Latent app finding: a
non-canonical stored processed_data can't be confirmed.)
- 3 of 4 fixtures (frontend/ML/PM JDs, far from a backend master) legitimately
score judge=2 with zero fabrication — so the default judge floor is lowered
3 -> 2 to avoid false-positives; drift detection still catches real drops.
- 'uv sync --extra e2e-monitor' alone removed the dev/test deps; docs now say
'--extra dev --extra e2e-monitor'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(e2e-monitor): golden baseline from first clean sweep (anthropic/claude-haiku-4-5)
4/4 variations, all renders non-blank, no fabrication. backend-eng judge=4
(close match); frontend/ml/product-manager judge=2 (honest weak tailoring to
distant roles — the truthfulness stress-test working as intended).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(resumes): canonicalize the improve preview/confirm hash gate
improve/preview hashes the raw improved_data dict while improve/confirm hashes
its ResumeData round-trip (request.improved_data.model_dump()). A stored resume
whose processed_data merely omits optional fields (which ResumeData defaults to
null) hashes differently on the two sides, so a valid tailoring is rejected with
400 ('preview hash mismatch') — i.e. 'tailoring won't save' for any non-schema-
complete stored resume. (The e2e test + the e2e-monitor harness both papered
over this by canonicalizing their inputs first.)
Fix: _hash_improved_data canonicalizes through ResumeData before hashing — what
its docstring already claimed. Idempotent for schema-complete data (no behavior
change there); only the previously-rejected non-canonical case is affected.
Tests: unit (hash stable across ResumeData round-trip + still distinguishes
different resumes) and integration (preview->confirm now 200 for a resume whose
project omits optional github/website; proven anti-theater — reverting the fix
fails it with 400). Found by the agentic e2e monitor's first live run (PR #823).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(e2e-monitor): address PR #823 review — port isolation, missing-variation detection, score normalization, scrub coverage, narrowed error boundaries, self-contained extra
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(e2e-monitor): mark spec implemented + fix plan sync command (PR #823 review: Copilot)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(resumes): tighten _hash_improved_data canonical type to dict[str, Any] (PR #824 review: kilo)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(e2e-monitor): PR #823 re-review (kilo + cubic)
- servers.py: verify :3000 actually responds before trusting an already-running
frontend (health check, not just 'port occupied'); PEP8 blank lines.
- baseline.py: flag judge_missing when the judge errored (None) but the baseline
had a score — a missing score is worse than a low one.
- judge.py: _normalize_score rounds instead of truncating (4.9 -> 5) and fails
closed on non-finite (inf/nan -> None) by catching ValueError + OverflowError.
- Tests for judge_missing + round/non-finite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(e2e-monitor): PR #823 round-3 review (kilo + cubic)
- baseline.py: judge_missing now carries baseline_value so baseline-diff.json is
self-contained (matches keyword_floor/judge_drop having their values).
- judge.py: _normalize_score wraps the whole conversion in one try/except so a
huge int (float() OverflowError) also fails closed; uses round-half-up
(int(x+0.5)) instead of round()'s banker's rounding.
- servers.py: frontend reuse on :3000 now requires a 200 (not just <500), so an
unrelated HTTP service squatting the port isn't mistaken for the frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(improver): accept list 'original' on reorder diffs (was silently dropped)
The diff prompt tells the model to send 'original': null for a reorder, but the
live LLM (claude-haiku) ignores that and sends the CURRENT skills list as
'original'. ResumeChange.original was 'str | None', so every reorder change
failed validation ('1 validation error for ResumeChange ... string_type') and
was skipped at parse time ('Skipping malformed change') — the JD-relevant skill
reordering never actually happened on ANY tailoring.
Fix: ResumeChange.original is now 'str | list[str] | None' (the reorder handler
never reads it anyway — it reorders 'value'), and _verify_original_matches
treats a non-str 'original' as 'no text check' (it already did for None). Pure
reorders now apply; non-pure ones still reject cleanly via the items-match gate.
Surfaced by the agentic e2e monitor's report (recurring 'Skipping malformed
change' across all 4 variations). Test reproduces the exact parse failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(e2e-monitor): narrate the loop + the AI-agent handoff in the CLI
Running the bare 'sweep' was silent except a final 'bundle: <path>', which made
it feel like a dead end — it captures evidence but produces no verdict, and the
verdict is the AGENT's job (the /monitor-e2e skill / any Claude Code session).
cmd_sweep now narrates each move live to stderr (seed → boot → per-variation
tailor/judge/render with score+render+fabricated, → sweep summary) and ends with
an explicit handoff block: 'this is EVIDENCE, not a verdict — hand it to an AI
agent (/monitor-e2e or 'judge the latest e2e-monitor bundle')'. The machine-
readable 'bundle: <path>' stays alone on stdout. README reframed: the bare CLI
is the plumbing; the front door is the agent (meant to run in the background
while you work on the app).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(improver): constrain list 'original' to reorder + harden verifier (PR #825 review)
Addresses Copilot + cubic (P1/P2): widening ResumeChange.original to allow a
list could (a) let a 'replace' with a list 'original' bypass the original-match
gate, and (b) crash the invented-metrics check (_METRIC_RE.findall on a list).
- model_validator: a list 'original' is valid ONLY for action='reorder'; for
text actions it's rejected at parse (so replace/append/add_skill can never
receive a list — verification + invented-metrics stay safe).
- _verify_original_matches: a non-str, non-None 'original' now FAILS (reject)
instead of returning True (bypass). reorder never calls it (replace-only gate),
so the reorder fix is unaffected.
- Test: a replace with a list 'original' raises ValidationError.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(e2e-monitor): surface render failures + outcome marker + flow-trace order (PR #826 review)
Addresses Copilot + kilo + cubic:
- A render exception left render={'non_blank': None}, so the line printed
'render=skipped' for an attempted-but-failed render. Track render_status
explicitly: skipped / non-blank / BLANK! / FAILED.
- The per-variation marker was a hardcoded ✓ even after a caught judge/render
failure. Now ✓ only when the judge produced a score AND render didn't
fail/blank; otherwise ⚠.
- flow-trace recorded 'seed-master' after 'boot' though seeding runs first;
the seed-master step is now appended before boot so the trace ordering
matches execution (and the 'BEFORE booting' comment).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tailor): expand diff scope to education/languages/certs/awards + preserve casing (#805)
The AI tailor only ever touched summary + work/project descriptions + skills,
and dropped original capitalization. Issue #805.
- Allow-list: education[i].description (scalar string), additional.languages,
additional.certificationsTraining, additional.awards. Education
degree/institution/years stay blocked (leaf-name check + scoped carve-out).
- Diff prompt: add casing-preservation rule (#12) WITHOUT dropping the existing
verified-skill-target rules (#10/#11) or the add_skill path; document the new
targetable paths.
- calculate_resume_diff: surface education-description, language, and award
changes in the preview modal; add 'language'/'award' to ResumeFieldDiff
field_type (backend Literal + frontend union) so building those diffs no
longer raises a Pydantic ValidationError.
- Frontend: render Language/Award change sections; i18n keys in all 5 locales.
- Tests: new allowed paths apply; degree/institution/years still rejected;
scalar-only education description (the [j] bullet form is rejected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(e2e-monitor): tolerate ~20% JD-keyword incorporation in judge truthfulness lens
Maintainer policy: surfacing job-description keywords/skills is the product's
job, so a moderate amount of JD-sourced wording the master lacked is legitimate
ATS tailoring, not fabrication. Add a documented, tunable JD_KEYWORD_TOLERANCE
(0.20) to the judge rubric so it stops penalizing this as a truthfulness
violation up to that share of content.
Hard structural guards are unchanged: flow.score_tailoring still enforces
no_fabricated_employers and personal_info_unchanged, and the rubric still
penalizes invented employers, fake titles/dates, and wholesale profession
swaps beyond the tolerance. Update AGENT_PLAYBOOK accordingly. Baseline may
warrant a maintainer refresh (scores can rise; regressions only fire on drops).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tailor): dedup education diff — ignore description in entry-level compare
Addresses kilo-code-bot review on PR #827. Education now has a dedicated
description diff (step 4b), so the entry-level _append_entry_changes for
education must ignore 'description' (like workExperience already does) — else a
description-only edit produced two 'education' diffs: the real one plus a
spurious entry-level 'education[i] modified' with identical degree/institution/
years labels. Add a regression test plus language/award diff tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pdf): deterministic render wait + generic error; contain error modal (#799 #808 #811)
PDF download intermittently 503'd with 'Page.goto: Timeout 30000ms exceeded'
waiting for networkidle. Root cause: networkidle is environment-fragile — the
Next.js dev server (HMR/Turbopack + RSC streaming) keeps the network busy, so
'idle' may never arrive and goto hangs to its default timeout (renders fine in
some environments, fails in others). Issues #799/#808.
- pdf.py: wait on the real readiness condition instead of networkidle —
wait_until='load' + the .resume-print content selector + fonts.ready — all
bounded by an explicit 60s timeout, so the outcome is deterministic.
- pdf.py: the catch-all error path leaked the raw Playwright call log
(internal navigation URLs) into the 503 returned to the client. Log it
server-side, return a generic message (CLAUDE.md rule 5). This also stops the
verbose trace from overflowing the client error modal (#811, root cause).
- confirm-dialog.tsx: defense-in-depth — contain long messages (max-height +
scroll + wrap) so ANY error stays inside the modal (#811).
- Tests: browser-free assertions that goto no longer uses networkidle and uses
a bounded timeout, that content gating is preserved, and that the catch-all
hides internals while curated messages (connection-refused, missing-exe) stay.
Real-Chromium render test still passes with the new wait strategy.
No new dependencies. Frontend CSS not lint/built locally (nvm constraint).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pdf): address PR #828 review — user-friendly error, fonts test, drop fragile assert
- Reword generic PDF error to not reference 'backend logs' (end users may have
no log access).
- Add test asserting document.fonts.ready is awaited (guards the fonts gate).
- Remove the fragile negative 'wait_until != networkidle' assertion; the
positive '== load' test already covers it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(config): clearing Base URL persists null instead of a stale value (#760)
The Settings UI sends api_base: null (or '') when the Base URL field is
cleared, but the save handler only updated the field when it was not None, so
a cleared field left the previous api_base in config.json — the backend kept
routing to the stale endpoint (manual config-file edit was the only recovery).
Use model_fields_set to distinguish 'omitted' (leave unchanged) from 'present
but blank/null' (explicit clear → None), and normalize blank/whitespace to None
so an empty string can never reach LiteLLM as a bogus endpoint. Also a small
security win: no stale base_url silently routing a paid key to a prior endpoint.
Tests: null clears a stale value; blank normalizes to None; omitting the field
leaves it unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pdf): bound the fonts wait; neutral catch-all message (PR #828 re-review)
- page.evaluate('document.fonts.ready') had no timeout, contradicting the
'all bounded' comment. Use page.wait_for_function(..., timeout=_NAV_TIMEOUT_MS)
so a stuck font load can't hang the render. Test updated to assert the bound.
- Catch-all message no longer implies a timeout (it handles all Playwright
errors, not just timeouts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(improver): salvage skill reorder instead of dropping it wholesale (#736)
When the LLM folds new (or duplicate/removed) items into a 'reorder' on
additional.technicalSkills, the strict set-equality check rejected the ENTIRE
change — so no skills were reordered or added, even though the LLM clearly
intended changes (observed with Claude Sonnet, and in the e2e-monitor logs:
'Diff rejected (reorder items mismatch): additional.technicalSkills').
Instead of dropping the change, salvage the safe subset:
- reorder existing items in the requested order;
- re-append any original the LLM omitted (never silently lose a real skill);
- for the skills list only, add new items that pass the SAME verified gate as
add_skill (allowed_skill_targets); unverified additions are dropped and logged.
Other reorderable lists (languages/certs/awards) have no verifier, so new items
are dropped — no fabrication. Pure permutations are unchanged.
This keeps the truthfulness guarantee (no unverified skills enter via reorder)
while fixing the 'skills never change' symptom. Two existing tests that asserted
the old wholesale-reject behavior are re-spec'd to the stronger salvage contract
(dedup + originals preserved); 5 new tests cover the salvage paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(improver): salvage honors requested order + preserves case-dupes (PR #830 review)
Addresses kilo + Copilot review on #830:
- Walk the proposed reorder in order, placing verified new skills where the
model put them (not appended last) so prioritized JD skills stay near the top.
- Use a casefold->list map so case-duplicate originals (e.g. 'python'/'Python')
are all preserved instead of collapsed to one.
Verifier behavior unchanged (skills stay permissive). Adds ordering + dup tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(prompts): make truthful JD-keyword incorporation the default across sections
Tailoring should pull JD-relevant keywords through EVERY section (summary, work
experience, projects, skills, cover letter) by default — to actually pass ATS /
recruiter screening — via TRUTHFUL reframing of the candidate's real experience
in the JD's language. NOT by inventing accomplishments or work history.
- DIFF_IMPROVE_PROMPT rule 11: conditional ('when the original text supports')
-> a systematic per-section reframe pass, with an explicit, load-bearing
anti-fabrication clause ('Do NOT add new work, metrics, or responsibilities;
only restate existing content ... verify every reframe stays factually accurate').
- COVER_LETTER_PROMPT: reframe qualifications in the job's terminology, gated to
'where the candidate's proven experience supports it' (+ ETL/pipelines example).
- KEYWORD_INJECTION_PROMPT (refiner): target EVERY section by default; strengthen
the evidentiary bar ('substantively supports') and the no-invent clause
('do not invent new content, metrics, or work history').
Designed + adversarially truthfulness-audited via a subagent workflow. Per that
audit, invented bullet NARRATIVE isn't caught by verify_diff_result/verify_alignment,
so these clauses are the only guard — locked by tests/unit/test_prompt_guardrails.py.
Skipped the proposed CRITICAL_TRUTHFULNESS_RULES['keywords'] reorder-permission
edit (marginal value, touches the full-output fallback that has no diff check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(prompts): address PR #831 review — drop em dash in rule 11, broaden reframe scope
- Remove the em dash from DIFF_IMPROVE_PROMPT rule 11 (rule 8 forbids em dashes
in output; having one in the rule text could prime the model to emit them).
- Broaden the default reframe scan to include education descriptions, matching
the prompt's editable paths and the 'every section' goal. Anti-fabrication
clause unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(prompts): remove remaining em dashes from diff rules + reconcile rule 9/11 (#831 review)
Kilo re-review: rules 1 and 9 still used em dashes (contradicting rule 8 'do not
use em dash characters', which can prime em-dash output); and rule 11's default
reframe could read as conflicting with rule 9 ('do not rewrite well-aligned
content'). Replaced the em dashes with semicolons and scoped rule 11's reframe to
content 'not already phrased that way (per rule 9 ...)' so the two rules compose
cleanly. Cosmetic/conservative prompt-text only; 391 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(builder): preserve Enter newlines in Additional Info textareas (#763)
handleArrayChange filtered out empty lines on every input event, so
pressing Enter (which creates a blank line) was stripped before the
value was stored — the textarea re-rendered with the filtered value,
undoing the keystroke. Users could not add multiple items.
Stop filtering at input time (`value.split('\n')`) so blank lines
persist while editing. To prevent empty strings from leaking into the
rendered resume, filter blank/whitespace-only entries at the render
level in every consumer that displays the additional arrays:
- resume-single-column.tsx, resume-modern.tsx: filter the destructured
technicalSkills/languages/certificationsTraining/awards arrays.
- resume-two-column.tsx, resume-modern-two-column.tsx: add filtered
local arrays and use them in the skills/languages/certs/awards blocks.
- highlighted-resume-view.tsx (builder live preview): filter the three
arrays it renders.
The four template components are the render path for the dashboard
Resume dispatcher, the paginated preview, the /print route, and the
resume detail page, so filtering there covers all of them. The backend
already drops empty strings (improver._normalize_string_list), so no
backend change is needed.
Adds tests: the form preserves blank lines, and ResumeSingleColumn does
not render empty entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(timeout): make the improve/request timeout configurable across all 3 layers (#776)
Local LLMs (Ollama, llama.cpp, ...) routinely need longer than the hardcoded
240s. The catch (why #776's community sed-fix failed): the timeout lives in
THREE coupled layers and the shortest aborts first —
1. backend asyncio.wait_for (app/routers/resumes.py)
2. Next.js proxyTimeout (apps/frontend/next.config.ts)
3. client AbortController default (apps/frontend/lib/api/client.ts)
Changing only the backend has no effect.
- Backend: new bounded setting REQUEST_TIMEOUT_SECONDS (default 240, clamped to
[30,1800]; blank/garbage falls back to 240 so it can't crash startup). The
504 message + log now report the configured value and point at the env vars.
- Frontend: next.config proxyTimeout and the client AbortController default both
derive from NEXT_PUBLIC_REQUEST_TIMEOUT_MS (same bounds); clearer AbortError
message. The client already mapped AbortError -> 'timed out'.
- .env.example documents REQUEST_TIMEOUT_SECONDS and the frontend coupling.
Tests: settings clamp/default/fallback (7) + an e2e wiring test proving a
configured 0.05s timeout actually yields a 504 (so the setting is really wired,
not hardcoded). 399 passed. Frontend not npm-built locally (nvm) — relies on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(timeout): address PR #833 review — wire improve calls to the default, robust parsing
Critical (Copilot): postImprove() in lib/api/resume.ts hardcoded 240_000 for the
improve/preview/confirm calls, which OVERRODE DEFAULT_TIMEOUT_MS — so
NEXT_PUBLIC_REQUEST_TIMEOUT_MS never applied to the very requests #776 is about.
Now uses DEFAULT_TIMEOUT_MS.
Also:
- config.py: catch OverflowError so REQUEST_TIMEOUT_SECONDS=inf can't crash
startup (int(float('inf')) raised). Tests for inf/nan added.
- next.config.ts + client.ts: parse NEXT_PUBLIC_REQUEST_TIMEOUT_MS robustly —
unset/blank/garbage -> 240000 default; 0/negative -> clamped to 30000 (matches
the backend validator) instead of '0 is falsy -> default'.
- .env.sample: document NEXT_PUBLIC_REQUEST_TIMEOUT_MS + the backend coupling.
Verified: backend 401 passed (incl. inf/nan); frontend vitest 128 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(deps): bump vitest 4.0.18 -> 4.1.8 to clear CVE-2026-47429 (#224, GHSA-5xrq-8626-4rwp)
Dependabot critical alert #224: vitest < 4.1.0 — the Vitest UI server can read/
execute arbitrary files. Verified context before acting (not trusting the alert
blindly): vitest is a DEV dependency (not shipped), and the vulnerable feature
is not used here — @vitest/ui is not installed and there is no --ui / test:ui
script, so the project never starts the UI server. Real exposure ~nil, but the
patch is a clean minor bump within v4, so applying it to clear the critical alert.
Bumped to 4.1.8 (>= 4.1.0 patched). npm audit: 0 vulnerabilities. Lock changes are
vitest/@vitest-scoped only. Full frontend suite: 131 passed on the new version.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): stop duplicate tiptap link/underline extension registration
The rich-text editor logged repeated '[tiptap warn]: Duplicate extension names
found: ["link", "underline"]'. StarterKit v3 (3.20) bundles the link and
underline extensions, but the editor also registered standalone Underline +
Link.configure(...), so each was added twice.
Disable the StarterKit-bundled link/underline (link: false, underline: false) and
keep the standalone ones, which carry the custom Link config (openOnClick:false,
target/rel) the LinkDialog relies on. No behavior change; warning gone.
Added a render-based regression test asserting no 'Duplicate extension names'
warning. Frontend suite: 132 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#839): address Copilot + cubic review feedback
Code review on PR #839 surfaced three valid items. (CodeQL's two findings are
false positives — scrub_config() redacts before write; only provider/model are
logged — handled separately via PR comments + alert dismissal.)
1. i18n locale-parity test (Copilot): the vitest guard hard-failed on EXTRA
locale keys, but scripts/check_locale_parity.py (which it claims to mirror)
and the `type Messages = typeof en` / `next build` contract both treat extras
as non-fatal. Relax the spec to warn (not fail) on extras; missing and
shape-mismatched keys still fail.
2. PDF-render crash hardening (cubic): the `.filter(item => item.trim() !== '')`
blank-entry filters added for #763 throw if an array element is not a string
(data arrives via `as ResumeData` casts with no runtime validation),
producing a blank PDF. Add an `item is string` type guard across all five
render-path components (modern, single/two-column, modern-two-column,
highlighted-resume-view).
3. e2e_monitor API-base duplication (cubic): render.py and flow.py each
hard-coded http://127.0.0.1:8000/api/v1. Consolidate into a single inert
API_BASE in e2e_monitor/__init__.py; derive servers.py BACKEND_HEALTH from it.
Findings #2 and #3 identified by cubic (https://cubic.dev); #1 by GitHub Copilot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design spec for LaTeX, Clean, Vivid resume templates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for LaTeX, Clean, Vivid templates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(templates): register latex, clean, vivid template IDs + controls/i18n
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(templates): add LaTeX single-column serif template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(templates): add Clean single-column minimal template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(templates): add Vivid two-column colorful template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(templates): document latex, clean, vivid templates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(templates): prettier formatting for latex/clean/vivid components
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(templates): wrap template thumbnail rows so 7 options don't overflow
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(templates): address Copilot/cubic/kilo review feedback
- vivid: guard work-experience meta so a missing year no longer renders an
orphaned leading '|' (kilo CRITICAL / cubic P2); add regression test
- vivid: add DynamicResumeSectionVivid wrapper so custom sections use the
accent small-caps headers + arrow bullets (kilo/cubic P2)
- latex: reorder header to Name -> Title -> Location -> Contact (kilo)
- latex: drop unused MapPin import / Location contact icon (Copilot)
- docs: fix stale File Structure + TemplateSettings shape in template-system.md (Copilot)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(templates): make both font controls live for Clean and LaTeX
Clean bound all text (incl. headers) to --body-font and LaTeX bound all text to
--header-font, so one font dropdown was inert per template (Clean's Header Font did
nothing). Split the binding: name + section headers follow --header-font, body follows
--body-font. Selecting a single-typeface template now seeds its signature fonts
(clean -> sans/sans, latex -> serif/serif) via applyTemplatePreset so the reference look
is the default while both controls stay live. Adds deterministic preset tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* backend: SQLite data layer, encrypted API keys, application tracker
Migrate the persistence layer from TinyDB to SQLAlchemy/SQLite and add two
new capabilities on top of it.
Data layer (behavior-preserving): one declarative Base + async engine
(sqlite+aiosqlite) backing resumes, jobs, improvements, plus new
applications and api_keys tables. The Database facade keeps the same
method names/signatures and returns plain dicts. Jobs' dynamic pipeline
fields (preview_hash(es), job_keywords, company/role) live in a
metadata_json column, flattened on read so the improve/confirm hash
handshake still round-trips. An idempotent TinyDB->SQLite importer runs on
startup and as a script (single-master enforcement, dynamic fields ->
metadata_json, legacy file renamed). The single-master invariant is kept
via the asyncio lock plus a partial unique index.
Encrypted multi-provider API keys: keys move out of config.json into an
encrypted SQLite table (Fernet; secret auto-generated at data/.secret_key,
chmod 600, gitignored). PUT /config/llm-api-key no longer writes the legacy
single api_key slot, and migrate_legacy_keys folds any legacy config.json
keys into the encrypted store (idempotent, non-clobbering) -- fixing the
bug where switching providers overwrote the previous key. openai_compatible
and ollama are added to the key-store providers.
Application tracker API: Application model + facade CRUD/bulk/reorder; an
/applications router (grouped list, manual add, detail tolerant of deleted
resumes, PATCH move with server-side renumber, bulk move/delete). Tailoring
auto-creates an "applied" card (best-effort, never blocks the flow);
company/role are extracted via the existing cached keyword pass.
Tests: async facade rewrite plus new crypto, key-independence,
applications, and auto-create suites. Full backend suite green.
* feat(tracker): grid canvas, wider board, scroll affordance, responsive height
Frame the board in the dashboard's bordered Swiss canvas (blue grid background,
hard shadow) but wider (max-w-[104rem]), and separate the seven stages with
full-height black dividers for a grid read. Add a horizontal-scroll affordance:
header prev/next buttons that disable at the edges, plus an always-visible stage
rail with per-column counts (click to jump) and a "scroll for more stages" hint,
so off-screen columns are discoverable. Make the canvas flex to the viewport
height via a flex-1 chain so the board fills the frame and tall columns scroll
internally. Failed drag moves now re-load authoritative state from the server
instead of reverting to a possibly-stale snapshot. Adds tracker.scroll.* i18n
keys across all five locales.
* fix(tracker): harden encrypted key store, dedupe, manual-add, migration
Review-finding fixes across the SQLite / keys / tracker backend:
- API keys: encrypt every key first, then swap the store in a single atomic
replace_api_keys() transaction, so a partial failure can no longer wipe
previously saved keys mid-replace.
- crypto: re-chmod 600 an existing .secret_key on load; regenerate on a
corrupt/invalid secret instead of crashing every encrypt/decrypt call.
- applications: add UniqueConstraint(job_id, resume_id) and catch IntegrityError
so concurrent confirms collapse to one card; clean up the orphaned job when
manual create fails; make the company/role cache write best-effort (never
500); skip (and log) rows with an unknown status instead of failing the list;
type-guard .strip() on non-string LLM company/role (router + extractor).
- migration: default missing created_at/updated_at to now() instead of writing
NULL into non-null timestamp columns.
Adds integration tests for dedupe, unknown-status skipping, and orphan cleanup.
* fix(tracker): surface notes-save and API errors instead of swallowing them
The card detail modal now shows an inline error when saving notes fails (it was
silently swallowed). The tracker API client normalizes FastAPI's `detail` (a
string for HTTPException, an array of {msg,...} for validation errors) so error
messages no longer render as "[object Object]".
* fix(tracker): address round-2 review (generic errors, effect churn, partial write)
Round-2 bot-review fixes:
- Surface only generic, localized error messages in the board, manual-add
dialog, and card modal; never echo raw backend error text inline, which could
leak sensitive values (cubic).
- Scroll/resize listeners no longer re-attach on every card-list change: the
sync fn is inlined and the effect depends on [loading, isEmpty] (cubic, kilo).
- crypto: extract _write_secret() that loops on os.write to handle partial
writes, deduping the two secret-write sites (kilo).
- tracker API client: handle a dict-shaped FastAPI `detail` so errors never
render as "[object Object]" (kilo).
* fix(tracker): round-3 review — write-loop guard, pin cryptography, English date
- crypto: `_write_secret` raises if `os.write` returns 0, guarding a theoretical
infinite loop on a 0-byte write (kilo).
- deps: pin `cryptography==46.0.3` to match the repo exact-pin policy
(Copilot/cubic).
- tracker card modal: format `applied_at` as English `Mon YYYY` via
`toLocaleDateString('en-US', { month: 'short', year: 'numeric' })` (cubic).
* fix(tracker): round-4 review nits — crypto race guard, dedup log, type hint
- crypto: first-run secret generation uses an atomic O_EXCL create and re-reads
the existing secret on conflict, so a concurrent first-run generation cannot
overwrite an already-written key.
- database: log (debug) when create_application collapses a concurrent
(job_id, resume_id) race, so duplicate-submission races are observable.
- models: Job.metadata_json typed as dict[str, Any].
* fix(crypto): atomic secret write eliminates the partial-read race
Write the Fernet secret to a temp file in full, fsync, then move it into place
atomically — an exclusive hard-link for first-run generation (fails if the
secret already exists) and an atomic replace for corrupt-key regeneration. A
concurrent reader can no longer observe a partial key, so there is no
partial-read -> ValueError -> O_TRUNC-overwrite window (kilo round-5).
* chore(deps): bump cryptography from 46.0.3 to 46.0.7 in /apps/backend
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.3 to 46.0.7.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.7)
---
updated-dependencies:
- dependency-name: cryptography
dependency-version: 46.0.7
dependency-type: direct:production
...
Signed-off-by: dependabot[bot] <support@github.com>
* fix(status): isolate /status subsystem checks for graceful degradation
The /status endpoint called check_llm_health and db.get_stats unguarded, so a
failure in either 500'd the whole endpoint and masked the other check. Each is
now wrapped in its own try/except: an LLM-probe or DB-stats failure degrades
only its own field (llm_healthy / has_master_resume + empty stats) while the
endpoint still returns 200. Adds regression tests for both degradation paths.
* fix(status): align llm_configured with key-optional providers; keep tracebacks
Review follow-ups on #843:
- llm_configured now treats openai_compatible as key-optional too (matching
check_llm_health), so a keyless local server is no longer reported
configured=false while healthy=true (Copilot, cubic).
- the degradation handlers use logger.exception() to preserve stack traces
instead of logger.error("...: %s", e) (Copilot, cubic).
Adds a regression test for the openai_compatible case.
* docs: sync agent context for SQLite, encrypted keys, and the application tracker
Align the context/architecture docs with the merged work (PRs #841, #843):
- Root + frontend CLAUDE.md: TinyDB -> SQLite (async SQLAlchemy); add the Kanban
Application Tracker (route, components/tracker, lib/api/tracker.ts, i18n,
local-state note) and per-provider encrypted API keys; bump the pytest count.
- backend-guide / backend-architecture: async SQLAlchemy/SQLite facade,
models/db_engine/crypto, the api_keys table + one-time migration importer, the
/applications router, and /status graceful degradation.
- testing-strategy: SQLite isolated_db fixture + new suites (~444 tests).
- apis/front-end-apis + apis/api-flow-maps: /applications endpoints + per-provider
encrypted /config/api-keys + /status degradation.
- scope-and-principles, e2e_monitor README: TinyDB -> SQLite wording.
Docs-only; no code changes.
* docs: correct API methods, /health label, versions, testing-baseline framing
Review fixes on #844 (Copilot):
- /config/api-keys updates use POST, not PUT (backend-guide + backend-architecture).
- /health is a liveness probe (no LLM call); LLM readiness lives under /status.
- scope-and-principles: Python 3.13+, Next.js 16 (was 3.11+/15).
- testing-strategy: frame the §2.2/§2.3 assessment as the 192-test baseline,
superseded by Phases 4 & 7 (real-SQLite isolated_db persistence tests).
* docs: add resume wizard design
* docs: add resume wizard redesign design spec
Typeform-style, AI-led adaptive wizard with a live resume preview.
Supersedes the section-picker UX from the original implementation plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add resume wizard redesign implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: fix wizard plan — index.ts barrel export drops ResumeWizardOption
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): adaptive resume wizard schemas
Replaces the v1 enum-based wizard schemas with the new Typeform-style
round-trip state model: ResumeWizardState (step, current_question,
history, asked_count, progress), ResumeWizardQuestion, ResumeWizardAnswer,
ResumeWizardHistoryEntry, ResumeWizardProgress, turn/finalize request+
response models, and validated action literals. Five unit tests cover
defaults, action validation, unknown-section rejection, and finalize
name guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(wizard): cover answer length bounds; note internships mapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): adaptive prompt and deterministic service helpers
Replaces the v1 wizard service with a new shape compatible with the
Task-1 schemas: RESUME_WIZARD_MAX_QUESTIONS constant, build_initial_wizard_state,
extract_intro_name, merge_unique_skills, section_prompt, compute_progress,
build_review_warnings, normalize_wizard_resume_data, and helpers. Rewrites the
RESUME_WIZARD_TURN_PROMPT to the one-question-at-a-time adaptive format.
Stubs out the /turn router endpoint (501) so the router stays importable until
Task 3 adds run_ai_turn. All 13 unit tests (Task 1 + Task 2 sets) pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): tighten intro-name regex; cover progress inflection
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): adaptive AI turn, section-scoped merge, back/review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): union-merge list sections so a partial model reply can't drop entries
Work experience / education / projects now merge by content signature instead of
wholesale replacement: existing entries the model omits are preserved, echoed
entries are replaced in place, new ones appended. Adds full-echo and partial-echo
preservation tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): adaptive turn router + finalize
Wire apply_back/apply_review/run_ai_turn into POST /turn (actions:
start/answer/skip/back/review) and replace the 501 stub. Include the
router-mount plumbing (main.py + routers/__init__.py). Replace the old
enum-based integration tests with five anti-theater tests covering the
answer/review/422/finalize/409 paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): log finalize pre-guard errors; cover start/back/skip turns
Move the existing-master pre-guard inside the try so DB errors are logged and
return a generic 500 (not a bare unlogged one). Add integration coverage for the
start, back, and skip turn actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): adaptive wizard API client types
Replace old v1 wizard shape (current_section/assistant_message/options)
with adaptive state shape (current_question, history, asked_count,
is_complete, progress). Remove ResumeWizardOption from barrel export.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(wizard): assert turn body payload and initial-state immutability
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): live resume preview replaces stat panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): explicit aria-hidden on skill check; list-none on bullets
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): Typeform question card; remove section picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): shadow-sw-lg token, progressbar role, h2 heading, stable warning keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): Typeform page orchestrator with live preview
Replace old section-picker/draft-preview page with the new Typeform-style
orchestrator: two-pane layout (QuestionCard + LivePreview), runTurn helper,
localStorage draft persistence (skipped on complete), defensive draft
validator, and finalizeResumeWizard → status cache → router flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): deep-normalize corrupt draft; keep-adding targets a real gap
readSavedDraft now coerces resume_data list fields to arrays so a corrupt or
shape-drifted localStorage draft can't crash the render into an unrecoverable
reload loop. Keep-adding points at the first content gap (not the no-op review
section) so the answer merges. Adds tests for corrupt-draft recovery, skip,
back, and keep-adding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): locale keys for adaptive wizard + API docs
Add resumeWizard i18n keys (title, answerLabel, keepAddingPrompt,
sections.*, actions.*, preview.empty, errors.finalizeFailed) to all
5 locale files (en/es/zh/ja/pt-BR). Remove orphaned v1 keys (kicker,
assistantLabel, actions.finalize, preview.warnings, errors.sectionFailed).
Update front-end-apis.md Resume Wizard section with accurate endpoint docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(wizard): type-safe progress draft restore; prettier formatting
Coerce restored progress.current/total to numbers (fixes a strict-mode TS2352
on the Record->ResumeWizardProgress cast that broke next build). Apply Prettier
to the wizard files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): surface AI 'ready to finish' signal; guard empty-name finalize
Consume the previously-inert is_complete flag: when the AI signals it has enough,
the question card shows a green 'ready to finish' hint (new resumeWizard.readyHint
key, all 5 locales). Add a missing-name review warning and gate the Create button
on a non-empty name so a nameless draft can't hit the generic finalize 422.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wizard): wire dashboard entry, /resume-wizard route, and test infra
Commits the wizard entry point (dashboard choice dialog + tile wiring), the
/resume-wizard route wrapper, the master-resume-choice dialog test, and the
isolated_db conftest registration the integration tests rely on — so the
feature is reachable from the UI and the suite is correct on a fresh checkout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(wizard): address PR review — security, cost guard, robustness
Backend:
- Sanitize the user answer before the LLM prompt (reuse improver._sanitize_user_input).
- Router cost guard: route to review (no LLM) once the question cap is reached.
- Finalize sets the title in the atomic create (no fragile follow-up update).
- Reject whitespace-only answers; keep section-enum keys English in the prompt.
- apply_back derives step from the restored section, not just the count.
Frontend:
- Coerce personalInfo fields to strings on draft restore (corrupt-draft crash).
- Locale-invariant skill casing (toLowerCase) to match backend casefold.
- Stable list keys in the preview; localStorage quota guard; finalize resume_id guard.
Adds tests for sanitization, the cap guard, whitespace rejection, back-step, and
non-string personalInfo recovery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(wizard): assign unique entry ids; index keys in preview
Root cause of the 'two children with key 0' flood: the wizard prompt omits entry
ids, so LLM entries default to id=0 — colliding as React keys and breaking the
builder's Math.max(...ids)+1 add logic on a finalized wizard resume. run_ai_turn
now renumbers workExperience/education/personalProjects entries with unique 1-based
ids; the live preview uses index keys as defense-in-depth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(wizard): stable id keys + token redaction (round 2 review)
- Preview reverts to stable key={item.id} now that ids are guaranteed unique;
draft restore also renumbers entry ids (asEntriesWithIds) so old/hand-crafted
drafts can't reintroduce id=0 key collisions.
- Sanitization now also redacts credential-like tokens (sk-/AIza/Bearer) via
llm._scrub_secrets, not just injection patterns.
- _assign_entry_ids test now covers education + projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(wizard): use a neutral descriptor for the one-question-at-a-time flow
Drop a third-party product brand name from docstrings, a code comment, and the
design/plan docs in favor of the generic 'one-question-at-a-time' wording, to
avoid any trademark concern. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): batch security bumps for backend + frontend dependencies
Combines the 7 open Dependabot PRs into one change, plus a manual
cryptography bump that Dependabot had not yet opened a PR for.
Backend (pyproject.toml + requirements.txt):
- cryptography 46.0.7 -> 48.0.1 (HIGH: GHSA-537c-gmf6-5ccf, vulnerable
OpenSSL bundled in wheels; no Dependabot PR existed for this)
- pydantic-settings 2.12.0 -> 2.14.2 (#866; GHSA-4xgf-cpjx-pc3j symlink escape)
- python-multipart 0.0.27 -> 0.0.31 (#858; GHSA-5rvq-cxj2-64vf quadratic DoS)
Frontend (package.json + package-lock.json):
- undici override ^7.24.1 -> ^7.28.0 (#865; clears 7 advisories incl. 3 HIGH)
- @vitejs/plugin-react ^5.1.4 -> ^5.2.0 + vite 7.3.2 -> 8.0.16 (#856; HIGH
GHSA-fx2h-pf6j-xcff fs.deny bypass; vite 8 drops esbuild for rolldown)
- dompurify 3.4.0 -> 3.4.11 (#864), markdown-it 14.1.1 -> 14.2.0 (#861),
js-yaml 4.1.1 -> 4.2.0 (#862), @babel/core -> 7.29.7 (transitive lockfile bumps)
Verification:
- frontend: npm audit 0 vulnerabilities (was 5); next build OK; 179 tests pass
- backend: uv sync resolves cleanly; 482 tests pass
- vitest@4 peer range already allows vite 8; plugin-react 5.2.0 adds vite 8 peer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(llm): fall back to prompt-only JSON when server rejects response_format (#857)
LM Studio (and some other OpenAI-compatible servers) report response_format
support via LiteLLM's registry but reject {"type": "json_object"} with a 400
("'response_format.type' must be 'json_schema' or 'text'"). complete_json's
existing JSON-mode fallback only handled malformed-JSON responses, so the 400
fell through to the generic handler and was re-raised — the Router does not
retry bad requests — failing every resume-wizard turn with a 500.
Detect a response_format rejection (new _is_response_format_unsupported helper)
and, when JSON mode is active, disable it and retry prompt-only. Genuine bad
requests (e.g. context-length errors) still propagate without an extra retry.
Tests: add coverage for the response_format-rejection fallback and for an
unrelated 400 failing fast (no retry). Full backend suite: 484 passed.
Fixes #857
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(llm): tighten response_format-rejection detection (PR review)
Address review feedback (cubic/Kilo) that the bare "response_format" substring
match could fire on an unrelated 400 that merely names the parameter (e.g. a
context-length error), wasting a retry.
Now require both a "response_format" mention AND a rejection/validation cue
("must be", "not support", "unsupported", "not allowed", "invalid"). The cue
set is deliberately broader than the suggested list so it still catches varied
provider wording such as "not supported" — narrowing to only {"must be",
"unsupported", "not allowed", "invalid"} would miss that phrasing and
re-introduce #857 for those providers.
Tests: add a varied-wording ("not supported") fallback case; change the
negative test to a context-length 400 that also names response_format (the
exact false-positive raised in review) and assert it still fails fast.
Full backend suite: 485 passed.
* Fix post-merge tests for async SQLite and LLM JSON fallback.
Use AsyncMock for database patches after the upstream SQLite migration,
and align response_format fallback tests with openai_compatible skipping
JSON mode on the first request.
* Fix post-merge async DB and multi-role work experience gaps.
Await SQLite calls in tailor-length endpoints, update e2e monitor seeding
for resum…
Fixes #805 (AI tailor only ever rewrote the same ~5 descriptions + summary, and lowercased text). A clean re-implementation of the useful parts of #809, plus a maintainer-requested e2e-monitor tuning.
Why not just merge #809
I reviewed #809 closely (security + correctness). It's not malicious (no exec/eval/network/deps/CI touched), but pulling it as-is has three problems, so this PR reworks it:
add_skillpath whileimprover.pystill computes/accepts them → prompt↔code desync that silently disables JD-sourced skill additions. Here we keep Bump django from 3.2.11 to 3.2.12 #10/Bump pillow from 9.0.0 to 9.0.1 #11 and add casing as rule Bump notebook from 6.4.1 to 6.4.10 #12.field_type="language"/"award"but never updates theResumeFieldDiffLiteral inmodels.py→ PydanticValidationErrorwhen those diffs are built. Fixed (backend Literal + frontend union).education[i].description[j](list/bullet), butEducation.descriptionis a scalarstr | None. Here it's scalar-only, with a test asserting the[j]form is rejected.Changes
#805fix (app):education[i].description(scalar),additional.languages,additional.certificationsTraining,additional.awards. Educationdegree/institution/yearsstay blocked (leaf-name check + scoped regex).add_skill; document new targetable paths.calculate_resume_diff: surface education-description, language, award changes in the preview modal; addlanguage/awardtoResumeFieldDiff.field_type.e2e-monitor tuning (maintainer-requested):
JD_KEYWORD_TOLERANCE = 0.20to the judge rubric: incorporating JD keywords/skills the master lacked is treated as legitimate ATS tailoring (not fabrication) up to ~20% of content. Hard structural guards unchanged (no_fabricated_employers,personal_info_unchanged); invented employers/titles/dates and wholesale profession swaps still penalized.Verification
uv run pytest→ 365 passed, 1 deselected (gated eval).🤖 Generated with Claude Code
Summary by cubic
Expands the tailor’s diff scope to education descriptions, languages, certifications, and awards while preserving original capitalization. Updates the e2e monitor judge to allow ~20% JD keyword incorporation without truthfulness penalties.
New Features
JD_KEYWORD_TOLERANCE = 0.20injudge.pyand documents policy inAGENT_PLAYBOOK.md(scores may rise; baseline may need refresh).Bug Fixes
languageandawardtoResumeFieldDiff.field_type(backend + frontend).education.description; rejecteducation[i].description[j]and keep other education fields blocked.descriptionin entry-level compare; editing only the description yields one change.Written for commit fba7dae. Summary will update on new commits.