Skip to content

feat: UI gaps batch — labels, disclosure, schema validation, layout UX#121

Closed
warnes wants to merge 66 commits intomainfrom
feat/llm-sanitizer-integration
Closed

feat: UI gaps batch — labels, disclosure, schema validation, layout UX#121
warnes wants to merge 66 commits intomainfrom
feat/llm-sanitizer-integration

Conversation

@warnes
Copy link
Copy Markdown
Contributor

@warnes warnes commented Apr 22, 2026

Summary

Closes 18 UI/UX gap items from the session_20260420_ui-gap-review OBO session.

Changes

Frontend

  • GAP-49/30/41/36 — Spell-check confirm gate; cover letter opening styles; Master CV tab; first-run onboarding (a5fc40a)
  • GAP-37 — First-visit welcome modal with 3-phase workflow overview (fd7c2a4)
  • GAP-25 — Real undo for layout instructions (12462bf)
  • GAP-28/29 — Publications heading: "Selected Publications" for subsets, "Publications" for all; venue warning (ad9edf0, f501e1e)
  • GAP-48 — Remove duplicate showAlertModal from ui-core.js (01c10c9)
  • GAP-45 — Gate rewrite submission on persuasion warning acknowledgement (732a431)
  • GAP-39 — Surface cover letter and screening DOCX files in the File Review tab (76bee01)
  • GAP-26 — Replace raw phase enum strings with human-friendly labels in session restore messages (126747b)
  • GAP-46 — Show one-time LLM data-transmission disclosure on first job analysis (5839b72)
  • GAP-38 — Rename session Delete button to "Move to Trash" (e8e0ee3)
  • GAP-47 — Show pt equivalent alongside px value in the font size control (ceec3bd)

Backend

  • GAP-42 — Add certifications to GET /api/master-data/full (345bd66)
  • GAP-32 — Persist ATS score and validation_results to metadata.json at compute time (065a88e)
  • GAP-43 — Add post-write schema validation with backup-restore to _save_master (e95e7ba)
  • GAP-31 — Lower cover letter generation prompt ceiling from ~300–400 to ~250–300 words (e0212e3)

Chore

  • Resolve 55 ambiguous specstory history files (3e0bae8)
  • Add linkedown workspace folder (a5d47c1)

Test Results

  • JS: 1116/1116 tests pass
  • Python cover-letter: 40/40 tests pass
  • Python schema validation: all relevant tests pass

warnes added 30 commits April 6, 2026 18:17
…JSON dump

- Add `_json_ld_blobs_to_markdown()` to job_routes.py that dynamically
  renders all JSON-LD blob fields as human-readable markdown with no
  hard dependency on any specific field like `description`
- Heading keys (title, name) become ## headers; meta fields
  (hiringOrganization, jobLocation, employmentType, datePosted, baseSalary)
  render as bold inline/block metadata; body fields (qualifications,
  responsibilities, skills, description, etc.) render as ### sections;
  all remaining fields append as bold key-value pairs
- HTML content in field values is stripped to plain text via BeautifulSoup
  before embedding in the markdown output
- Remove `json_ld_text` tracking (description-only fallback); both the
  SPA-noise branch and the hybrid branch now call `_json_ld_blobs_to_markdown`
  so no fields are dropped
- Fix nested-dict inline rendering: when a sub-field renders to multiple
  lines, insert a newline before the content instead of concatenating inline
- Add `_is_section_header()` guard in `_infer_position_name` so generic
  markdown labels (Description, Qualifications, etc.) are not mistaken for
  position titles when JSON-LD blobs have no title/name field
- Update test assertion from exact-string match to substring containment
  to match the enriched markdown output format
- Rewrote test_ui_auth.py for the 4-step wizard/pill model: TestLlmStatusPill
  and TestModelWizard replace the defunct TestAuthBadge/TestAuthModal classes;
  full wizard integration tests navigate to the copilot-oauth auth step
- Deleted TestSessionPicker (4 tests) -- .load-item-row intentionally removed
- Split stale test_generate_calls_api_rewrites_then_action into two accurate
  tests reflecting the current rewrite-then-user-continues flow
- Fixed _fetchAndDisplayLayoutPreview() in layout-instruction.js: guard with
  GENERATION_PHASES so passive restore (idle/confirmed) never calls
  markPreviewGenerated(), preventing phase regression in browser-restore tests
- Rebuilt bundle.js after layout-instruction.js changes

chore(tooling): add Playwright test runner helpers

- Add "test:ui" npm script (conda run -n cvgen pytest tests/ui/)
- Add test_ui.sh executable script with pass-through pytest args
Implements the plan in .github/prompts/plan-stepPillNavStateModel.prompt.md.

Bug fix: navigating back to an earlier tab via the tab bar or a step pill
previously showed a confirmation modal whose "Proceed" callback called
switchTab() -- but there was no visible path to return to the active step.
The browsing-away amber ring now serves as the return affordance.

Core changes
------------
* Remove the tab-click guard from ui-core.js setupEventListeners() --
  tab clicks go directly to switchTab(), no modal.
* Remove _showReRunConfirmModal calls from handleStepClick back-nav path --
  clicking a completed step pill now browses directly, no modal.
