File size: 25,620 Bytes
6303ae6
 
 
 
 
 
 
 
 
 
 
 
357ee36
 
 
 
6303ae6
 
 
 
 
 
357ee36
6303ae6
357ee36
6303ae6
357ee36
6303ae6
 
 
 
 
93411b2
6303ae6
 
 
 
 
22b38d8
357ee36
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93411b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
93411b2
6303ae6
 
 
 
 
 
 
 
 
 
 
 
c9d9189
6303ae6
 
 
 
 
 
 
 
 
 
 
93411b2
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357ee36
 
 
 
 
6303ae6
 
 
 
 
 
 
93411b2
 
 
 
 
6303ae6
 
 
 
 
 
 
357ee36
 
6303ae6
 
 
357ee36
6303ae6
 
 
 
357ee36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
93411b2
 
 
 
 
6303ae6
 
 
 
 
 
 
357ee36
6303ae6
 
 
357ee36
93411b2
6303ae6
 
 
 
 
 
357ee36
 
 
 
 
6303ae6
357ee36
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d9189
6303ae6
 
 
c9d9189
357ee36
 
 
 
 
 
 
 
c9d9189
 
6303ae6
357ee36
 
 
6303ae6
 
 
 
 
 
 
 
c9d9189
 
 
 
 
 
6303ae6
93411b2
 
 
 
6303ae6
c9d9189
93411b2
6303ae6
 
c9d9189
 
 
 
6303ae6
 
 
 
 
 
357ee36
6303ae6
93411b2
6303ae6
 
 
 
 
 
c9d9189
6303ae6
 
 
 
 
 
c9d9189
6303ae6
 
 
 
 
 
 
 
 
 
c9d9189
 
6303ae6
 
 
 
 
 
22b38d8
 
 
 
 
 
6303ae6
 
 
c9d9189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
be469a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357ee36
 
 
 
 
 
be469a4
 
357ee36
 
 
 
 
 
be469a4
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357ee36
 
 
6303ae6
 
 
 
 
 
 
 
 
 
357ee36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be469a4
 
 
 
 
 
6303ae6
 
 
 
 
 
 
93411b2
 
c9d9189
6303ae6
93411b2
 
6303ae6
c9d9189
 
6303ae6
c9d9189
6303ae6
93411b2
c9d9189
6303ae6
 
 
c9d9189
 
93411b2
c9d9189
 
 
6303ae6
 
 
 
 
93411b2
 
 
 
 
 
 
357ee36
 
 
 
 
 
 
 
 
 
 
6303ae6
c9d9189
 
 
 
 
 
 
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
"""Upwork Proposal Strategist β€” Streamlit entry point.

End users never run this directly β€” they double-click the launcher (see
README), which opens the app in their web browser. Developers launch it via
``python desktop_app.py`` (same thing β€” starts the server and opens the browser)
or, after ``pip install -e .``, ``python -m streamlit run app/main.py`` β€” no
PYTHONPATH needed in either case, because the project is an installable package
and the launcher puts the project root on ``sys.path``. See DEVELOPER.md.
"""

from __future__ import annotations

import copy
import traceback
from datetime import datetime, timezone

import streamlit as st

from app.config import (
    APP_TITLE, APP_TAGLINE,
    get_settings, reload_settings,
    get_saved_dossier_path, save_dossier_path,
    apply_session_overrides, REMEMBERED_CONFIG_KEYS,
)
from app.services import browser_tag, diagnostics, key_store
from app.services.api_gate import ApiGateError, run_capability_test
from app.services.folder_validator import score_evidence, validate
from app.services.dossier_reader import read_dossier
from app.services.evidence_index import build_evidence_index
from app.ui import (
    setup_screen,
    dossier_screen,
    interview_screen,
    screenshot_screen,
    confirmation_screen,
    analysis_screen,
    proposal_screen,
)
from app.ui import api_usage_panel, diagnostics_panel, motion_runtime, theme
from app.utils.logging_utils import get_logger

_logger = get_logger("upwork_strategist.main")


# (step key, sidebar label) in flow order.
STEPS: list[tuple[str, str]] = [
    ("setup", "Setup"),
    ("dossier", "Dossier"),
    ("screenshot", "Job Screenshot"),
    ("confirmation", "Confirm Details"),
    ("analysis", "Analysis"),
    ("proposal", "Proposal"),
]

