Spaces:
Sleeping
Sleeping
| """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("</", "<\\/") | |
| html = f""" | |
| <button id="ups-copy" | |
| style="border:none;border-radius:10px;background:#2563EB;color:#fff; | |
| font-weight:600;font-size:0.9rem;padding:0.55rem 1.1rem; | |
| cursor:pointer;font-family:inherit;"> | |
| Copy Proposal | |
| </button> | |
| <span id="ups-copy-msg" style="margin-left:10px;color:#137333; | |
| font-size:0.85rem;font-family:inherit;"></span> | |
| <script> | |
| const btn = document.getElementById('ups-copy'); | |
| const msg = document.getElementById('ups-copy-msg'); | |
| btn.addEventListener('click', async () => {{ | |
| try {{ | |
| await navigator.clipboard.writeText({safe}); | |
| msg.textContent = 'Copied to clipboard'; | |
| }} catch (e) {{ | |
| msg.textContent = 'Press Ctrl/Cmd + C to copy'; | |
| }} | |
| }}); | |
| </script> | |
| """ | |
| 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( | |
| '<p style="font-family:var(--ff-mono);font-size:10px;letter-spacing:.16em;' | |
| 'text-transform:uppercase;color:var(--text-faint);margin:0 0 .5rem">Proposal</p>', | |
| 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) | |