Skip to content

fix(tailor): expand diff scope + preserve casing (#805); e2e-monitor JD-keyword tolerance#827

Merged
srbhr merged 3 commits into
devfrom
fix/805-tailor-scope-casing
Jun 1, 2026
Merged

fix(tailor): expand diff scope + preserve casing (#805); e2e-monitor JD-keyword tolerance#827
srbhr merged 3 commits into
devfrom
fix/805-tailor-scope-casing

Conversation

@srbhr

@srbhr srbhr commented Jun 1, 2026

Copy link
Copy Markdown
Owner

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:

  1. Undisclosed regression: fix(tailor): expand diff scope and preserve casing (fixes #805) #809 overwrites diff-prompt rules Bump django from 3.2.11 to 3.2.12 #10/Bump pillow from 9.0.0 to 9.0.1 #11 (verified skill targets) and removes the add_skill path while improver.py still 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.
  2. Latent crash: fix(tailor): expand diff scope and preserve casing (fixes #805) #809 emits field_type="language"/"award" but never updates the ResumeFieldDiff Literal in models.py → Pydantic ValidationError when those diffs are built. Fixed (backend Literal + frontend union).
  3. Correctness nit: fix(tailor): expand diff scope and preserve casing (fixes #805) #809 allows education[i].description[j] (list/bullet), but Education.description is a scalar str | None. Here it's scalar-only, with a test asserting the [j] form is rejected.
  4. Scope creep: fix(tailor): expand diff scope and preserve casing (fixes #805) #809's branch drags in unrelated [Bug]: Enter key doesn't create new lines in Additional Info textareas #763/fix(builder): allow Enter key to create new lines in Additional Info textareas (#763) #788 frontend changes (empty-line filtering in templates/forms). Excluded — those belong in their own PR.

Changes

#805 fix (app):

  • Allow-list: education[i].description (scalar), additional.languages, additional.certificationsTraining, additional.awards. Education degree/institution/years stay blocked (leaf-name check + scoped regex).
  • Diff prompt: add casing-preservation rule (Bump notebook from 6.4.1 to 6.4.10 #12); keep verified-skill-target rules + add_skill; document new targetable paths.
  • calculate_resume_diff: surface education-description, language, award changes in the preview modal; add language/award to ResumeFieldDiff.field_type.
  • Frontend: render Language/Award change sections; i18n keys in all 5 locales (parity verified).
  • Tests: new allowed paths apply; blocked education fields still rejected; scalar-only education description.

e2e-monitor tuning (maintainer-requested):

  • Add documented, tunable JD_KEYWORD_TOLERANCE = 0.20 to 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.
  • ⚠️ The committed monitor baseline may warrant a refresh (judge scores can rise; regressions only fire on drops) — left to a deliberate maintainer commit.

Verification

  • uv run pytest365 passed, 1 deselected (gated eval).
  • Locale JSON parses + key parity across en/es/ja/pt-BR/zh verified.
  • Frontend lint/build NOT run locally (nvm constraint) — relies on reviewer/CI.

🤖 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

    • Allow targets: education[i].description (replace only), additional.languages, additional.certificationsTraining, additional.awards; degree/institution/years remain blocked.
    • Diff preview shows Language and Award changes; i18n keys added in en/es/ja/pt-BR/zh.
    • e2e monitor: adds JD_KEYWORD_TOLERANCE = 0.20 in judge.py and documents policy in AGENT_PLAYBOOK.md (scores may rise; baseline may need refresh).
  • Bug Fixes

    • Avoid validation errors by adding language and award to ResumeFieldDiff.field_type (backend + frontend).
    • Enforce scalar-only education.description; reject education[i].description[j] and keep other education fields blocked.
    • Deduplicate education diffs by ignoring description in entry-level compare; editing only the description yields one change.
    • Diff logic surfaces education description, language, and award changes while ignoring list reorders.

Written for commit fba7dae. Summary will update on new commits.

Review in cubic

srbhr and others added 2 commits June 1, 2026 22:10
…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>
Copilot AI review requested due to automatic review settings June 1, 2026 16:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) plus additional.languages, additional.certificationsTraining, and additional.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_TOLERANCE parameter 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))):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@kilo-code-bot

kilo-code-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 1 WARNING, 1 SUGGESTION (1 FIXED) | Recommendation: Address WARNING before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1

Incremental Changes (since previous review)

Two new commits added:

  • improver.py line 1402: Added {"description"}, ignore_keys to education _append_entry_changes call — fixes the previous SUGGESTION about duplicate education description diffs.
  • test_resume_diff.py: Added 4 regression tests for education dedup, language add/remove, language order ignored, and award add. Tests are clean and well-structured.

No NEW issues found in the incremental diff.

Issue Details (click to expand)

WARNING (Carried Forward)

File Line Issue
apps/backend/app/services/improver.py 1302 Education descriptions compared by array index — if entries are added/removed at different positions, descriptions mismatch against wrong entries (workExperience uses SequenceMatcher for alignment)

SUGGESTION (Carried Forward)

File Line Issue
apps/backend/app/schemas/models.py 475–484 ResumeDiffSummary model doesn't track languages/awards — summary stats will omit these new change types entirely

RESOLVED

File Line Issue
apps/backend/app/services/improver.py 1402 Education _append_entry_changes missing ignore_keys={"description"}FIXED: Now passes {"description"} correctly, matching the workExperience pattern
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
apps/backend/app/schemas/models.py 475–484 ResumeDiffSummary has fields for skills, descriptions, and certifications but no equivalent for languages or awards added/removed. The calculate_resume_diff function now generates diffs with field_type="language" and field_type="award", but the summary never counts them. Frontend stat cards only display the tracked fields. Consider adding languages_added, languages_removed, awards_added, awards_removed to ResumeDiffSummary and updating the summary builder at lines 1414–1426.
Files Reviewed (11 files total, 2 incremental)
  • apps/backend/app/prompts/templates.py — No issues
  • apps/backend/app/schemas/models.py — 1 SUGGESTION (ResumeDiffSummary gap)
  • apps/backend/app/services/improver.py — 1 WARNING (carried forward), 1 SUGGESTION (resolved)
  • apps/backend/e2e_monitor/AGENT_PLAYBOOK.md — No issues
  • apps/backend/e2e_monitor/judge.py — No issues
  • apps/backend/tests/unit/test_apply_diffs.py — No issues
  • apps/backend/tests/unit/test_resume_diff.py — No issues (4 new tests added incrementally)
  • apps/frontend/components/common/resume_previewer_context.tsx — No issues
  • apps/frontend/components/tailor/diff-preview-modal.tsx — No issues
  • apps/frontend/messages/{en,es,ja,pt-BR,zh}.json — No issues (i18n parity verified)

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>
@srbhr

srbhr commented Jun 1, 2026

Copy link
Copy Markdown
Owner Author

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 _append_entry_changes for education needed ignore_keys={"description"} like workExperience, otherwise a description-only edit produced a second spurious education[i] modified entry (with identical degree/institution/years labels). Added a regression test (test_education_description_change_is_not_duplicated) + language/award diff tests.

↩️ 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 education[i].description, and degree/institution/years stay blocked — so original/improved education lists are always the same length and order. The entry pairing here is the same index-based approach the existing workExperience entry loop uses; SequenceMatcher in _append_list_changes aligns bullets within an entry (a list), which doesn't apply to education's scalar description. Matching by a stable key for a list that can't reorder would add complexity without changing behavior.

🕒 SUGGESTION (models.py — ResumeDiffSummary lacks languages/awards counts): deferred (non-blocking, cosmetic). The summary is a coarse stats roll-up; the preview modal renders language/award changes from detailedChanges, not from the summary, so the feature is complete without it. Adding languages_added/removed etc. would also mean new frontend stat cards — happy to do it as a follow-up if you want the stat row to include them.

Full suite: 369 passed, 1 deselected.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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$"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>

@srbhr
srbhr merged commit 634c465 into dev Jun 1, 2026
3 checks passed
@srbhr
srbhr deleted the fix/805-tailor-scope-casing branch June 1, 2026 17:01
lachlanmag added a commit to lachlanmag/Resume-Matcher that referenced this pull request Jun 29, 2026
* 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…
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