# "Confirm Details" is a developer/admin-only screen. Normal users get a
# clean Setup β†’ Dossier β†’ Job Screenshot β†’ Analysis β†’ Proposal flow; the
# extracted job fields are confirmed automatically in the backend right
# after extraction, so these steps are hidden unless SHOW_DEBUG_PANEL=true.
DEBUG_ONLY_STEPS: frozenset[str] = frozenset({"confirmation"})

# Screens that are a second route through a step rather than a step of their
# own. They are reachable and renderable, but they are NOT in STEPS: they
# borrow their parent's rail row, breadcrumb, step number and accent, so the
# workflow stays the same length however the user builds their profile.
#
# The interview is one of these. It is an alternative to reading a dossier
# folder β€” same step, same outcome (an evidence index), different input β€” so
# giving it its own rail row would tell the user the flow grew a step when it
# did not, and would leave "Step N of M" counting differently depending on
# which route they took.
STEP_PARENT: dict[str, str] = {"interview": "dossier"}


def _parent_step(step_key: str) -> str:
    """The step a sub-screen reports as, for the rail and the top bar."""
    return STEP_PARENT.get(step_key, step_key)


def _visible_steps(show_debug: bool) -> list[tuple[str, str]]:
    """Return the steps shown in the sidebar for the current mode."""
    if show_debug:
        return list(STEPS)
    return [(key, label) for key, label in STEPS if key not in DEBUG_ONLY_STEPS]

RENDERERS = {
    "setup": setup_screen.render,
    "dossier": dossier_screen.render,
    "interview": interview_screen.render,
    "screenshot": screenshot_screen.render,
    "confirmation": confirmation_screen.render,
    "analysis": analysis_screen.render,
    "proposal": proposal_screen.render,
}


SESSION_DEFAULTS: dict[str, object] = {
    # Setup gate
    "api_status": None,
    "api_ok": False,
    # Theme (light default)
    "theme_dark": True,
    # Navigation
    "current_step": "setup",
    # Dossier flow
    "dossier_folder_path": "",
    "dossier_validation": None,
    "dossier_chunks": None,
    "dossier_read": False,
    "dossier_read_error": False,
    "evidence_index": None,
    "canonical_profile": None,
    "evidence_index_meta": None,
    # Interview (the second route through Dossier)
    "profile_source": "dossier",
    "interview_state": None,
    "interview_started": False,
    "interview_last_turn": None,
    "interview_turn_seq": 0,
    # Published by the interview when it builds a profile; None until then.
    # Declared here rather than left to spring into existence on the first
    # build, so every reader (Analysis scores with it, Dossier decides
    # whether to still offer a gap-fill interview with it) sees the same key
    # from the first rerun and a reset can return it to a value that exists.
    "interview_strength": None,
    # Screenshots
    "screenshots_uploaded": False,
    "uploaded_screenshots": [],
    "pasted_screenshots": [],
    "screenshot_upload_sig": (),
    "extracted_job_fields": None,
    # Confirmation
    "confirmed_job_fields": None,
    "fields_confirmed": False,
    "current_job_fingerprint": None,
    # Analysis output
    "scoring_result": None,
    "recommendation_result": None,
    "match_data": None,
    "generated_proposal": None,
    "verified_proposal": None,
    "proposal_context": None,
    "selected_evidence_for_proposal": None,
    # API usage tracking
    "api_usage_log": [],
}


def _init_session_state() -> None:
    for key, value in SESSION_DEFAULTS.items():
        if key not in st.session_state:
            # A copy per session: this process serves every visitor, and the
            # defaults themselves would hand them all one shared list (the
            # usage log is appended to in place, so one visitor's calls and
            # failures would show up in everyone's panels and reports).
            st.session_state[key] = copy.deepcopy(value)


def _is_unlocked(step_key: str) -> bool:
    if step_key == "setup":
        return True
    if step_key == "dossier":
        return bool(st.session_state.get("api_ok"))
    if step_key == "interview":
        # Same gate as its parent step: the interview needs a working API key
        # and nothing else. Without this branch the sweep below would bounce
        # the user straight back to Setup the moment they opened it.
        return bool(st.session_state.get("api_ok"))
    if step_key == "screenshot":
        return bool(st.session_state.get("evidence_index"))
    if step_key == "confirmation":
        return bool(st.session_state.get("extracted_job_fields"))
    if step_key == "analysis":
        return bool(st.session_state.get("fields_confirmed"))
    if step_key == "proposal":
        # Also locked while the analysis is being worked out again: a draft
        # started now would build on the verdict that is about to change.
        return bool(
            st.session_state.get("fields_confirmed")
            and st.session_state.get("recommendation_result")
            and not st.session_state.get("analysis_running")
        )
    return False


