Spaces:
Sleeping
Sleeping
Download app/ui/proposal_screen.py from SkillArbitrage/UpworkAutomation: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/ui/proposal_screen.py
- Command line
-
hf download hf://spaces/SkillArbitrage/UpworkAutomation/app/ui/proposal_screen.py
-
curl -L -o proposal_screen.py https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/ui/proposal_screen.py
20.9 kB
| """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""" | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| html, body {{ margin: 0; background: transparent; }} | |
| #ups-copy {{ | |
| border: none; border-radius: 12px; padding: 10px 18px; | |
| font-family: Inter, ui-sans-serif, system-ui, sans-serif; | |
| font-size: 14px; font-weight: 500; color: #FFFFFF; cursor: pointer; | |
| background: linear-gradient(135deg, #7C3AED, #9333EA 55%, #A855F7); | |
| box-shadow: 0 8px 24px rgba(139, 92, 246, 0.22); | |
| transition: transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1), | |
| box-shadow 120ms cubic-bezier(0.2, 0.8, 0.2, 1); | |
| }} | |
| #ups-copy:hover {{ transform: translateY(-1px); box-shadow: 0 10px 28px rgba(139, 92, 246, 0.30); }} | |
| #ups-copy:active {{ transform: scale(0.98); transition-duration: 80ms; }} | |
| #ups-copy:focus-visible {{ outline: 2px solid rgba(139, 92, 246, 0.78); outline-offset: 2px; }} | |
| #ups-copy-msg {{ | |
| margin-left: 12px; color: #22C55E; font-size: 12px; | |
| font-family: Inter, ui-sans-serif, system-ui, sans-serif; | |
| }} | |
| @media (prefers-reduced-motion: reduce) {{ | |
| #ups-copy {{ transition: none; }} | |
| }} | |
| </style> | |
| <button id="ups-copy">Copy Proposal</button> | |
| <span id="ups-copy-msg"></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=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( | |
| '<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, | |
| ) | |
| # 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) | |