"""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, ) from app.ui import output_screen, theme # Background-task id for proposal generation (one at a time). _PROPOSAL_TASK = "proposal_generation" def _copy_button(text: str) -> None: """A real one-click copy button (works in deployed Streamlit).""" safe = json.dumps(text).replace(" Copy Proposal """ components.html(html, height=48) 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 {} output_screen.render_clean_stage_banner( output_screen._stage_user_state(prop_meta), output_screen._stage_user_state(verify_meta), ) theme.screen_head( "proposal", "Your draft is ready", "A grounded first draft built only from facts in your dossier. " "Read it through, then copy it straight into Upwork.", ) proposal_text = proposal_result.get("proposal", "") # Grounding gate: when the verification pass FAILED, the draft was not # confirmed against the dossier evidence, so we must not present it as a # ready-to-send proposal. Show a clear failure notice instead of the # ungated copy box β€” the user can re-run once the AI service recovers. verification_failed = ( proposal_result.get("verification_status") == "failed" ) if verification_failed: with st.container(border=True): theme.section_label("Proposal not verified") st.error( "The grounding check could not confirm this draft against " "your dossier evidence, so it is not shown. This usually " "means the AI service failed during verification. Please try " "generating again." ) if show_debug: _render_debug_panel(proposal_result, settings=settings) return 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", ) screening_answers = proposal_result.get("screening_answers") or [] if screening_answers: with st.container(border=True): 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.markdown(f"**Q{i}: {question}**") st.write(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')}`)." ) 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, ) do_not_proceed = recommendation.get("verdict") == "Do Not Proceed" with st.container(border=True): 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"approx context chars: {pmeta.get('approx_context_chars', 0)}" ) if generate_clicked and not gate_blocked: # 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. bg.start( _PROPOSAL_TASK, 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_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"): tstate = bg.status(_PROPOSAL_TASK) if tstate["status"] == "running": with st.container(border=True): 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(_PROPOSAL_TASK)["result"] st.session_state.proposal_generating = False 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() else: # error bg.pop(_PROPOSAL_TASK) st.session_state.proposal_generating = False st.session_state.generated_proposal = None st.session_state.verified_proposal = None st.error(output_screen.USER_FACING_ERROR) return proposal_result = st.session_state.get("verified_proposal") if proposal_result: _render_proposal(proposal_result, show_debug, settings=settings)