def _proposal_state() -> str | None:
    """Where the stored proposal stands, in the Proposal screen's own terms.

    ``"ready"``: a draft the screen offers to send (fact-checked, or marked
    as not fact-checked when the check could not run). ``"review"``: the
    fact-check ran and flagged claims, so the screen asks for a closer look
    first. ``"retry"``: nothing to send β€” the check left no text, or the AI
    did not write it and only the fixed outline is there. ``None`` before any
    result. The rail tick and the header pill both read this, so neither
    calls a proposal ready while the screen is saying it is not.
    """
    vp = st.session_state.get("verified_proposal")
    if not isinstance(vp, dict) or not vp:
        return None
    written_by_ai = bool((vp.get("__meta__") or {}).get("used_api"))
    if not written_by_ai or not str(vp.get("proposal") or "").strip():
        return "retry"
    if vp.get("verification_status") == "failed":
        return "review"
    return "ready"


def _is_completed(step_key: str) -> bool:
    if step_key == "setup":
        return bool(st.session_state.get("api_ok"))
    if step_key == "dossier":
        return bool(st.session_state.get("evidence_index"))
    if step_key == "interview":
        return bool(
            st.session_state.get("evidence_index")
            and st.session_state.get("profile_source") == "interview"
        )
    if step_key == "screenshot":
        return bool(st.session_state.get("extracted_job_fields"))
    if step_key == "confirmation":
        return bool(st.session_state.get("fields_confirmed"))
    if step_key == "analysis":
        return bool(st.session_state.get("recommendation_result"))
    if step_key == "proposal":
        return _proposal_state() == "ready"
    return False


def _lock_reason(step_key: str, show_debug: bool = False) -> str:
    if step_key in ("dossier", "interview"):
        return "Locked β€” run the API check first."
    if step_key == "screenshot":
        return "Locked β€” read your dossier and build proof points first."
    if step_key == "confirmation":
        return "Locked β€” extract job details from a screenshot first."
    if step_key == "analysis":
        if show_debug:
            return "Locked β€” confirm the job details first."
        # Confirm Details is hidden outside debug mode: the details are
        # confirmed when the user presses Analyze Opportunity.
        return "Locked β€” add a job screenshot and press Analyze Opportunity first."
    if step_key == "proposal":
        if st.session_state.get("analysis_running"):
            return "Locked β€” wait for the analysis to finish."
        return "Locked β€” run the analysis first."
    return "Locked."


def _step_status(step_key: str, current: str) -> str:
    if step_key == current:
        return "current"
    if not _is_unlocked(step_key):
        return "locked"
    if _is_completed(step_key):
        return "completed"
    return "available"


# Quiet glyphs that read as a clean status, not technical noise.
_STATUS_GLYPH = {
    "completed": "βœ“",
    "current": "●",
    "available": "β—‹",
    "locked": "β—¦",
}


# Session status pill -> the design canvas's pill tones.
_CHIP_TONE = {"ready": "ok", "info": "violet", "attention": "warn", "missing": "stop"}

# The pill for a stored proposal, by _proposal_state().
_PROPOSAL_CHIP = {
    "ready": ("Proposal Ready", "ready"),
    "review": ("Proposal Needs Review", "attention"),
    "retry": ("Proposal Needs a Retry", "attention"),
}


def _header_chip() -> tuple[str, str]:
    proposal = _proposal_state()
    if proposal:
        return _PROPOSAL_CHIP[proposal]
    if st.session_state.get("recommendation_result"):
        return "Analysis Ready", "info"
    if st.session_state.get("api_ok"):
        return "API Ready", "ready"
    return "API Missing", "missing"


