"""Screen 3: Job screenshot upload and detail extraction. The user uploads one or more screenshots of an Upwork job post; the tool reads them and pulls out the structured job details. In the normal flow those details are confirmed automatically in the backend and the analysis unlocks immediately — there is no separate review step. Missing details are kept as "Not visible"; the tool never guesses hidden information. The manual "Confirm Details" review page is developer/admin-only and appears only when ``SHOW_DEBUG_PANEL=true``. Provider/model labels and fallback details likewise only appear in the Developer Debug Panel. """ from __future__ import annotations import io from typing import Any import streamlit as st from app.config import ( GROQ_VISION_UNSUPPORTED_MESSAGE, clear_opportunity_state, get_settings, ) from app.services.screenshot_parser import ( NOT_VISIBLE, SCREENSHOT_FIELDS, confirm_fields, extract_fields, get_meta as get_screenshot_meta, ) from app.ui import theme # Optional clipboard-paste component. It lets a user copy a screenshot with # the snipping tool and paste it straight into the app. It is an *optional* # dependency: if it isn't installed the screen falls back to drag-drop/browse # only, and headless tests (which don't ship the component) keep working. # pip install streamlit-paste-button try: # pragma: no cover - import guard, exercised only when the pkg is present from streamlit_paste_button import paste_image_button as _paste_image_button except Exception: # noqa: BLE001 - any import failure → paste simply unavailable _paste_image_button = None # The uploader widget key. Stable across reruns (so Streamlit keeps the # selected files) and unique to this screen. A counter suffix lets us RESET # the widget on demand (Streamlit can't clear a file_uploader any other way): # bumping the counter mounts a fresh, empty uploader for the next job. UPLOADER_KEY = "screenshot_uploader" def _uploader_key() -> str: """Current uploader widget key, including the reset counter.""" return f"{UPLOADER_KEY}_{st.session_state.get('uploader_reset_count', 0)}" def _clear_screenshots_for_new_job() -> None: """Wipe the current screenshots + analysis so the user can analyze a new job. Resets the uploader (via the counter), clears pasted images, the extracted fields, and every per-opportunity result (match, score, recommendation, proposal), then sends the user back to the screenshot step. """ st.session_state["uploader_reset_count"] = ( st.session_state.get("uploader_reset_count", 0) + 1 ) st.session_state[PASTED_KEY] = [] st.session_state.uploaded_screenshots = [] st.session_state.screenshots_uploaded = False st.session_state.screenshot_upload_sig = () st.session_state.extracted_job_fields = None st.session_state.extraction_failed = False clear_opportunity_state(st.session_state) st.session_state.current_step = "screenshot" # Session key holding the most recent clipboard-pasted screenshot as a # ``{name, mime, bytes}`` record (same shape as an uploaded file), so it can # survive reruns and feed the same downstream handler as the file uploader. PASTED_KEY = "pasted_screenshots" # User-facing message shown if a file was picked but its bytes never # reached the server (e.g. a residual upload failure). Never exposes the # underlying Axios/HTTP status. UPLOAD_FAILED_MESSAGE = ( "Screenshot upload failed. Please refresh and try again with a " "PNG or JPG file." ) # Shown when the AI could not read ANY field from the screenshot(s) — almost # always a provider/key problem, not a bad screenshot. We stop here instead of # running an analysis on empty data (which would wrongly read "Do Not Proceed"). EXTRACTION_FAILED_MESSAGE = ( "The AI could not read any details from your screenshot. This is almost " "always an AI-service issue, not your screenshot. Please check:\n\n" "1. **Your AI provider can read images.** Use **OpenAI**, **Anthropic**, " "or **Gemini** on the Setup page — **Groq cannot read images**.\n" "2. **Your API key is active and has credit.** An expired key or empty " "balance makes the read fail silently.\n" "3. **The screenshot is clear** — a full, legible image of the job post " "(PNG or JPG), not blurry or tiny.\n\n" "Fix the above (the **Change API Settings** button is in the sidebar), " "then click **Analyze Opportunity** again." ) # Map a file extension to a MIME type when the browser didn't supply one. _MIME_BY_SUFFIX = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp", } def _guess_mime(name: str) -> str: suffix = name.lower().rsplit(".", 1)[-1] if "." in name else "" return _MIME_BY_SUFFIX.get(suffix, "image/png") def _collect_uploaded_screenshots(files: Any) -> list[dict]: """Read each Streamlit ``UploadedFile`` into an in-memory record. For every successfully-read upload we keep only ``{name, mime, bytes}`` in session state — the image is held in memory for this session and never written to disk. Files whose bytes can't be read (a failed/partial upload) are skipped so they don't count as present. """ collected: list[dict] = [] for uploaded_file in files or []: if uploaded_file is None: continue getvalue = getattr(uploaded_file, "getvalue", None) if not callable(getvalue): # Not a real UploadedFile (or an unreadable selection) — skip. continue try: data = getvalue() except Exception: # noqa: BLE001 - treat any read error as a failed upload continue if not data: continue name = getattr(uploaded_file, "name", None) or "screenshot" mime = getattr(uploaded_file, "type", None) or _guess_mime(name) collected.append({"name": name, "mime": mime, "bytes": bytes(data)}) return collected def _capture_pasted_screenshot() -> list[dict]: """Render the clipboard-paste button and return the pasted image record(s). Returns the persisted pasted screenshot as a ``[{name, mime, bytes}]`` list (same shape as an uploaded file) so it can be merged with the file uploader and feed the identical downstream handler. When a fresh image is pasted it replaces the previously-pasted one. If the component isn't installed, nothing is rendered and any previously-pasted image is kept. """ if _paste_image_button is None: # Component not installed — drag-drop/browse remains fully functional. return list(st.session_state.get(PASTED_KEY) or []) # Colour the paste button to match the active theme (the component renders # in its own iframe, so it can't inherit our CSS — we pass colours in). _dark = bool(st.session_state.get("theme_dark", False)) if _dark: _btn_bg, _btn_hover, _btn_fg = "#2c313d", "#3a4150", "#f4f5f7" else: _btn_bg, _btn_hover, _btn_fg = "#7c3aed", "#6d28d9", "#ffffff" try: paste_result = _paste_image_button( "📋 Paste screenshot from clipboard", # Key carries the same reset counter as the uploader, so "Clear # screenshots" mounts a FRESH paste widget with no retained image — # otherwise the component keeps re-adding the old pasted screenshot. key=f"screenshot_paste_button_{st.session_state.get('uploader_reset_count', 0)}", background_color=_btn_bg, hover_background_color=_btn_hover, text_color=_btn_fg, errors="ignore", ) except Exception: # noqa: BLE001 - component unavailable (e.g. headless) → upload-only # Rendering the clipboard widget must never break the screen; fall back # to upload-only and keep any image pasted on a previous run. return list(st.session_state.get(PASTED_KEY) or []) image = getattr(paste_result, "image_data", None) if image is not None: # PIL image → PNG bytes, the same {name, mime, bytes} record an upload # produces. Held in memory only; never written to disk. try: buffer = io.BytesIO() image.save(buffer, format="PNG") st.session_state[PASTED_KEY] = [ { "name": "pasted-screenshot.png", "mime": "image/png", "bytes": buffer.getvalue(), } ] except Exception: # noqa: BLE001 - a bad paste must never crash the screen pass return list(st.session_state.get(PASTED_KEY) or []) def _signature(screenshots: list[dict]) -> tuple[tuple[str, int], ...]: """A light fingerprint of the uploaded set used to detect new uploads. Built from (name, byte-length) per file — enough to notice a swapped or added screenshot without hashing or logging the image itself. """ return tuple((s["name"], len(s["bytes"])) for s in screenshots) def _clear_stale_analysis() -> None: """Drop everything derived from a previous screenshot. Clears the extracted job fields plus every per-opportunity result (confirmed fields, match, score, recommendation, fingerprint, proposals) so a prior opportunity's analysis can never leak into a newly-uploaded one. """ st.session_state.extracted_job_fields = None clear_opportunity_state(st.session_state) def _reset_after_extract() -> None: """Clear every per-opportunity result before the new screenshot's job details are confirmed, so a previous opportunity's confirmed fields, match, score, recommendation, fingerprint, or proposal can never leak into this one.""" clear_opportunity_state(st.session_state) def _visible_field_count(extracted: dict) -> int: """How many job fields the vision model actually read (not 'Not visible').""" return sum( 1 for key in SCREENSHOT_FIELDS if (extracted.get(key) or {}).get("value") and (extracted.get(key) or {}).get("value") != NOT_VISIBLE ) def _extraction_failed(extracted: dict) -> bool: """True when the screenshot read produced no usable data. Treated as a failure when the vision API call did not succeed, OR it succeeded but read zero fields — both mean we must NOT run an analysis on empty data (which would wrongly conclude "Do Not Proceed"). """ meta = get_screenshot_meta(extracted) used_api = bool(meta.get("used_api")) status = str(meta.get("status") or "").lower() ok_status = status in ("", "ok", "success", "succeeded") if not used_api or not ok_status: return True return _visible_field_count(extracted) == 0 def _auto_confirm(extracted: dict) -> None: """Confirm the extracted job details in the backend (normal flow). No user review: extracted values become the confirmed values, with any missing field kept as "Not visible". This unlocks the analysis without a separate confirmation page. """ st.session_state.confirmed_job_fields = confirm_fields(extracted) st.session_state.fields_confirmed = True def _render_debug_panel(meta: dict) -> None: settings = get_settings() with st.expander("Developer Debug Panel", expanded=False): st.markdown( f"**Provider:** `{settings.llm_provider}` • " f"**Model:** `{settings.active_model}` • " f"**Task:** `screenshot_extraction`" ) st.markdown( "**Vision extraction:** " + ("LLM API used" if meta.get("used_api") else "local fallback") ) if meta.get("error_message"): st.caption(f"Reason: {meta['error_message']}") def _render_debug_handoff(extracted: dict) -> None: """Developer/admin handoff: review the fields on the Confirm Details page.""" with st.container(border=True): theme.section_label("Detected details") visible = sum( 1 for key in SCREENSHOT_FIELDS if (extracted.get(key) or {}).get("value") and (extracted.get(key) or {}).get("value") != NOT_VISIBLE ) st.success(f"{visible} of {len(SCREENSHOT_FIELDS)} details captured.") st.caption("Review and correct them on the next step.") if st.button( "Continue to Confirm Details", type="primary", key="continue_to_confirmation_btn", ): st.session_state.current_step = "confirmation" st.rerun() _render_debug_panel(get_screenshot_meta(extracted)) def render() -> None: if not st.session_state.get("evidence_index"): st.error( "This step is locked. Read your dossier and create proof points first." ) if st.button("Back to Dossier", key="back_to_dossier_from_screenshot"): st.session_state.current_step = "dossier" st.rerun() return settings = get_settings() debug = bool(getattr(settings, "show_debug_panel", False)) theme.screen_head( "screenshot", "Add the job post", "Drop a screenshot of the Upwork job — or paste one you copied. " "The tool reads the details for you. Anything it can't see is marked Not visible — it never guesses.", ) with st.container(border=True): theme.section_label("Add screenshot") # Native Streamlit uploader — no custom axios/fetch, no external # endpoint. The bytes stay on this server in memory only. uploaded = st.file_uploader( "Drop one or more screenshots of the job post", type=["png", "jpg", "jpeg", "webp"], accept_multiple_files=True, key=_uploader_key(), help="Files are held in this session only and never saved to disk.", ) # Clipboard paste — copy a screenshot (e.g. with the snipping tool) # and paste it here. It produces the same {name, mime, bytes} record # as an upload, so both input methods feed one downstream handler. if _paste_image_button is not None: st.caption("…or paste a screenshot you copied to the clipboard:") pasted = _capture_pasted_screenshot() uploader_screenshots = _collect_uploaded_screenshots(uploaded) # Both inputs merge into one set the rest of the screen treats # uniformly (extraction already merges multiple screenshots). screenshots = uploader_screenshots + pasted st.session_state.uploaded_screenshots = screenshots st.session_state.screenshots_uploaded = bool(screenshots) # A new (or swapped) screenshot — uploaded OR pasted — invalidates any # analysis built from a previous one; clear it before re-analyzing. signature = _signature(screenshots) if screenshots and signature != st.session_state.get("screenshot_upload_sig"): _clear_stale_analysis() # A new screenshot is a fresh attempt — clear any prior failure note. st.session_state.extraction_failed = False st.session_state.screenshot_upload_sig = signature # The widget shows the file(s) but none of their bytes reached the # server — surface a clean message instead of a raw upload error. if uploaded and not uploader_screenshots: st.error(UPLOAD_FAILED_MESSAGE) # A previous Analyze attempt couldn't read the screenshot — explain why # (provider/key issue) and let the user fix Setup and retry. if st.session_state.get("extraction_failed"): st.error(EXTRACTION_FAILED_MESSAGE) if screenshots: col_ready, col_clear = st.columns([2, 1]) with col_ready: st.caption(f"{len(screenshots)} screenshot(s) ready.") with col_clear: if st.button( "🗑️ Clear screenshots", key="clear_screenshots_btn", use_container_width=True, help="Remove these screenshots so you can analyze a different job.", ): _clear_screenshots_for_new_job() st.rerun() st.caption( "Missing details will be marked “Not visible”. The tool won't guess " "hidden information." ) # Groq is text-only: a Groq vision provider can't read screenshots. # Show a clean message and block extraction until the operator points # vision at a supported provider on the Setup page. vision_unsupported = settings.active_vision_provider == "groq" if vision_unsupported: st.warning(GROQ_VISION_UNSUPPORTED_MESSAGE) can_extract = bool( st.session_state.get("api_ok") and st.session_state.get("uploaded_screenshots") and not vision_unsupported ) if can_extract: extract_help = None elif vision_unsupported: extract_help = ( "Choose a vision provider that supports images " "(OpenAI, Anthropic, or Gemini) on the Setup page." ) else: extract_help = "Add at least one screenshot first." # Normal flow merges extraction + analysis into ONE button: it reads # the screenshot, confirms the details in the backend, and advances # straight to Analysis. Debug mode keeps the granular "Extract Job # Details" → manual Confirm Details handoff for developers/admins. analyze_label = "Extract Job Details" if debug else "Analyze Opportunity" analyze_key = "extract_job_details_btn" if debug else "analyze_opportunity_btn" if st.button( analyze_label, type="primary", key=analyze_key, disabled=not can_extract, help=extract_help, ): # Pass raw image bytes (not file paths) to the vision parser. image_inputs = [ (s["bytes"], s["mime"]) for s in st.session_state.get("uploaded_screenshots") or [] ] spinner_msg = ( "Reading your screenshot…" if debug else "Reading your screenshot and analyzing the opportunity…" ) with st.spinner(spinner_msg): fields = extract_fields(image_inputs) st.session_state.extracted_job_fields = fields _reset_after_extract() # Guard: if the vision read produced nothing usable, STOP here and # explain why — never run an analysis on empty data (it would # wrongly read "Do Not Proceed" with every signal "Not visible"). if _extraction_failed(fields): st.session_state.extraction_failed = True st.rerun() elif not debug: # One go: auto-confirm in the backend and jump to Analysis. st.session_state.extraction_failed = False _auto_confirm(fields) st.session_state.current_step = "analysis" st.rerun() # Debug/admin path keeps the manual review handoff to Confirm Details. extracted = st.session_state.get("extracted_job_fields") if extracted and debug: _render_debug_handoff(extracted)