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