def _render_sidebar(show_debug: bool) -> None:
    # The rail from the design canvas: brand lockup, a caption, one row per
    # step, and the engine card pinned to the foot. The rows are still real
    # st.button controls β€” the widget layer gives each one its step icon and
    # its on / done / locked state.
    theme.sidebar_brand(APP_TITLE, APP_TAGLINE)
    theme.rail_cap("Workflow")
    current = st.session_state.current_step
    # A sub-screen lights its parent's row, so the rail always shows where the
    # user is in the workflow β€” but the row still navigates, because from the
    # interview "Dossier" means "take me back to the folder route".
    rail_current = _parent_step(current)

    for key, label in _visible_steps(show_debug):
        status = _step_status(key, rail_current)
        unlocked = status != "locked"
        glyph = _STATUS_GLYPH.get(status, "β—‹")
        # The ordinal moved to the top bar, which now shows the live segmented
        # rail and "Step N of M". The glyph stays: it is the only per-row state
        # signal Streamlit does not already express through type= / disabled.
        btn_label = f"{glyph}  {label}"
        clicked = st.button(
            btn_label,
            key=f"nav_btn_{key}",
            type="primary" if status == "current" else "secondary",
            disabled=not unlocked,
            use_container_width=True,
            help=None if unlocked else _lock_reason(key, show_debug),
        )
        if clicked and unlocked and key != current:
            st.session_state.current_step = key
            st.rerun()

    # API status warning β€” shown any time the key is no longer working so the
    # user knows why things are failing without hunting through the screens.
    if st.session_state.get("api_ok") is False and st.session_state.get("api_status") not in (None,):
        st.warning("AI service issue β€” go to Setup to fix it.")

    # ── Always-visible settings link ───────────────────────────────────
    # The Setup screen is auto-skipped once an API key is saved, so users
    # need an explicit way back to change it.
    st.divider()
    if st.button(
        "Change API Settings",
        key="goto_setup_btn",
        use_container_width=True,
        type="secondary",
        help="Go back to Setup to change your API key or AI provider.",
    ):
        st.session_state.current_step = "setup"
        st.rerun()

    # Dark / light theme toggle.
    dark_on = st.toggle(
        "Dark mode",
        value=bool(st.session_state.get("theme_dark", True)),
        key="theme_toggle",
    )
    if dark_on != st.session_state.get("theme_dark"):
        st.session_state.theme_dark = dark_on
        st.rerun()

    # Problem/diagnostics panel. NOT debug-gated: a user who hits an error is
    # precisely the user who cannot flip SHOW_DEBUG_PANEL, so the explanation
    # and the share button have to be reachable by default. Renders nothing
    # when there are no failures, leaving a healthy run visually unchanged.
    diagnostics_panel.render(key_suffix="sidebar")

    # Developer-only API usage panel. Hidden unless SHOW_DEBUG_PANEL=true.
    api_usage_panel.render(key_suffix="sidebar")

    # The engine card closes the rail, as on the design canvas. The provider
    # and model names are debug-gated exactly as everywhere else in this app
    # (Settings.show_debug_panel), so a normal user sees the state of their AI
    # service and nothing about which one it is.
    _render_engine_card(show_debug)


def _render_engine_card(show_debug: bool) -> None:
    settings = get_settings()
    ok = bool(st.session_state.get("api_ok"))
    if show_debug:
        theme.engine_card(
            "Engine",
            settings.active_model or "Not configured",
            f"{settings.active_provider or 'no provider'} Β· "
            + ("connected" if ok else "not connected"),
            "ok" if ok else "stop",
        )
        return
    theme.engine_card(
        "AI service",
        "Connected" if ok else "Not connected",
        "Ready for analysis" if ok else "Set your key on Setup",
        "ok" if ok else "warn",
    )


