Spaces:
Sleeping
Sleeping
Fix: proposal </div> render, persist dossier uploads across navigation, robust dark-mode bridge
f9a06ca verified | """Screen 2: Dossier folder and profile evidence. | |
| Normal (non-technical) flow — a single button: | |
| * **Continue to Job Screenshot** — the user points the tool at a local | |
| folder and clicks once. That one button runs the full backend chain | |
| silently behind a spinner — ``validate`` → ``read_dossier`` → | |
| ``create_evidence_index`` — then advances. The user never sees the | |
| intermediate "Validate Folder" / "Read Dossier" / "Create Evidence Index" | |
| steps, the file count, or the dossier-strength score. A folder that can't | |
| be validated/read shows one clean inline error and keeps the user on the | |
| page — never a traceback. | |
| Original files never leave the user's machine. The file count, the | |
| dossier-strength score, the separate Validate/Read/Index buttons, the | |
| chunk/proof-point stats, per-file source types, and the scoring rubric | |
| appear only when ``SHOW_DEBUG_PANEL=true``. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| import streamlit as st | |
| from app.config import clear_opportunity_state, get_settings, save_dossier_path | |
| from app.models.schemas import SOURCE_TYPE_LABELS | |
| from app.services.dossier_reader import ( | |
| DossierReadSummary, | |
| read_dossier, | |
| summarize_chunks, | |
| ) | |
| from app.services.evidence_index import build_evidence_index | |
| from app.services.folder_validator import ( | |
| FolderValidationResult, | |
| SUPPORTED_EXTENSIONS, | |
| score_rubric_labels, | |
| validate, | |
| ) | |
| from app.ui import theme | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _strength_color(score: int) -> str: | |
| if score >= 80: | |
| return "green" | |
| if score >= 60: | |
| return "blue" | |
| if score >= 40: | |
| return "orange" | |
| return "red" | |
| def _reset_downstream() -> None: | |
| """Invalidate everything that depends on the dossier contents.""" | |
| for key in ( | |
| "dossier_chunks", | |
| "evidence_index", | |
| "canonical_profile", | |
| "evidence_index_meta", | |
| "extracted_job_fields", | |
| ): | |
| st.session_state[key] = None | |
| st.session_state.dossier_read = False | |
| # Also drop any per-opportunity analysis (match/score/recommendation/ | |
| # proposal/fingerprint) — it was built on the old evidence. | |
| clear_opportunity_state(st.session_state) | |
| def _render_validation_clean(result: FolderValidationResult, *, debug: bool) -> None: | |
| if result.issues and not result.exists: | |
| for issue in result.issues: | |
| st.error(issue) | |
| return | |
| # Normal UI shows ONLY the file count. The X/100 dossier-strength score, | |
| # its label, the "to strengthen your dossier consider adding…" line, and | |
| # the "evidence collection looks solid" banner mislead non-technical | |
| # users, so they are gated behind SHOW_DEBUG_PANEL=true. | |
| if debug: | |
| col_files, col_strength = st.columns(2) | |
| col_files.metric("Dossier Files", result.total_files) | |
| col_strength.metric("Dossier strength", f"{result.strength_score}/100") | |
| color = _strength_color(result.strength_score) | |
| st.markdown(f"**Strength:** :{color}[{result.strength_label}]") | |
| else: | |
| st.metric("Dossier Files", result.total_files) | |
| for issue in result.issues: | |
| st.error(issue) | |
| for warning in result.warnings: | |
| st.warning(warning) | |
| if debug and result.missing_categories: | |
| st.caption( | |
| "To strengthen your dossier, consider adding: " | |
| + ", ".join(result.missing_categories) | |
| ) | |
| if ( | |
| debug | |
| and result.can_continue | |
| and not result.warnings | |
| and not result.issues | |
| and result.strength_score >= 60 | |
| ): | |
| st.success("Your evidence collection looks solid.") | |
| def _render_debug_panel(result: FolderValidationResult | None) -> None: | |
| settings = get_settings() | |
| meta = st.session_state.get("evidence_index_meta") or {} | |
| proofs = st.session_state.get("evidence_index") | |
| with st.expander("Developer Debug Panel", expanded=False): | |
| st.markdown( | |
| f"**Provider:** `{settings.llm_provider}` • " | |
| f"**Model:** `{settings.active_model}`" | |
| ) | |
| chunks = st.session_state.get("dossier_chunks") | |
| if st.session_state.get("dossier_read") and chunks is not None: | |
| summary = summarize_chunks(chunks) | |
| st.markdown( | |
| f"**Dossier read:** {summary.files_processed} file(s), " | |
| f"{summary.chunks_extracted} chunk(s), " | |
| f"{summary.failed_files} failed." | |
| ) | |
| for f in summary.files: | |
| label = SOURCE_TYPE_LABELS.get(f.source_type, f.source_type) | |
| line = ( | |
| f"- `{f.file_path}` — {label} · status: **{f.status}** · " | |
| f"chunks: **{f.chunk_count}**" | |
| ) | |
| if f.warning: | |
| line += f" · ⚠ {f.warning}" | |
| st.write(line) | |
| if proofs is not None and meta: | |
| st.markdown( | |
| f"**Evidence index source:** " | |
| + ("LLM API" if meta.get("used_api") else "local fallback") | |
| ) | |
| if meta.get("error_message"): | |
| st.caption(f"Reason: {meta['error_message']}") | |
| claim_counts: dict[str, int] = {} | |
| for p in proofs: | |
| claim_counts[p.claim_type] = claim_counts.get(p.claim_type, 0) + 1 | |
| if claim_counts: | |
| st.markdown("**Proof points by claim type:**") | |
| for claim_type, count in sorted( | |
| claim_counts.items(), key=lambda kv: (-kv[1], kv[0]) | |
| ): | |
| st.write(f"- {claim_type}: **{count}**") | |
| if result is None: | |
| return | |
| if result.source_type_counts: | |
| st.markdown("**Source-type breakdown:**") | |
| for source_type, count in sorted( | |
| result.source_type_counts.items(), key=lambda kv: (-kv[1], kv[0]) | |
| ): | |
| label = SOURCE_TYPE_LABELS.get(source_type, source_type) | |
| st.write(f"- {label}: **{count}**") | |
| labels = score_rubric_labels() | |
| if labels: | |
| st.markdown("**Score breakdown:**") | |
| for key, label in labels.items(): | |
| awarded = result.score_breakdown.get(key, 0) | |
| st.write(f"- {label}: **{awarded}**") | |
| if result.files: | |
| st.markdown(f"**Files detected ({result.total_files}):**") | |
| for record in result.files: | |
| label = ( | |
| SOURCE_TYPE_LABELS.get(record.source_type, record.source_type) | |
| if record.source_type | |
| else "—" | |
| ) | |
| icon = ( | |
| "✓" | |
| if record.supported and record.readable | |
| else ("⊘" if not record.supported else "✗") | |
| ) | |
| st.write(f"{icon} `{record.relative_path}` — {label}") | |
| st.caption( | |
| "Supported extensions: " + ", ".join(sorted(SUPPORTED_EXTENSIONS)) | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Cloud (upload) mode | |
| # --------------------------------------------------------------------------- | |
| # | |
| # On a desktop install the user points the app at a local folder. On a shared | |
| # cloud deployment (e.g. a Hugging Face Space) there is no access to the | |
| # visitor's machine, so they upload their files instead. The uploaded bytes are | |
| # written to a private per-session temp directory and the existing | |
| # ``validate → read_dossier → build_evidence_index`` pipeline runs against that | |
| # directory unchanged — nothing about the downstream flow or look changes. | |
| def is_cloud_mode() -> bool: | |
| """True when running on a shared host where local folder paths can't work. | |
| Detected from Hugging Face's ``SPACE_ID`` (set on every Space) or an | |
| explicit ``UPWORK_CLOUD`` override, so local desktop runs keep the original | |
| folder-path UI untouched. | |
| """ | |
| return bool(os.getenv("SPACE_ID") or os.getenv("UPWORK_CLOUD")) | |
| # Session keys for the persisted upload set. The records (name + bytes) are | |
| # kept in session — NOT just in the file_uploader widget — because the widget | |
| # returns an empty list after the user navigates to another step and back, so | |
| # relying on it alone makes the dossier appear "lost". Kept until the page is | |
| # reloaded (new session) or the user clears them. | |
| _UPLOAD_RECORDS_KEY = "dossier_uploaded_files" | |
| _UPLOAD_SEQ_KEY = "dossier_uploader_seq" | |
| def _upload_signature(records: list[dict]) -> tuple: | |
| """(name, size) per file — detects a genuinely changed upload set.""" | |
| return tuple((r["name"], len(r["bytes"])) for r in records) | |
| def _materialize_records(records: list[dict]) -> str: | |
| """Write the persisted upload records into this session's temp dir. | |
| Fully replaces the previous set. The directory lives under the OS temp | |
| location and holds the files only for the lifetime of the session; it is | |
| never committed or shared. Returns the directory path for the standard | |
| folder pipeline. | |
| """ | |
| base = st.session_state.get("_dossier_upload_dir") | |
| if not base or not Path(base).is_dir(): | |
| base = tempfile.mkdtemp(prefix="upwork_dossier_") | |
| st.session_state["_dossier_upload_dir"] = base | |
| base_path = Path(base) | |
| for existing in base_path.iterdir(): | |
| try: | |
| existing.unlink() | |
| except OSError: # pragma: no cover - best effort cleanup | |
| pass | |
| for r in records: | |
| safe_name = Path(r["name"]).name # strip any directory components | |
| if not safe_name: | |
| continue | |
| (base_path / safe_name).write_bytes(r["bytes"]) | |
| return str(base_path) | |
| def _sync_dossier_uploads() -> list[dict]: | |
| """Reconcile the live uploader with the persisted record set. | |
| * A new/changed upload replaces the stored records, re-materializes the | |
| temp folder, and invalidates downstream evidence. | |
| * An empty uploader (e.g. after navigating away and back) keeps the stored | |
| records, so the dossier is never silently lost. | |
| * If the records exist but their temp folder is gone (rare), it is rebuilt. | |
| Returns the current persisted records. | |
| """ | |
| ss = st.session_state | |
| accepted = sorted(ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS) | |
| uploader_key = f"dossier_uploads_{ss.get(_UPLOAD_SEQ_KEY, 0)}" | |
| files = st.file_uploader( | |
| "Upload your profile evidence", | |
| type=accepted, | |
| accept_multiple_files=True, | |
| key=uploader_key, | |
| help=( | |
| "Portfolio, résumé, testimonials, past proposals. Your files are " | |
| "processed in this session only and are never stored on the server." | |
| ), | |
| ) | |
| stored: list[dict] = ss.get(_UPLOAD_RECORDS_KEY) or [] | |
| if files: | |
| records = [{"name": f.name, "bytes": f.getvalue()} for f in files] | |
| if _upload_signature(records) != _upload_signature(stored): | |
| ss[_UPLOAD_RECORDS_KEY] = records | |
| ss["dossier_folder_path"] = _materialize_records(records) | |
| _reset_downstream() | |
| stored = records | |
| elif stored: | |
| # Uploader came back empty (navigation), but we still hold the files — | |
| # make sure the temp folder still backs them for the read pipeline. | |
| path = (ss.get("dossier_folder_path") or "").strip() | |
| if not path or not Path(path).is_dir(): | |
| ss["dossier_folder_path"] = _materialize_records(stored) | |
| return stored | |
| def _clear_dossier_uploads() -> None: | |
| """Remove the persisted dossier upload set and reset the uploader widget.""" | |
| ss = st.session_state | |
| ss[_UPLOAD_RECORDS_KEY] = [] | |
| ss["dossier_folder_path"] = "" | |
| # Bump the uploader key so the widget itself re-mounts empty. | |
| ss[_UPLOAD_SEQ_KEY] = ss.get(_UPLOAD_SEQ_KEY, 0) + 1 | |
| _reset_downstream() | |
| def _card_upload_dossier(debug: bool) -> None: | |
| """Cloud variant of the dossier step: upload files, then continue. | |
| Mirrors the single-button normal flow — an uploader plus one *Continue to | |
| Job Screenshot* button that silently runs validate → read → index via | |
| :func:`_continue_chain`. In debug mode the granular read/index cards still | |
| render against the uploaded folder, exactly as on desktop. | |
| """ | |
| with st.container(border=True): | |
| theme.section_label( | |
| "Step 1 · Upload dossier files" if debug else "Upload dossier files" | |
| ) | |
| stored = _sync_dossier_uploads() | |
| has_files = bool(stored) | |
| if has_files: | |
| col_ready, col_clear = st.columns([3, 1]) | |
| col_ready.caption(f"{len(stored)} file(s) ready.") | |
| if col_clear.button( | |
| "Remove all", | |
| key="dossier_clear_uploads_btn", | |
| use_container_width=True, | |
| help="Clear these files so you can upload a different set.", | |
| ): | |
| _clear_dossier_uploads() | |
| st.rerun() | |
| if debug: | |
| # Validate against the uploaded folder so the file count / strength | |
| # panel works the same as the desktop debug flow. | |
| path = (st.session_state.get("dossier_folder_path") or "").strip() | |
| if st.button( | |
| "Validate Files", | |
| key="validate_folder_btn", | |
| disabled=not has_files, | |
| help="Upload at least one file first." if not has_files else None, | |
| ): | |
| st.session_state.dossier_validation = validate(path) | |
| _reset_downstream() | |
| result: FolderValidationResult | None = st.session_state.get( | |
| "dossier_validation" | |
| ) | |
| if result is not None: | |
| _render_validation_clean(result, debug=debug) | |
| return | |
| if st.button( | |
| "Continue to Job Screenshot", | |
| type="primary", | |
| key="continue_to_screenshot_btn", | |
| disabled=not has_files, | |
| help="Upload at least one file first." if not has_files else None, | |
| ): | |
| _continue_chain() | |
| elif not has_files: | |
| st.caption( | |
| "Upload your profile evidence files, then continue." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Cards | |
| # --------------------------------------------------------------------------- | |
| def _card_select_folder(debug: bool) -> None: | |
| with st.container(border=True): | |
| theme.section_label("Step 1 · Select dossier folder") | |
| st.text_input( | |
| "Dossier folder path", | |
| placeholder="/absolute/path/to/your/profile-evidence-folder", | |
| key="dossier_folder_path", | |
| help="Original files never leave your machine.", | |
| ) | |
| path = (st.session_state.get("dossier_folder_path") or "").strip() | |
| if st.button( | |
| "Validate Folder", | |
| key="validate_folder_btn", | |
| disabled=not path, | |
| help="Enter a folder path first." if not path else None, | |
| ): | |
| st.session_state.dossier_validation = validate(path) | |
| _reset_downstream() | |
| result: FolderValidationResult | None = st.session_state.get( | |
| "dossier_validation" | |
| ) | |
| if result is not None: | |
| _render_validation_clean(result, debug=debug) | |
| else: | |
| st.caption("After validation you'll see how many files were found.") | |
| def _resolve_dossier_path(result: FolderValidationResult | None) -> str: | |
| """Folder path to read: prefer the typed path, fall back to validation.""" | |
| path = (st.session_state.get("dossier_folder_path") or "").strip() | |
| if path: | |
| return path | |
| if result is not None and result.exists: | |
| return result.folder | |
| return "" | |
| def _perform_read(path: str) -> None: | |
| """Read the dossier folder and persist the results into session state. | |
| A single bad file never aborts the read (the reader degrades it to a | |
| ``failed`` chunk), and an unexpected top-level failure leaves the user | |
| with a clean error rather than a crashed app. | |
| """ | |
| try: | |
| chunks = read_dossier(path) if path else [] | |
| except Exception: # noqa: BLE001 - never crash the screen on a read error | |
| st.session_state.dossier_chunks = [] | |
| st.session_state.dossier_read = False | |
| st.session_state.dossier_read_error = True | |
| else: | |
| st.session_state.dossier_chunks = chunks | |
| st.session_state.dossier_read = True | |
| st.session_state.dossier_read_error = False | |
| # A fresh read invalidates anything built from a previous read. | |
| st.session_state.evidence_index = None | |
| st.session_state.canonical_profile = None | |
| st.session_state.evidence_index_meta = None | |
| def _render_read_summary(summary: DossierReadSummary) -> None: | |
| """User-facing read result. No raw dossier text — counts only.""" | |
| st.success("Dossier read successfully") | |
| col_files, col_chunks, col_failed = st.columns(3) | |
| col_files.metric("Files processed", summary.files_processed) | |
| col_chunks.metric("Chunks extracted", summary.chunks_extracted) | |
| col_failed.metric("Failed files", summary.failed_files) | |
| if summary.failed_files: | |
| st.warning("Some files could not be read, but the app continued.") | |
| st.caption("Profile evidence is held in this session only.") | |
| def _card_read_evidence() -> None: | |
| with st.container(border=True): | |
| theme.section_label("Step 2 · Read profile evidence") | |
| result: FolderValidationResult | None = st.session_state.get( | |
| "dossier_validation" | |
| ) | |
| can_read = bool(result and result.can_continue) | |
| if st.button( | |
| "Read Dossier", | |
| key="read_dossier_btn", | |
| disabled=not can_read, | |
| help=None if can_read else "Validate a folder first.", | |
| ): | |
| path = _resolve_dossier_path(result) | |
| with st.spinner("Reading your profile evidence…"): | |
| _perform_read(path) | |
| # Rendered from session state (not just on click) so the summary | |
| # survives Streamlit reruns. | |
| if st.session_state.get("dossier_read_error"): | |
| st.error( | |
| "We couldn't read that folder. Re-validate the folder and " | |
| "try again." | |
| ) | |
| chunks = st.session_state.get("dossier_chunks") | |
| dossier_read = bool(st.session_state.get("dossier_read")) | |
| if dossier_read and chunks is not None: | |
| _render_read_summary(summarize_chunks(chunks)) | |
| st.divider() | |
| col_index, _ = st.columns(2) | |
| with col_index: | |
| can_build = bool(dossier_read and chunks) | |
| if st.button( | |
| "Create Evidence Index", | |
| key="build_evidence_btn", | |
| disabled=not can_build, | |
| help=None if can_build else "Read the dossier first.", | |
| ): | |
| proofs, profile, meta = build_evidence_index(chunks) | |
| st.session_state.evidence_index = proofs | |
| st.session_state.canonical_profile = profile | |
| st.session_state.evidence_index_meta = meta | |
| proofs = st.session_state.get("evidence_index") | |
| if proofs: | |
| st.metric("Proof Points", len(proofs)) | |
| st.caption("Evidence is ready for matching.") | |
| proofs = st.session_state.get("evidence_index") | |
| if proofs: | |
| st.divider() | |
| if st.button( | |
| "Continue to Job Screenshot", | |
| type="primary", | |
| key="continue_to_screenshot_btn", | |
| ): | |
| # Persist path so the app auto-reloads it on next restart. | |
| p = (st.session_state.get("dossier_folder_path") or "").strip() | |
| if p: | |
| save_dossier_path(p) | |
| st.session_state.current_step = "screenshot" | |
| st.rerun() | |
| # --------------------------------------------------------------------------- | |
| # Normal-flow continue (runs validate → read → index silently) | |
| # --------------------------------------------------------------------------- | |
| def _continue_chain() -> None: | |
| """Run the full dossier pipeline silently, then advance. | |
| Wires the single normal-mode "Continue to Job Screenshot" button to the | |
| backend chain in order — ``validate()`` → ``read_dossier()`` → | |
| ``create_evidence_index()`` — behind one spinner, so a non-technical | |
| user never sees the intermediate Validate / Read / Index steps. The | |
| pipeline order is unchanged; only the on-screen surface collapsed to one | |
| action. | |
| A folder that can't be validated (bad path, empty folder, or no readable | |
| files) — or a read that yields nothing — stops the chain: the user stays | |
| on the page and sees one clean error, never a stack trace. | |
| """ | |
| path = (st.session_state.get("dossier_folder_path") or "").strip() | |
| with st.spinner("Preparing your profile evidence…"): | |
| # 1. Validate the typed folder. A re-run invalidates any evidence | |
| # built from a previously selected folder. | |
| result = validate(path) if path else None | |
| st.session_state.dossier_validation = result | |
| _reset_downstream() | |
| # 2. Read + 3. build the evidence index only when the folder is usable. | |
| if result is not None and result.can_continue: | |
| _perform_read(path) | |
| chunks = st.session_state.get("dossier_chunks") | |
| if chunks: | |
| proofs, profile, meta = build_evidence_index(chunks) | |
| st.session_state.evidence_index = proofs | |
| st.session_state.canonical_profile = profile | |
| st.session_state.evidence_index_meta = meta | |
| if st.session_state.get("evidence_index"): | |
| # Persist the path so the app can auto-reload it on the next restart. | |
| if path: | |
| save_dossier_path(path) | |
| st.session_state.current_step = "screenshot" | |
| st.rerun() | |
| else: | |
| st.error("Couldn't read that folder. Check the path and try again.") | |
| def _card_continue_normal() -> None: | |
| """Single-button dossier card for the non-technical flow. | |
| One text box + one "Continue to Job Screenshot" button. The button runs | |
| the whole backend chain silently behind a spinner — ``validate()`` → | |
| ``read_dossier()`` → ``create_evidence_index()`` — then advances. There | |
| is no file count, no strength score, and no separate Validate / Read / | |
| Index buttons; those are all debug-only. | |
| """ | |
| # Pre-fill the text input with the persisted path so the user | |
| # sees their last folder already filled in if they land here again. | |
| from app.config import get_saved_dossier_path | |
| if not st.session_state.get("dossier_folder_path"): | |
| saved = get_saved_dossier_path() | |
| if saved: | |
| st.session_state["dossier_folder_path"] = saved | |
| with st.container(border=True): | |
| theme.section_label("Select dossier folder") | |
| st.text_input( | |
| "Dossier folder path", | |
| placeholder="/absolute/path/to/your/profile-evidence-folder", | |
| key="dossier_folder_path", | |
| help="Original files never leave your machine.", | |
| ) | |
| path = (st.session_state.get("dossier_folder_path") or "").strip() | |
| # The single button runs validate + read + index, then advances (or, | |
| # on failure, leaves a clean inline error via _continue_chain). | |
| if st.button( | |
| "Continue to Job Screenshot", | |
| type="primary", | |
| key="continue_to_screenshot_btn", | |
| disabled=not path, | |
| help="Enter a folder path first." if not path else None, | |
| ): | |
| _continue_chain() | |
| else: | |
| st.caption( | |
| "Point to a local folder of your profile evidence, then continue." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Public render | |
| # --------------------------------------------------------------------------- | |
| def render() -> None: | |
| if not st.session_state.get("api_ok"): | |
| st.error("This step is locked. Run the API check on the Setup screen first.") | |
| if st.button("Back to Setup", key="back_to_setup_from_dossier"): | |
| st.session_state.current_step = "setup" | |
| st.rerun() | |
| return | |
| settings = get_settings() | |
| debug = bool(getattr(settings, "show_debug_panel", False)) | |
| cloud = is_cloud_mode() | |
| intro = ( | |
| "Upload your real work — portfolio, résumé, testimonials, past " | |
| "proposals. Every claim is grounded in <b>these files only</b>." | |
| if cloud | |
| else "Point the app at a folder of your real work — portfolio, résumé, " | |
| "testimonials, past proposals. Every claim is grounded in <b>these files only</b>." | |
| ) | |
| theme.screen_head("dossier", "Your work dossier", intro) | |
| # If the smart-landing auto-load failed (folder moved / deleted), show a | |
| # clear error so the user knows to update the path — no traceback. | |
| if st.session_state.get("dossier_read_error") and not st.session_state.get("dossier_read"): | |
| st.error( | |
| "Your saved dossier folder could not be found or read. " | |
| "Please enter the correct path below and continue." | |
| ) | |
| if debug: | |
| # Developer/admin mode keeps the granular Validate / Read Dossier / | |
| # Create Evidence Index buttons, the file count, strength score, | |
| # chunk/proof-point stats, and the debug panel. The first card swaps to | |
| # an uploader in cloud mode; everything downstream is identical. | |
| if cloud: | |
| _card_upload_dossier(debug) | |
| else: | |
| _card_select_folder(debug) | |
| _card_read_evidence() | |
| _render_debug_panel(st.session_state.get("dossier_validation")) | |
| elif cloud: | |
| # Cloud normal mode: one uploader + one button. The button silently | |
| # runs validate → read → index and advances, just like desktop. | |
| _card_upload_dossier(debug) | |
| else: | |
| # Desktop normal mode: one text box + one button. The button silently | |
| # runs validate → read → index and lands the user on the Job Screenshot | |
| # step (no file count, no strength score, no extra buttons). | |
| _card_continue_normal() | |