* Change confirmReRunPhase to call backToPhase(step) instead of reRunPhase(step)
  -- the hover rerun button repositions the phase pointer and lets the user
  press the action button rather than triggering a silent LLM re-run.

Visual model (two independent signals)
---------------------------------------
Fill   = app state  (blue=active, green=completed, amber=stale, red=stale-critical)
Ring   = view cursor (blue solid ring on viewed step, amber pulsing ring on
         active pill while browsing elsewhere)

CSS additions (styles.css)
  .step.viewing        -- solid 2px blue ring; transition 0.2s
  .step.browsing-away  -- pulsing amber ring via @Keyframes browsing-pulse
  inset box-shadows removed from .step.stale/.step.stale-critical (background
  colour is sufficient; avoids compound box-shadow conflict with ring)

JS additions (workflow-steps.js)
  _getStepTooltip()        -- pure state->tooltip-text function
  _updateViewingIndicator() -- applies .viewing/.browsing-away classes and
                              Bootstrap 5 tooltips to each step pill; exported
  updateWorkflowSteps()    -- calls _updateViewingIndicator at end; handles
                              status.stale_steps to apply .stale class
  handleStepClick()        -- simplified: no modals, direct switchTab()

review-table-base.js
  switchTab() calls _updateViewingIndicator(tab) after setCurrentTab()

Backend (Phase D)
-----------------
* StatusResponse dataclass gains stale_steps: List[str]
* status route populates stale_steps from conversation.state
* back_to_phase() computes stale_steps = all steps after target phase;
  stored in session state so fetchStatus() reflects it immediately

Tests: 1058/1058 JS, 1154/1155 Python (pre-existing Playwright Copilot-auth
       browser test unrelated to this change)
feat(ui): step-pill two-signal nav state model
…fore analysis

Session modal (fix lost due to concurrent agent edits):
- loadSessionAndCloseModal() now clears dismissDisabled before calling
  closeSessionsModal(), so clicking Load in the startup-required sessions
  modal always dismisses it even when opened with required:true.

Job URL fetch:
- fetchJobFromURL() now switches to the job tab and renders the fetched
  content via populateJobTab() instead of immediately calling analyzeJob().
- populateJobTab() shows a primary "Analyze Job" button when phase is
  still 'init', giving the user a chance to review the content first.
- Confirmation message tells the user to click Analyze Job to continue.

Tests: increase default CV_SERVER_STARTUP_TIMEOUT from 15→30s in
tests/ui/conftest.py to match the timeout used in recent test runs.
- Correct JSDoc block ordering in layout-instruction.js:
  _buildPreviewPayload doc now precedes its function; the
  _fetchAndDisplayLayoutPreview strategy docstring now sits directly
  above that function instead of above the helper (orphaned by insertion)

- Remove unused `import pytest` from tests/ui/test_ui_auth.py (leftover
  from deleted pytest.skip() pattern in the old TestAuthModal class;
  new wizard tests assert directly, so pytest is not needed)

- Make test_ui.sh portable: replace hardcoded Caskroom Python path
  (/usr/local/Caskroom/miniconda/base/envs/cvgen/bin/python) with
  `conda run -n cvgen python` (consistent with npm run test:ui)

All 102 Playwright UI tests pass.
…nd_customizations

analyze_job must advance to JOB_ANALYSIS, not CUSTOMIZATION.
CUSTOMIZATION is only correct after recommend_customizations runs.
If the backend jumped to CUSTOMIZATION here, a server restart between
analysis and recommendations would restore the session with an empty
Customise tab (the frontend _resolveRestoredPhase guard in session-manager.js
compensated, but the root cause was in the backend).

recommend_customizations must advance to CUSTOMIZATION, not REWRITE_REVIEW.
The user reviews recommendations in the Customise tab before proceeding to
the Rewrite tab. Premature REWRITE_REVIEW caused a restart to drop the user
into an empty Rewrite tab, skipping Customise entirely.

Correct phase sequence:
  analyze_job              -> JOB_ANALYSIS
  recommend_customizations -> CUSTOMIZATION
  user advances            -> REWRITE_REVIEW

Restore two regression tests deleted by the concurrent agent:
  test_analyze_action_sets_job_analysis_phase
  test_recommend_customizations_action_sets_customization_phase

The _resolveRestoredPhase frontend guards added in PR #88 are intentionally
kept as defence-in-depth; the backend fix + frontend guard together are more
robust than either alone.

