"""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()