def _try_restore_saved_config() -> None:
    """Load this browser's saved (encrypted) Setup config from Supabase.

    Runs once per session. Only acts when key storage is configured and the
    session has no API key yet. On success it applies the saved provider /
    model / key / vision settings into the session so the rest of the app β€”
    including smart-landing's API check β€” proceeds as if the user just set up.

    Entirely fail-safe (see :mod:`app.services.key_store`): any failure leaves
    the session untouched and the user simply lands on Setup as before.
    """
    ss = st.session_state
    if ss.get("_restore_done"):
        return
    if not key_store.is_configured():
        ss["_restore_done"] = True
        return
    if get_settings().has_api_key:
        ss["_restore_done"] = True
        return
    tag = browser_tag.get_or_create_tag()
    if not tag:
        return  # tag not available yet; try again on the next render
    # A Clear Credentials whose delete failed is finished first: the user
    # asked for this browser's saved key to be forgotten, so nothing is
    # restored while that delete is still pending.
    if setup_screen.finish_pending_forget(tag):
        ss["_restore_done"] = True
        return
    stored = key_store.load_config(tag)
    ss["_restore_done"] = True
    # Provider, model and key choices only. A config saved by an older version
    # also holds operator-only settings such as SHOW_DEBUG_PANEL, which must
    # keep following the server rather than what it was at save time.
    stored = {
        k: v for k, v in (stored or {}).items() if k in REMEMBERED_CONFIG_KEYS
    }
    if stored:
        apply_session_overrides(stored)
        ss["_config_restored"] = True


def _try_smart_landing(settings) -> None:
    """On a fresh session, silently restore Setup + Dossier if already done.

    Setup: re-run the API check. If it passes, mark api_ok = True.
    Dossier: if a folder path was previously entered AND is still valid,
    silently re-load the evidence index. If the path is gone/broken, show
    a sidebar warning instead of forcing the user back to the Dossier screen.
    On success both steps are skipped and the user lands on Job Screenshot.
    On any failure the user lands on the broken step as normal.
    """
    ss = st.session_state

    # --- Step 1: API check -------------------------------------------
    # Only attempt if a key is already saved; don't block if it's missing.
    if not ss.get("api_ok") and settings.has_api_key:
        try:
            result = run_capability_test(settings=settings, live=True)
            if result.ok:
                ss["api_ok"] = True
                ss["api_status"] = ApiGateError.API_OK
            else:
                ss["api_ok"] = False
                ss["api_status"] = result.status
                # Can't go further without a working API key.
                ss["current_step"] = "setup"
                return
        except Exception:
            ss["api_ok"] = False
            ss["current_step"] = "setup"
            return

    if not ss.get("api_ok"):
        # No key saved at all β†’ stay on Setup.
        ss["current_step"] = "setup"
        return

    # --- Step 2: Dossier auto-load -----------------------------------
    if ss.get("evidence_index"):
        # Evidence index already loaded in this session β†’ skip to screenshot.
        ss["current_step"] = "screenshot"
        return

    # Prefer the session-state path; fall back to the persisted .env path.
    path = (ss.get("dossier_folder_path") or "").strip()
    if not path:
        path = get_saved_dossier_path()
    if not path:
        # No path saved anywhere β†’ go to Dossier so user can enter one.
        ss["current_step"] = "dossier"
        return
    # Always keep session_state in sync so the dossier screen shows it.
    ss["dossier_folder_path"] = path

    try:
        result = validate(path)
        if not result.can_continue:
            # Path exists but unusable β†’ land on Dossier with a clear error.
            ss["dossier_validation"] = result
            ss["current_step"] = "dossier"
            return
        chunks = read_dossier(path)
        if not chunks:
            ss["current_step"] = "dossier"
            return
        proofs, profile, meta = build_evidence_index(chunks)
        ss["dossier_chunks"] = chunks
        ss["dossier_read"] = True
        ss["dossier_read_error"] = False
        # Re-scored from what the files hold, as the Dossier screen does:
        # validate() sees file names only, so a full dossier PDF scored thin.
        ss["dossier_validation"] = score_evidence(result, proofs) if proofs else result
        ss["evidence_index"] = proofs
        ss["canonical_profile"] = profile
        ss["evidence_index_meta"] = meta
        ss["current_step"] = "screenshot"
    except Exception:
        # Folder moved or deleted β†’ land on Dossier, warn there.
        ss["dossier_read_error"] = True
        ss["current_step"] = "dossier"


# Shown in place of a screen that raised. The details go to the server log.
_SCREEN_ERROR_MESSAGE = (
    "Something went wrong in the app on this step. Your work so far is kept: "
    "try again, or go back a step in the sidebar."
)


