File size: 26,511 Bytes
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9a06ca
 
 
 
 
 
 
6303ae6
f9a06ca
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
f9a06ca
 
6303ae6
 
f9a06ca
6303ae6
 
 
f9a06ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
f9a06ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
"""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()