"""Screen 6: Proposal. Generates a grounded proposal from the analysis results and presents it in a copy-friendly way. Only a small, relevance-filtered subset of the dossier is ever sent to the AI — the full dossier is never sent, and evidence IDs / raw API details never appear in the client-facing proposal. Technical details are gated behind ``SHOW_DEBUG_PANEL=true``. """ from __future__ import annotations import json import time import streamlit as st import streamlit.components.v1 as components from app.config import get_settings from app.services import background_tasks as bg from app.services.proposal_generator import ( ProposalGenerationError, build_proposal_context, generate as generate_proposal, recheck_proposal, ) from app.ui import output_screen, theme # Background-task name for proposal generation. Each run gets its own id # (bg.new_task_id), kept in this session under "proposal_task_id". _PROPOSAL_TASK = "proposal_generation" # A re-run of the fact-check alone, on the draft already on screen. It is # polled like a generation run, under the same session keys. _RECHECK_TASK = "proposal_recheck" _RECHECK_FAILED_MESSAGE = ( "Something went wrong in the app while checking the draft again. Your " "draft is still below, not fact-checked." ) # Screen heading and standfirst for an AI draft, by fact-check outcome. _READY_HEAD = ( "Your draft is ready", "A grounded first draft built only from facts in your dossier. " "Read it through, then copy it straight into Upwork.", ) _CHECK_HEADS = { "unverified": ( "Your draft is ready", "The fact-check could not run this time, so this draft has not been " "checked against your dossier. Read it closely before you copy it " "into Upwork.", ), "failed": ( "Your draft needs a closer look", "The fact-check found claims your dossier does not support. Read the " "flagged claims below the draft before you copy it into Upwork.", ), } _PROPOSAL_FAILED_MESSAGE = ( "The AI couldn't write the proposal this time. Wait a minute, then press " "Generate Proposal again. If it keeps failing, check the API key on the " "Setup screen." ) # Anything but a provider failure is a fault in the app, not the user's key. _PROPOSAL_APP_ERROR_MESSAGE = ( "Something went wrong in the app while writing the proposal. This is not " "a problem with your API key. Press Generate Proposal to try again, and " "if it keeps failing, share the diagnostics from the sidebar with the " "app team." ) def _copy_button(text: str) -> None: """A real one-click copy button (works in deployed Streamlit).""" safe = json.dumps(text).replace("", "<\\/") # The button lives in its own component iframe, so it cannot inherit the # Aurora stylesheet — the tokens are written inline here instead. Purely # presentational; the clipboard behaviour below is unchanged. html = f""" """ components.html(html, height=56) def _flagged_claims(proposal_result: dict) -> list[str]: """The claims the fact-check removed or softened, each once, in order.""" claims = [str(c or "").strip() for c in proposal_result.get("removed_claims") or []] claims += [ str(s.get("original") or "").strip() for s in proposal_result.get("softened_claims") or [] if isinstance(s, dict) ] return list(dict.fromkeys(c for c in claims if c)) def _start_recheck(proposal_result: dict, *, settings) -> None: """Run the fact-check again, in the background, on the draft shown.""" if st.session_state.get("proposal_generating"): return task_id = bg.new_task_id(_RECHECK_TASK) if bg.start( task_id, recheck_proposal, dict(proposal_result), st.session_state.get("confirmed_job_fields") or {}, settings=settings, ): st.session_state.proposal_task_id = task_id st.session_state.proposal_generating = True st.rerun() def _render_proposal(proposal_result: dict, show_debug: bool, *, settings) -> None: prop_meta = proposal_result.get("__meta__") or {} verify_meta = proposal_result.get("verification_meta") or {} verification_status = proposal_result.get("verification_status") # Without the AI the app can only fill a fixed outline from the files. # That outline must never pass for a finished proposal. written_by_ai = bool(prop_meta.get("used_api")) if written_by_ai: # A fact-check that could not run gets its own warning below, worded # for this screen, instead of the generic stage banner. stage_states = [output_screen._stage_user_state(prop_meta)] if verification_status != "unverified": stage_states.append(output_screen._stage_user_state(verify_meta)) output_screen.render_clean_stage_banner(*stage_states) theme.screen_head( "proposal", *_CHECK_HEADS.get(verification_status, _READY_HEAD) ) else: theme.screen_head( "proposal", "AI writing was unavailable", "This is only a rough outline, filled in from your files without " "the AI. Don't send it as it is: wait a minute, then press " "Generate Proposal again.", ) st.warning( "Not a finished proposal. The AI service didn't respond this time, " "so the outline below is not tailored to the job." ) proposal_text = proposal_result.get("proposal", "") # Grounding gate: a draft the fact-check did not confirm is still shown # (throwing it away leaves nothing to send), but never as a checked one. # A check that could not run says so and offers to run again; a check # that failed lists the claims it flagged. Only an empty text has # nothing to show. if not str(proposal_text or "").strip(): with st.container(border=True, key="ups_panel_proposal_1"): theme.section_label("Proposal not verified") st.error( "The fact-check left no text to show for this draft. " "Please try generating again." ) if show_debug: _render_debug_panel(proposal_result, settings=settings) return if verification_status == "unverified": st.warning( "Not fact-checked. The check against your dossier could not run " "this time, so check every number, client name and tool in this " "draft before you send it, or run the check again." ) elif verification_status == "failed": st.warning( "The fact-check could not confirm parts of this draft against your " "dossier. Read the flagged claims below the draft and take out any " "that are still in the text before you send it." ) pending_answers = any( qa.get("verification_status") == "unverified" for qa in proposal_result.get("screening_answers") or [] ) if verification_status == "unverified" or pending_answers: if st.button( "Run fact-check again", key="recheck_proposal_btn", help="Checks this draft and its screening answers against your dossier without rewriting them.", ): _start_recheck(proposal_result, settings=settings) word_count = proposal_result.get("word_count", 0) claims = proposal_result.get("factual_claims") or [] file_count = len({c.get("source_file") for c in claims if c.get("source_file")}) theme.proposal_doc(proposal_text, word_count, file_count) col_copy, col_dl = st.columns([1, 1]) with col_copy: _copy_button(proposal_text) with col_dl: st.download_button( "Download as .txt", data=proposal_text, file_name="upwork_proposal.txt", mime="text/plain", key="download_proposal_btn", ) flagged = _flagged_claims(proposal_result) if verification_status == "failed" else [] if flagged: with st.container(border=True, key="ups_panel_proposal_5"): theme.section_label("Flagged by the fact-check") for claim in flagged: # Plain text: a claim is model output, never markup. st.text(f"• {claim}") screening_answers = proposal_result.get("screening_answers") or [] if screening_answers: with st.container(border=True, key="ups_panel_proposal_2"): theme.section_label("Screening answers") for i, qa in enumerate(screening_answers, start=1): question = str(qa.get("question") or "").strip() answer = str(qa.get("answer") or "").strip() if question: st.text(f"Q{i}: {question}") answer_status = qa.get("verification_status", "unverified") if answer_status == "failed": st.warning("This answer failed its fact-check. Add supporting profile details and regenerate.") else: if answer_status not in {"passed", "passed_with_softening"}: st.warning("Draft answer — its fact-check has not passed. Review before sending.") st.text(answer) # Start a fresh job without restarting the app. st.write("") if st.button("Analyze another job", key="new_job_from_proposal_btn", help="Clear this job and upload a new screenshot."): from app.ui.screenshot_screen import _clear_screenshots_for_new_job _clear_screenshots_for_new_job() st.rerun() if show_debug: _render_debug_panel(proposal_result, settings=settings) def _render_debug_panel(proposal_result: dict, *, settings) -> None: prop_meta = proposal_result.get("__meta__") or {} verify_meta = proposal_result.get("verification_meta") or {} with st.expander("Developer Debug Panel", expanded=False): if prop_meta.get("used_api"): st.success( f"`proposal_generation` used LLM API " f"(`{prop_meta.get('provider')}` / `{prop_meta.get('model')}`)." ) else: st.warning("LOCAL PLACEHOLDER — API NOT USED for `proposal_generation`.") st.caption( f"Evidence points sent: `{prop_meta.get('evidence_points_sent', 0)}` • " f"Approx context chars: `{prop_meta.get('compact_context_chars', 0)}` • " f"Retry used: `{'yes' if prop_meta.get('retry_used') else 'no'}`" ) if verify_meta.get("used_api"): st.success( f"Verification used LLM API " f"(`{verify_meta.get('provider')}` / `{verify_meta.get('model')}`)." ) else: st.warning( f"Verification did not run via the LLM API " f"(status `{verify_meta.get('status') or 'unknown'}`)." ) claims = proposal_result.get("factual_claims") or [] st.caption(f"Citation-backed claims surviving verification: {len(claims)}") def render() -> None: if not st.session_state.get("fields_confirmed"): debug = bool(getattr(get_settings(), "show_debug_panel", False)) if debug: st.error("This step is locked. Confirm the job details first.") if st.button( "Back to Confirm Details", key="back_to_confirm_from_proposal" ): st.session_state.current_step = "confirmation" st.rerun() else: st.error( "This step is locked. Extract job details from a screenshot first." ) if st.button( "Back to Job Screenshot", key="back_to_screenshot_from_proposal" ): st.session_state.current_step = "screenshot" st.rerun() return recommendation = st.session_state.get("recommendation_result") if not recommendation: st.info("Run the analysis first so the proposal can build on it.") if st.button("Go to Analysis", key="go_to_analysis_from_proposal"): st.session_state.current_step = "analysis" st.rerun() return settings = get_settings() show_debug = bool(getattr(settings, "show_debug_panel", False)) confirmed_job = st.session_state.get("confirmed_job_fields") or {} evidence_index = st.session_state.get("evidence_index") or [] match_data = st.session_state.get("match_data") # Screen head rendered inside _render_proposal; show a brief label here. st.markdown( '
Proposal
', unsafe_allow_html=True, ) # The warning follows the verdict the user was shown on the Analysis # screen — the job checklist — not the hidden fit-score tier, which could # read "Do Not Proceed" under a "Proceed with caution" verdict. beginner_eval = (match_data or {}).get("beginner_evaluation") if isinstance(beginner_eval, dict) and beginner_eval.get("result"): do_not_proceed = bool(beginner_eval.get("instant_no")) else: do_not_proceed = recommendation.get("verdict") == "Do Not Proceed" with st.container(border=True, key="ups_panel_proposal_3"): theme.section_label("Generate") override_ok = True if do_not_proceed: st.warning( "This opportunity is not strongly recommended. Generate anyway?" ) override_ok = st.checkbox( "Yes, generate the proposal anyway", key="proposal_override_checkbox" ) gate_blocked = do_not_proceed and not override_ok generate_clicked = st.button( "Generate Proposal", type="primary", disabled=gate_blocked, key="generate_proposal_btn", help=( "Drafts a grounded proposal using only a small, relevant subset " "of your evidence." ), ) if show_debug: preview = build_proposal_context( confirmed_job, evidence_index, match_result=match_data, recommendation_result=recommendation, max_evidence_points=settings.max_proposal_evidence_points, max_context_chars=settings.max_proposal_context_chars, ) pmeta = preview.get("__meta__") or {} st.caption( f"Debug — evidence points selected: " f"{pmeta.get('evidence_points_selected', 0)} • " f"candidate data chars before prompt fitting: {pmeta.get('approx_context_chars', 0)}" ) # A click while this session's draft is still being written keeps # that run going instead of paying for a duplicate one. if ( generate_clicked and not gate_blocked and not st.session_state.get("proposal_generating") ): # Run generation on a BACKGROUND thread so switching steps or # browser tabs doesn't kill it. Everything the worker needs is # passed explicitly — it never touches st.session_state. The run # id lives only in this session, so no other visitor can collect # this draft. task_id = bg.new_task_id(_PROPOSAL_TASK) if bg.start( task_id, generate_proposal, confirmed_job, list(evidence_index), recommendation, match_data=match_data, canonical_profile=st.session_state.get("canonical_profile"), complexity=None, settings=settings, ): st.session_state.proposal_task_id = task_id st.session_state.proposal_generating = True st.session_state.verified_proposal = None st.session_state.generated_proposal = None st.rerun() # ── Poll the background generation (runs on every render, so it resumes # even if the user navigated away and came back). ─────────────────── if st.session_state.get("proposal_generating"): task_id = st.session_state.get("proposal_task_id") or "" rechecking = task_id.startswith(_RECHECK_TASK + ":") tstate = bg.status(task_id) if tstate["status"] == "running": with st.container(border=True, key="ups_panel_proposal_4"): if rechecking: theme.section_label("Fact-checking") st.info( "Checking your draft against your dossier again… this " "keeps running even if you switch to another step or tab." ) else: theme.section_label("Generating") st.info( "✍️ Writing your proposal… this keeps running even if you " "switch to another step or tab. Come back here anytime — " "your draft will be waiting." ) time.sleep(1.0) st.rerun() elif tstate["status"] == "done": res = bg.pop(task_id)["result"] st.session_state.proposal_generating = False st.session_state.proposal_task_id = None if res is not None: st.session_state.generated_proposal = { "proposal": res.get("draft_proposal") or res.get("proposal", ""), "word_count": res.get("word_count", 0), } st.session_state.verified_proposal = res st.rerun() elif tstate["status"] == "error": failed = bg.pop(task_id) st.session_state.proposal_generating = False st.session_state.proposal_task_id = None if rechecking: # The draft itself is fine: it stays on screen, still marked # as not fact-checked. st.error(_RECHECK_FAILED_MESSAGE) else: st.session_state.generated_proposal = None st.session_state.verified_proposal = None if isinstance(failed.get("exc"), ProposalGenerationError): st.error(_PROPOSAL_FAILED_MESSAGE) else: st.error(_PROPOSAL_APP_ERROR_MESSAGE) return else: # idle: the run is gone (expired), so there is nothing to collect st.session_state.proposal_generating = False st.session_state.proposal_task_id = None proposal_result = st.session_state.get("verified_proposal") if proposal_result: _render_proposal(proposal_result, show_debug, settings=settings)