def _report_screen_crash(step: str, exc: Exception) -> None:
    """Log a screen's uncaught exception and record it for diagnostics.

    Call it in the ``except``. The traceback goes to the server log, inside
    the message so the log's redaction filter scrubs it like any other line.
    The diagnostics event names the exception's class only β€” never its
    message, which can quote dossier or job text.
    """
    error_type = type(exc).__name__
    _logger.error(
        "screen=%s status=app_error error_type=%s\n%s",
        step,
        error_type,
        traceback.format_exc(),
    )
    # Same record shape as llm_client's, so a report renders one table.
    diagnostics.record_event(
        {
            "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "task_name": f"{step}_screen",
            "provider": None,
            "model": None,
            "used_api": False,
            "status": "app_error",
            "error_message": None,
            "duration_seconds": None,
            "estimated_input_tokens": None,
            "estimated_output_tokens": None,
            "request_id": None,
            "http_status": None,
            "error_type": error_type,
        }
    )


def main() -> None:
    st.set_page_config(
        page_title=APP_TITLE,
        page_icon="🧭",
        layout="wide",
        initial_sidebar_state="expanded",
    )

    theme.inject_css()
    _init_session_state()

    settings = get_settings()
    show_debug = bool(getattr(settings, "show_debug_panel", False))

    # Confirm Details is debug-only β€” keep normal users out of it even if a
    # stale navigation target points there.
    if not show_debug and st.session_state.current_step == "confirmation":
        st.session_state.current_step = (
            "analysis" if st.session_state.get("fields_confirmed") else "screenshot"
        )

    # ---- Smart landing: skip screens the user has already completed --------
    # On a fresh Streamlit session (first page load or page refresh) we check
    # whether Setup and Dossier are already done and land the user directly on
    # Job Screenshot so they never have to re-enter their key or folder path.
    # We only do this once per session (guarded by the _smart_land_done flag),
    # and only when the session default "setup" is still the current step.
    # Restore a previously saved (encrypted) config for this browser BEFORE
    # smart-landing, so a returning visitor's key is in place and they get
    # auto-advanced past Setup without re-entering anything.
    _try_restore_saved_config()
    settings = get_settings()

    if not st.session_state.get("_smart_land_done"):
        st.session_state["_smart_land_done"] = True
        _try_smart_landing(settings)

    if not _is_unlocked(st.session_state.current_step):
        st.session_state.current_step = "setup"

    # Re-tint the whole screen to the current step's signature color. A
    # sub-screen borrows its parent's accent, breadcrumb and step number.
    dark = bool(st.session_state.get("theme_dark", True))
    current_step = st.session_state.current_step
    crumb_step = _parent_step(current_step)
    theme.apply_step_accent(crumb_step, dark=dark)

    # The top bar from the design canvas: breadcrumb, a segmented rail that
    # lights the step you are on, "Step N of M", and the session status pill.
    visible = _visible_steps(show_debug)
    step_index = None
    for idx, (key, _label) in enumerate(visible, start=1):
        if key == crumb_step:
            step_index = idx
            break

    chip_label, chip_kind = _header_chip()
    theme.top_bar(
        crumb_a=APP_TITLE,
        crumb_b=dict(STEPS).get(crumb_step, crumb_step),
        step_index=step_index,
        step_total=len(visible),
        right_html=theme.tag(chip_label, _CHIP_TONE.get(chip_kind, "")),
    )

    with st.sidebar:
        _render_sidebar(show_debug)

    # Dispatched through .get: an unknown step key (stale session state, or a
    # screen that navigated somewhere that no longer exists) would otherwise
    # raise a KeyError and blank the whole app.
    renderer = RENDERERS.get(st.session_state.current_step)
    if renderer is None:
        st.session_state.current_step = "setup"
        renderer = RENDERERS["setup"]
    # A screen that raises gets a plain message in its place instead of
    # Streamlit's traceback, which shows server paths and source lines to the
    # visitor. The rail and the session are untouched, so they can try again.
    # Exception only: st.rerun() and st.stop() raise BaseException subclasses,
    # which must keep passing through.
    step = st.session_state.current_step
    try:
        renderer()
    except Exception as exc:  # noqa: BLE001 - last guard around a whole screen
        _report_screen_crash(step, exc)
        st.error(_SCREEN_ERROR_MESSAGE)

    # Motion drives the animation the CSS layer cannot express: panels that
    # arrive as they scroll into view, springs on hover and press, the fit ring
    # drawing to its score, and numbers counting up to the value just computed.
    # Started last so its first pass sees a fully rendered page; after that a
    # MutationObserver keeps up with Streamlit's reruns on its own.
    motion_runtime.inject()


if __name__ == "__main__":
    main()