UpworkAutomation / app /main.py
ananyajoshi's picture
Fix audited pipeline, session isolation and proposal grounding issues
357ee36
Raw History Blame Contribute Delete
25.6 kB
"""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()