UpworkAutomation / app /ui /proposal_screen.py
ananyajoshi's picture
Deploy app: Docker Space, session-only API keys, cloud file-upload dossier
6303ae6 verified
Raw
History Blame Contribute Delete
12 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,
)
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)