Fixes were originally present in the dev stash (stash@{0}) before being
overwrote by concurrent agent edits during PR #88 work.
fix(review): address 3 code review findings from post-refactor review
… layout (#70)

Issue #70: EXPERIENCE section should flow after Achievements when space allows,
rather than always starting on a forced second page.

Root cause: the cv-template used two separate #page-one / #page-two <div>
elements, each with their own float context. The rigid two-div structure
forced a hard CSS page-break between them, making it impossible for content
to flow naturally from the first logical page to subsequent print pages.

Fix: merge the two page divs into a single #cv-body div with one unified
left-col (contact -> education -> awards -> certs -> languages -> skills)
and one right-col (header -> summary -> achievements -> experience ->
publications). Both columns now flow across print pages naturally.

CSS changes:
- Screen: replace #page-one / #page-two per-div selectors with #cv-body
  selectors; left-col uses align-self: stretch; right-col uses flex: 1
- Print: float-based two-column layout on #cv-body with box-decoration-break:
  clone on the sidebar for repeating background; add break-inside: avoid on
  .section so whole sections move to the next page rather than splitting
- Remove: forced page-break-before: always on #page-two, fixed 11in
  min-height constraints, and the two-float-context mismatch
- Update inline JS page-break marker injection to target #cv-body

Test changes:
- TestOptionalSidebarFields: updated 3 tests to check #cv-body selectors and
  new print CSS rules instead of deprecated #page-one / #page-two rules
- TestExperiencePageFlow: updated all 6 tests to use cv-body slice instead of
  page-two; replaced the first-page/second-page heading test with a
  document-order assertion (achievements before experience in HTML order)
- 88 tests pass: test_cv_template.py + test_benchmark_cv_render.py +
  test_template_renderer.py; also test_layout_digest.py + test_ats_generation.py
When a job description is fetched from a URL, the session now exposes
that URL through /api/status so the frontend can link back to the
original posting.

Changes:
- Add `job_url: Optional[str]` field to StatusResponse dataclass
- Wire `conversation.state.get("job_url")` into the StatusResponse
  construction in status_routes.py
- In populateJobTab(), wrap the position-title <h1> in an <a> link
  (color:inherit, text-decoration:none) when job_url is present
- Add a subtitle line below the <h1> showing the full URL with CSS
  overflow-ellipsis truncation and a title= tooltip for hover; both
  links open in a new tab with rel="noopener noreferrer"
- Rebuild web/bundle.js

No URL display is shown when the job was pasted rather than fetched.
- Advance to JOB_ANALYSIS (not CUSTOMIZATION) after analyze_job in both
  the web _execute_action path and the CLI loop; CUSTOMIZATION is only the
  correct phase once recommend_customizations has run
- Advance to CUSTOMIZATION (not REWRITE_REVIEW) after recommend_customizations;
  REWRITE_REVIEW is only set by the /api/cv/rewrites route after the user
  clicks through the Customise tab
- Add _resolveRestoredPhase guards: if restored phase is customization or
  rewrite_review but no customizations exist in the session, fall back to
  job_analysis so restarts mid-workflow place the user at the right step
- Export _resolveRestoredPhase for testability
- Add 2 Python regression tests and 7 JS tests covering all guard branches

fix(job-input): improve HTML-to-text conversion for job descriptions

- Preserve line breaks from <br> and block-level elements before extracting
  plain text in _json_ld_blobs_to_markdown, preventing word run-on from
  inline-concatenated HTML
- In _renderJobText detect raw HTML stored in older sessions and sanitize
  directly via DOMPurify instead of running through marked, avoiding
  double-parse corruption while keeping XSS protection on both code paths
Fixes two bugs exposed when loading a prior session:

1. Two different question sets on session restore
   - analyze_job now uses a JSON-schema prompt so questions arrive as
     structured data in one LLM call; conversation_history (human-readable)
     and state[post_analysis_questions] (structured objects) are populated
     from the same parsed result, eliminating the regex-divergence mismatch.
   - Add _parse_json_questions_response() with markdown-fence stripping,
     json.loads fast path, and bracket-depth fallback for robustness.

2. Missing chat history on session restore
   - fetch_job_url and load_job_file now append a user message to
     conversation_history so job submissions appear in the restored log.
   - do_action() calls _save_session() immediately after _execute_action()
     so history is persisted even if client never calls
     /api/post-analysis-responses.

3. Collapsible long messages in chat
   - Add _makeCollapsibleContent() in message-queue.js: messages exceeding
     8 lines or 480 chars show a preview with a Show N more lines toggle.
   - Add backtick inline-code rendering alongside existing bold/italic.
   - Add .collapsible-msg, .msg-preview, .msg-full, .msg-toggle-btn CSS.

4. Update test_analyze_action_includes_extracted_questions_in_context to
   mock the LLM response as JSON rather than free-form numbered text.
…rser

The layout-instruction path in apply_layout_instruction() used a bare
json.loads() call with no fallback. Any model response wrapped in a
markdown fence (```json ... ```) would crash with JSONDecodeError rather
than being gracefully recovered.

Changes:
- Wrap json.loads() in try/except json.JSONDecodeError; fall through to
  self.llm._parse_json_response() which handles bracket-scan extraction
- Extend the outer except handler to also catch ValueError (raised by
  _parse_json_response when no JSON container is found), returning the
  same parse_error dict that json.JSONDecodeError produced before
- Add regression test: markdown-fenced layout response must parse
  successfully via the bracket-scan fallback
- Update test_invalid_json_response_includes_raw_response to mock
  _parse_json_response.side_effect = ValueError, reflecting the real
  method's behaviour for un-parseable text

Part of: feat/structured-json-output (Phase 1 of 4)
…r OpenAI providers

Adds json_mode: bool = False to the LLMClient.chat() abstract method and all
concrete implementations, then enables native response_format enforcement on
providers that support it.

Provider behaviour:
- OpenAIClient (and subclasses GroqClient, GitHubModelsClient, CopilotClient):
  pass response_format={"type": "json_object"} to the API when json_mode=True,
  constraining output at the API level rather than relying on prompt text alone
- CopilotOAuthClient: same — adds response_format to the raw JSON payload
- AnthropicClient, GeminiClient, CopilotSdkClient, LocalLLMClient, StubLLMClient:
  accept json_mode param but ignore it; enforcement remains prompt-only for
  providers that do not support a native JSON mode parameter
- call_llm() convenience wrapper gains the same param and passes it through

Internal call sites updated to pass json_mode=True:
- analyze_job_description() — returns structured job-analysis dict
- recommend_customizations() — returns structured recommendations dict
- rank_publications_for_job() — returns structured publication ranking list
- _propose_rewrites_via_chat() — returns structured rewrite proposals list

Call sites intentionally NOT updated (non-JSON responses):
- generate_professional_summary() — plain text
- rewrite_achievement() — plain text
- semantic_match() — numeric score
- convert_to_bibtex() — raw BibTeX text

Part of: feat/structured-json-output (Phase 2 of 4)
Add runtime structured output validation for the three heavy LLM calls:

- New `scripts/utils/llm_response_models.py`:
  - `JobAnalysisResponse` (10 required fields + optional `reasoning`)
  - `CustomizationResult` with nested `ExperienceRecommendation`,
    `SkillRecommendation`, `AchievementRecommendation`, `SuggestedAchievement`
  - `PublicationRankingItem` (cite_key, relevance_score, confidence, etc.)

- New `LLMClient._validate_with_repair()` helper:
  - Validates a parsed dict against a Pydantic v2 BaseModel
  - On `ValidationError`, extracts missing/invalid field paths and issues a
    targeted one-shot repair prompt to the LLM (with json_mode=True)
  - Re-validates the repaired response; re-raises if still invalid

- Wired validation into three callers in `llm_client.py`:
  - `analyze_job_description` → validates with `JobAnalysisResponse`
  - `recommend_customizations` → validates with `CustomizationResult`
  - `rank_publications_for_job` → validates each array item with
    `PublicationRankingItem`; invalid items after repair are skipped with a
    warning rather than crashing the full ranking call

All 278 tests pass (pytest tests/test_llm_client.py
tests/test_layout_instructions.py tests/test_cv_orchestrator.py
tests/test_conversation_manager.py).
…ions

Wire json_mode=True into the structured clarifying-questions chat call so
API providers (OpenAI, GitHub Models, CopilotOAuth) enforce JSON output at the
protocol level rather than relying only on prompt instructions.

- `conversation_manager.py`: add `json_mode=True` to `self.llm.chat()` call
  that generates post-analysis clarifying questions (the call that feeds
  `_parse_json_questions_response`)

- Remove dead `_extract_structured_questions()` method — it parsed numbered
  free-form text but was never called; the flow has used `_parse_json_questions_response`
  (JSON-first) since the structured-questions prompt was introduced

- `tests/test_conversation_manager.py`: remove two tests that directly exercised
  the deleted `_extract_structured_questions` helper; retain the remaining
  test asserting phase transitions (61 tests pass)

All non-browser tests pass: 1248/1250 (2 pre-existing Playwright timeouts).
Four findings from systematic code review; three resolved with code changes,
one accepted as intentional design.

Finding 1 — CopilotSdkClient ignores json_mode (FIXED):
  scripts/utils/llm_client.py: When json_mode=True, prepend a hard system
  instruction 'Respond with valid JSON only. No prose, no markdown fences.'
  before forwarding messages to any-llm. The copilotsdk provider converts
  messages to a flat prompt and has no response_format API param; the system
  message provides equivalent enforcement.

Finding 2 — JobAnalysisResponse / CustomizationResult all-optional fields
  (DESIGN ACCEPTED):
  scripts/utils/llm_response_models.py: Added docstring comments explaining
  that all fields intentionally have defaults — type safety without breaking
  partial responses. Empty fields degrade gracefully in the downstream workflow.

Finding 3 — No tests for _validate_with_repair() repair path (FIXED):
  tests/test_llm_client.py: Added TestValidateWithRepair class with 3 tests
  covering all code paths — immediate success, repair-on-failure, and
  ValidationError re-raise on persistent failure.

Finding 4 — confidence field not validated against enum (ACCEPTED):
  scripts/utils/llm_response_models.py: Added inline comments
  '# expected: high | medium | low' to all four confidence fields.
  Kept as plain str for flexibility since confidence is display-only.

CodeQL: 15 pre-existing findings in routes/ (path-injection, SSRF,
stack-trace-exposure) — none in the modified files.
All tests: 279 passed.
Security fixes (CodeQL):
- auth_routes: remove stack trace exposure from HTTP 500 responses in
  set-model endpoint; add logger.warning for auth poll thread failures
- job_routes: add DNS resolution check to block DNS-rebinding SSRF
  attacks on bare hostnames
- session_routes: replace user-supplied path concatenation with a
  server-side enumeration helper (_resolve_session_path) to prevent
  path-injection vulnerabilities across load, delete, rename, restore,
  and trash endpoints

Test fixes (Playwright):
- test_web_ui_workflow: increase page-ready timeout (5 s to 15 s) to
  avoid intermittent startup failures on slow CI runners
- mock_responses: add missing structured_output field to mock analysis
  fixture so phase-advance assertions pass

Logging improvements:
- auth_routes: log Copilot auth poll failures at WARNING level before
  storing error string for UI (background thread was completely silent
  on server logs)
- job_routes: existing _requests.RequestException and bare Exception
  handlers already use logger.exception; Timeout/ConnectionError are
  user-expected conditions returning 400/500 with instructions
- session_routes: silent except-pass clauses in listing loops are
  intentional (skip corrupt session.json); outer handlers already use
  logger.exception
…utput

feat(conversation): structured JSON output with Pydantic validation and self-repair
Backend:
- auth_routes: add _persist_provider_model_to_config() -- atomic YAML
  write (tmp+backup) so model/provider changes in the UI survive restart
- llm_client: forward llm_request_timeout config value to OpenAI,
  Anthropic, and Gemini API calls (guards against hanging requests)
- run_codeql.sh: add --custom-only mode to run .github/codeql/ queries
  against the VS Code extension's cached DB (~1-5 min vs ~15 min)

Frontend:
- job-analysis.js: skip extractStructuredQuestionsFromAssistantText
  when structured questions already present from API response
- job-input.js: add FORBID_ATTR:['style'] to DOMPurify to strip
  inline CSS from Word/JSON-LD job descriptions
- message-queue.js: use display='block' (not '') in show-more toggle
- session-manager.js: trigger background testCurrentModel() health
  check after session restore and loadSessionFile
- utils.js: guard cleanJsonResponse against non-string input

Tests:
- test_llm_client: patch utils.config.get_config in Anthropic and
  Gemini chat tests to isolate them from real config.yaml timeout value

CodeQL queries: add 13 custom queries under .github/codeql/ covering
  unlogged exceptions, swallowed exceptions, exception detail in
  responses, SSRF path traversal, hardcoded secrets, LLM calls without
  timeout, master-data writes, Flask route inventory, and more
Replace silent `except: pass` blocks with structured log messages, add a
slow-state badge to the LLM busy overlay, update the default provider/model,
tighten CodeQL queries, and remove a dead workspace entry.

Logging improvements
- auth_routes: log non-critical provider/model persistence failure at DEBUG
- generation_routes: log materialization failure and git-commit exceptions at WARNING
- master_data_routes: log failed backup restoration at WARNING
- session_routes: log unreadable session files at DEBUG; scan errors at WARNING
- status_routes: log skipped unreadable sessions during clarification search at DEBUG
- conversation_manager: log skipped session files and layout digest failures
- copilot_auth: log unreadable token cache at DEBUG
- session_registry: log timestamp parse failures at DEBUG

UI — LLM busy overlay
- Add "Taking longer than usual" pill badge that appears in slow-state overlay
- Badge is hidden by default; shown only when the `.slow` class is applied

Config
- Switch default_provider to copilot-sdk and default_model to gpt-5-mini

CodeQL
- llm-call-without-timeout: exclude provider chat() impls that manage timeout
  internally via config, eliminating misleading false positives
- master-data-write-outside-window: remove cv_data from master-var predicate
  (always a local rendering copy, never the master dict)
- path-traversal: add isBarrier for _resolve_session_path, _resolve_backup_path,
  and safe_join helpers; narrow sink to os.path.join only

Chore
- Remove dead clem-diagrams entry from cv-builder.code-workspace
Add 15 new tests covering:
- _getStepTooltip: all tooltip states (upcoming, active+viewing, browsing-away,
  completed+viewing/not-viewing, stale-critical, stale)
- _updateViewingIndicator: viewing class applied, other pills cleared,
  browsing-away on active pill, no browsing-away when viewing active pill,
  tab alias mappings (questions→analysis, exp-review→customizations),
  unknown tab clears all rings
…firmed

- Remove previewAvailable guard from the fresh-render path so
  generation_state.preview_html is always populated before confirm-layout,
  covering the legacy generate_cv action that writes files to disk without
  updating backend generation state
- Collapse the now-redundant recovery path into the fresh-render path;
  passive restore is used only for confirmed/final_complete sessions or
  when generate-preview is unavailable (no job_analysis yet)
- Regenerate web/bundle.js
…et leakage

.specstory/history/ files can contain API keys from chat sessions.
Also untrack .specstory/statistics.json which was previously committed.
…rvive tab navigation

Agent-Logs-Url: https://github.com/Warnes-Innovations/cv-builder/sessions/ad3a0147-1320-484e-abdc-a603588e6fae

Co-authored-by: warnes <6144863+warnes@users.noreply.github.com>

# Conflicts:
#	web/bundle.js
warnes added 27 commits April 20, 2026 17:04
Replace the bespoke 11-pattern substring list in cv_orchestrator.py with
a centralised prompt_safety module backed by the llm-sanitizer library.

Changes
-------
scripts/utils/prompt_safety.py (new)
  - Lazy Scanner singleton (thread-safe module-level initialisation).
  - scan_text_for_injection(text, min_risk) — union of llm-sanitizer rule
    scan and supplementary substring check; covers zero-width chars, base64
    payloads, homoglyphs, data-exfil patterns AND cv-builder-specific phrases
    (system prompt, agent instruction, etc.) that must be detected in plain-
    text DOM fragments (comment/hidden-element text) where surrounding HTML
    syntax is absent.
  - sanitize_instruction_text(text) — strips injection content via
    llm-sanitizer redact() then a word-boundary regex pass; returns
    (cleaned_text, findings) in the legacy cv-builder dict format.
  - scan_for_safety_ale  - scan_for_safety_ale  - scan_for_safety_ale  - scan_for_safety_ale  - scan_for_safety_ale  - scan_for_safety_ale  - sls/cv_orchestrator.py
  - Remove _LAYOUT_AGENT_INSTRUCTION_PATTERNS constant.
  - _sanitize_layout_instruction_text: replace manual regex loop with
    sanitize_instruction_text(); return shape unchanged.
  - _sanitize_layout_context_html, _sanitize_layout_instruction_htm  - _sanitize_layout_context_html, _sanitize_layout_instruction_htm  - _sa
                                                                          s/r                                                                          s/y.                                                                          s  text is extracted; log a warning if indicators found; include
    safety_alert in the JSON respons    safety_alert in the JSON respons    safety_alert in the JSON y.py (new)
  - 21 unit tests covering scan_text_for_injection, sanitize_  - 21 unit tests covering scan_text_for_injection, sanitize_  - dth-char detection test.

All 343 existing Python tests pass; 21 new tests added and passing.
- add __env to repository ignore rules

- prevent accidental commits of local API key environment file

- keep environment guidance in workspace while excluding sensitive content
The llm-sanitizer package is installed locally via `pip install -e` but is
not yet published to PyPI and cannot be added to scripts/requirements.txt.
CI was failing with an ImportError at collection time because
`scripts/utils/prompt_safety.py` imported directly from `llm_sanitizer.*`.

Changes:
- Wrap the three `llm_sanitizer` imports in a try/except ImportError block
  and set `_HAS_LLM_SANITIZER = True/False`.
- Guard the scanner-based passes in `scan_text_for_injection`,
  `sanitize_instruction_text`, and `scan_for_safety_alert` so they
  short-circuit gracefully when the library is absent.
- The fast substring pass in `scan_text_for_injection` always runs,
  preserving injection detection for the most common cv-builder cases
  without the library.
- `min_risk` parameter type changed from `RiskLevel` (import-time default)
  to `Any = None`, resolved to `RiskLevel.high` at call time when the
  library is available.

Local test: 306 passed (CI-equivalent command).
- Correct persona tally consistency in the rollup

- Add technical persona gap synthesis and updated totals

- Normalize markdown formatting for clean lint diagnostics
…date requirements

- Remove optional import logic and guards from prompt_safety.py
- Restore direct imports for RiskLevel, ScanResult, redact, Scanner
- Add llm-sanitizer>=0.1.0 to requirements.txt for CI and local installs
- Update UI browser restore test for new layout review state
- Minor workspace config tweak for uv

All Python tests pass (306/306) in cvgen environment.
…V tab, and first-run onboarding

- GAP-49 (spell-check confirm gate): add _confirmProceedToGenerate() modal
  before CV generation in both empty and full spell-check submit paths;
  shows current ATS score and staleness warning before proceeding
- GAP-30 (cover letter opening style): add formal/hook/narrative opening
  style selector to cover letter form; _OPENING_GUIDANCE dict in backend
  drives per-style LLM prompt; opening_style persisted in session state
- GAP-41 (Master CV tab pre-job): expose 'master' tab in STAGE_TABS.job
  so Master CV editor is accessible before job description is entered
- GAP-36 (first-run onboarding): session-free /api/setup/master-cv-status
  and /api/setup/create-master-cv endpoints; showOnboardingModal() /
  onboardingCreateEmptyProfile() JS functions; onboarding modal HTML in
  index.html; 7 new JS tests covering all onboarding paths
- fix(tests/ui): change Playwright goto wait_until from networkidle to
  load across all 8 conftest fixtures to eliminate a pre-existing flaky
  timeout; _wait_for_ui_ready() already ensures JS readiness
Enhance the onboarding modal (introduced in GAP-36) into a general
welcome/orientation screen shown on every startup until explicitly dismissed.
Both variants now share a complete workflow overview.

Changes:
- web/index.html: restructure onboarding modal body with shared 3-step
  workflow section (colored numbered badges: build master profile -> target
  job -> harvest improvements) and variant-specific CTA banners (green for
  present, amber for missing); "Don't show again" checkbox on present footer
- web/session-manager.js: add _setWelcomeSection(), maybeShowWelcomeModal()
  (checks localStorage dismissal flag, queries /api/setup/master-cv-status,
  shows appropriate section), and closeWelcomeModal() (hides overlay,
  optionally persists dismissal flag); update showOnboardingModal(); export
  2 new symbols
- web/app.js: call maybeShowWelcomeModal() in init() after ensureSessionContext
- web/bundle.js: regenerated (2717.9 KB)
- tests/js/session-manager.test.js: update DOM fixtures; update
  showOnboardingModal test to assert section visibility; add 7 new tests
  covering maybeShowWelcomeModal and closeWelcomeModal; fix localStorage
  cleanup with try/catch

Closes GAP-37
master_data_routes.py omitted certifications from the /api/master-data/full
response, causing the Certifications section in the Master CV editor to
always render empty despite correct data in Master_CV_Data.json.

Changes:
- scripts/routes/master_data_routes.py: add
  "certifications": master.get('certifications', []) to master_data_full()
  response payload

Closes GAP-42
Replace the non-functional undoInstruction() stub with snapshot-based undo.
Before each layout instruction is applied, the current HTML and instruction
list are pushed onto an in-memory undo stack (capped at 20 entries). When
Undo is pressed, the last snapshot is popped and restored: preview iframe,
stateManager CV artifacts, and instruction history are all rolled back.

Changes:
- web/layout-instruction.js: add module-level _layoutUndoStack / _UNDO_STACK_MAX;
  push snapshot in submitLayoutInstruction() before API call; replace stub
  undoInstruction() with stack-pop restore logic
- web/bundle.js: regenerated (2719.1 KB)
- tests/js/layout-instruction.test.js: add submitLayoutInstruction to imports;
  rewrite undoInstruction describe block with 4 tests covering empty-stack
  no-op, snapshot restore of HTML and instructions, system message, and
  absent-layoutInstructions guard

Closes GAP-25
Resolves the 79-record ambiguous set from the initial /specstoryOrganize
pass (55 unique files after cross-workspace duplicates were identified).

Changes:
- Deleted 21 empty/stub sessions (untitled sessions, "File editing
  session" placeholders, and near-empty sessions under 300B)
- Moved 8 personal-research sessions (diabetic neuropathy knowledge
  system work) to ~/CV/llm-history/ instead of a code repo
- Resolved 23 cross-workspace duplicate groups: kept the canonical copy
  in the correct repo, deleted or moved the duplicates accordingly
- Routed 3 single-copy files to their correct repos (vscode-config)
- Annotated all kept copies with specstory-relocated metadata
…ons"; GAP-29 venue warning

GAP-28: cv-template.html conditionally rendered "Publications" when no count
suffix was needed. Heading now always reads "Selected Publications"; the count
suffix (n) is appended only when some publications were omitted.

GAP-29: _format_publications() in cv_orchestrator.py never set venue_warning,
so the .pub-venue-warn icon in the template was never triggered. Now sets
venue_warning to a descriptive message for any entry missing both journal and
booktitle fields; entries with a venue continue to receive an empty string.

Changes:
- templates/cv-template.html: always emit "Selected Publications"; move count
  suffix outside the conditional so it only appears when pubs were omitted
- scripts/utils/cv_orchestrator.py: add venue_warning field to every entry in
  _format_publications() based on presence of journal or booktitle

Closes GAP-28
Closes GAP-29
…ublications" for all

Update cv-template.html and requirements to reflect the correct heading rule:
- "Selected Publications" when a subset of accepted publications is shown
- "Publications" when all accepted publications are shown
- Count suffix (N) never appears in generated documents

Also fix stale evidence in review-status/hiring-manager.md where two rows
still described venue_warning as unimplemented (fixed by GAP-29, ad9edf0).

Changes:
- templates/cv-template.html: conditional heading in both HTML section and
  ATS plain-text block
- tasks/user-story-hiring-manager.md: US-M7 item 5 and acceptance criteria
  updated to specify the subset/full distinction and prohibit count suffix
- tasks/gaps.md: GAP-24 description and GAP-28 status updated; count context
  removed from recommended resolution
- tasks/ui-review.md: GAP-28 finding marked CLOSED
- tasks/review-status/hiring-manager.md: heading and venue-warning rows
  updated to Pass/Partial with accurate evidence

Amends behavior introduced in ad9edf0.
…compute time

Previously, ats_score (from POST /api/cv/ats-score) and validation_results
(from GET /api/ats-validate) were only written to metadata.json during
POST /api/finalise. If finalise was never called, those values were lost.

Fix: add a _try_patch_metadata() helper to both generation_routes.py and
review_routes.py. After each value is computed and stored in session state,
the helper immediately patches it into the session's metadata.json file.
The helper is fire-and-forget — exceptions are logged as warnings but never
propagated, so no existing error paths change.

Affected routes:
- POST /api/cv/ats-score  → patches {"ats_score": score} into metadata.json
- GET  /api/ats-validate  → patches {"validation_results": {...}} into metadata.json
showAlertModal and closeAlertModal were defined in both ui-core.js and
ui-helpers.js. The ui-core.js versions targeted non-existent element IDs
(alert-modal, alert-title, alert-message) and created a second hidden
overlay dynamically, making them effectively dead code.

The ui-helpers.js versions are canonical: they target the correct HTML
elements (alert-modal-overlay, alert-modal-title, alert-modal-message)
and integrate with the focus-trap helpers (trapFocus, restoreFocus,
setInitialFocus) exported by ui-core.js.

Changes:
- Remove showAlertModal + closeAlertModal function bodies and export
  from ui-core.js
- Remove stale "also defined in ui-core.js" comment from ui-helpers.js
- Rebuild web/bundle.js
…cknowledgement

Require the user to expand the persuasion warnings panel and click
"Acknowledged" before the "Submit All Decisions" button becomes enabled
and before submitRewriteDecisions() will proceed.

Changes:
- updateRewriteTally(): disable submit button when persuasionWarningsAcknowledged
  is false (in addition to the existing pending > 0 check)
- Acknowledged button onclick: call updateRewriteTally() after setting the
  flag so the submit button re-evaluates immediately
- submitRewriteDecisions(): hard guard — shows an alert and returns early if
  warnings have not been acknowledged (defence in depth)
- Add setPersuasionWarningsAcknowledged() setter for testability
- Update rewrite-review.test.js: set acknowledged=true in the
  "enables submit button when no pending cards remain" test
- Rebuild web/bundle.js
… Review tab

Backend:
- POST /api/cover-letter/save now appends the generated cover letter
  filename to generated_files['files'] in session state so it is
  registered for download and visible to the frontend.
- POST /api/screening/save does the same for the screening responses DOCX.

Frontend (download-tab.js / bundle.js):
- _collectDownloadableFiles() recognises CoverLetter_* and
  Screening_Responses_* filename prefixes and renders them with
  appropriate descriptions instead of the generic DOCX label.

Both artefacts are now served by the existing /api/download/<filename>
route via the generated_files['files'] list lookup.
…abels

SESSION_PHASE_LABELS in utils.js now maps every backend phase enum to a
human-readable title-case string (e.g. 'rewrite_review' → 'Rewrite Review',
'init' → 'Getting Started') rather than lowercase abbreviations.

session-manager.js: loadSessionFile restore message now reads from
SESSION_PHASE_LABELS via an inline lookup instead of embedding the raw
enum value (e.g. '(rewrite_review)' → '(Rewrite Review)').

Tests updated to match:
- session-manager.test.js: restore-message expectation already set to
  '(Rewrite Review)' — now passes.
- session-switcher.test.js: formatSessionPhaseLabel expectations updated
  from short abbreviated values to full canonical labels ('Rewrite Review',
  'Layout Review').

1108/1108 JS tests pass.
On the first analyzeJob() call, append a system message informing the
user that submitted content is sent to the configured LLM provider.
The flag is persisted in localStorage (StorageKeys.LLM_DISCLOSURE_SHOWN)
so the message shows only once per browser profile.

Changes:
- api-client.js: add LLM_DISCLOSURE_SHOWN key to StorageKeys.
- job-analysis.js: import StorageKeys; check+set flag at top of
  analyzeJob() before any fetch.
- tests/js/job-analysis.test.js: two new tests — shows disclosure on
  first call; suppresses it when flag already set.
- tests/js/api-client.test.js: update key-count assertion 5 → 6 and add
  assertion for new LLM_DISCLOSURE_SHOWN key.

1111/1111 JS tests pass.
…ter_data_routes._save_master

The _save_master helper in master_data_routes.py now mirrors the behaviour
already present in web_app.py:

1. Creates a timestamped backup before writing (unchanged).
2. Calls validate_master_data() on the in-memory dict immediately after
   the write.
3. If validation fails, restores the backup and raises ValueError so the
   calling route can surface the error rather than silently persisting
   invalid data.

91 Python master-data tests pass.
session-switcher-ui.js:
- Session row button text: 'Delete' → 'Move to Trash'
- Button title attribute: 'Delete session' → 'Move session to Trash'
- Saved-sessions section note: 'rename, or delete saved work' →
  'rename, or move saved work to Trash'

Permanent-deletion labels in the Trash view ('Delete Forever',
'Permanently delete…') are intentionally unchanged — they accurately
describe an irreversible action on already-trashed items.

1111/1111 JS tests pass.
…trol

layout-instruction.js:
- Add `pxToPt(px)` helper (exported): converts CSS px to typographic pt
  using 96 px/in × 72 pt/in convention (1px = 0.75pt), rounded to 1dp
- Rename label from 'Base font size (px):' to 'Base font size:' — unit
  now appears inline in the adjacent pt-display span
- Add `<span id="font-size-pt-display">` next to the input showing the
  current value as 'Npx (M.m pt)'
- Wire `input` event on base-font-size-input to update the span live
- On session-state restore, update the pt span alongside the input value
- Default display: 13 px (9.8 pt)

tests/js/layout-instruction.test.js:
- Import pxToPt
- Add 5-case `pxToPt` describe block covering default value, round
  numbers, minimum, and rounding-to-1dp behaviour

1116/1116 JS tests pass.
master_data_routes.py (cover_letter_generate):
- Prompt instruction changed from '~300–400 words' to '~250–300 words'

The front-end validation range (250–400) is intentionally left unchanged
in this change — see GAP-HM-04 for planned role-differentiated validation.

40/40 cover-letter Python tests pass.
cv-builder.code-workspace:
- Add ../linkedown as a new sibling-repo workspace folder entry
p.write_text(json.dumps(skeleton, indent=2), encoding="utf-8")
except OSError as exc:
logger.exception("Failed to create skeleton Master_CV_Data.json")
return jsonify({"ok": False, "error": str(exc)}), 500
resolved = candidate.resolve()
resolved.relative_to(resolved_root)
return resolved
candidate_resolved = candidate.resolve()
@warnes warnes closed this Apr 22, 2026
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