# -*- coding: utf-8 -*- """SmartPaper R3-Free UI: Streamlit is an observer, never the paper executor.""" import hashlib import html import json import logging import os import re import secrets import shutil import subprocess import sys import time import textwrap from datetime import datetime, timezone from pathlib import Path import streamlit as st import streamlit.components.v1 as components from config import APP_CONFIG from managed_auth import ( cancel_generation_task, generation_task_status, get_product_ui_projection, recover_generation_task, require_alipay_login, render_logged_in_account, render_payment_purchase, start_generation_task, ) from i18n import ( LANGUAGE_OPTIONS, get_output_suffix, get_research_material_template, get_target_flag, get_target_language, tr, ) from core.advanced_method_registry import ( METHOD_CAPABILITY_REGISTRY_VERSION, METHOD_FAMILIES, METHOD_RESOLVER_VERSION, default_method_family, method_capability, method_family_for_legacy_lane, normalize_method_family, reconcile_method_authority, resolve_generation_preflight, ) from smartpaper_restore_bridge import edge_bridge from utils.file_parser import ( extract_research_material_content, research_material_text_fingerprint, validate_research_material_template, ) st.set_page_config( page_title="Article Agent", page_icon="📊", layout="wide", initial_sidebar_state="collapsed", ) def inject_mobile_responsive_form_styles() -> None: """Improve narrow-screen form readability without changing desktop behavior.""" st.markdown( """ """, unsafe_allow_html=True, ) inject_mobile_responsive_form_styles() # A3-R5D: default-off, benchmark-only startup probe; never blocks the UI. try: from core.a3_glm_live_probe import run_startup_probe_if_enabled run_startup_probe_if_enabled() except Exception as _a3_probe_exc: logging.error("[A3_R5D_GLM_PROBE] bootstrap_failed error_class=%s", _a3_probe_exc.__class__.__name__) # A3-R5H: default-off Gemini Free-Tier startup probe; never blocks the UI. try: from core.a3_gemini_live_probe import run_startup_probe_if_enabled as run_gemini_startup_probe_if_enabled run_gemini_startup_probe_if_enabled() except Exception as _a3_gemini_probe_exc: logging.error("[A3_R5H_GEMINI_PROBE] bootstrap_failed error_class=%s", _a3_gemini_probe_exc.__class__.__name__) WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace") os.makedirs(WORKSPACE_DIR, exist_ok=True) # The display-only preview cache must exist from the first Streamlit script run. # Runtime preview files remain ephemeral and carry no task/execution authority. (Path(__file__).resolve().parent / "static" / "previews").mkdir(parents=True, exist_ok=True) os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false" os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false" WECHAT_BLOCKER_JS = """""" components.html(WECHAT_BLOCKER_JS, height=0, width=0) def inject_file_uploader_i18n(language: str) -> None: """Localize uploaders by semantic label, never by visual/tab position.""" payload = { "browse": tr(language, "browse_files"), "uploaders": { tr(language, "research_materials_label"): { "title": tr(language, "drop_research_materials"), "limit": tr(language, "limit_research_materials"), }, # Legacy labels remain recognized for recovered historical UI state, # but the unified workspace renders only research_materials_label. tr(language, "upload_reference"): { "title": tr(language, "drop_docs"), "limit": tr(language, "limit_docs"), }, tr(language, "upload_interviews"): { "title": tr(language, "drop_interviews"), "limit": tr(language, "limit_interviews"), }, tr(language, "upload_stats"): { "title": tr(language, "drop_data"), "limit": tr(language, "limit_data"), }, }, } script = f""" """ components.html(script, height=0, width=0) def inject_wechat_browser_notice() -> None: """Warn immediately when SmartPaper is opened inside WeChat or WeCom.""" script = """ """ components.html(script, height=0, width=0) for key, default in { "is_running": False, "zip_buffer": None, "zip_filename": "", "run_mode": "asq", "sp_generation_task": None, "sp_pending_launch": None, "sp_pending_resume_task_id": "", "sp_pending_cancel_task_id": "", "sp_payment_required": False, "sp_terminal_notice": None, "sp_task_recovery_initialized": False, "sp_last_ui_task_status": "", "sp_scroll_to_generation_action": False, "sp_completed_download_scroll_task_id": "", "sp_research_design_authority": "", "sp_method_selector_ui": "", "sp_research_design_ui_snapshot": "", "sp_research_intent_snapshot": "", "sp_material_document_use": "reference", # legacy recovered-session compatibility only "sp_material_parse_cache": {}, }.items(): if key not in st.session_state: st.session_state[key] = default def _safe_key(value: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", str(value or "").strip()) return cleaned[:100] or "anonymous" def _new_generation_idempotency_key() -> str: return f"gen_{int(time.time() * 1000)}_{secrets.token_urlsafe(18)}" def _atomic_json(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") os.replace(temporary, path) def _canonical_request_hash(value: dict) -> str: material = dict(value or {}) material.pop("request_hash", None) encoded = json.dumps(material, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _task_dir(task_id: str) -> Path: return Path(_user_workspace) / "tasks" / _safe_key(task_id) def _local_resume_capability(task: dict) -> tuple[bool, str]: """Return whether this HF runtime has the local material required to resume. Worker/D1 remains authoritative for the remote task state. This function answers only the local capability question and never changes task/quota state. """ task = dict(task or {}) task_id = str(task.get("task_id") or "").strip() if not task_id: return False, "task_id_missing" task_dir = _task_dir(task_id) request_path = task_dir / "request.json" if not request_path.is_file(): return False, "request_missing" try: request_payload = json.loads(request_path.read_text(encoding="utf-8")) except Exception: return False, "request_unreadable" if not isinstance(request_payload, dict): return False, "request_invalid" if str(request_payload.get("task_id") or "").strip() != task_id: return False, "request_task_mismatch" request_hash = str(request_payload.get("request_hash") or "").strip().lower() if not re.fullmatch(r"[a-f0-9]{64}", request_hash): return False, "request_hash_invalid" if request_hash != _canonical_request_hash(request_payload): return False, "request_hash_mismatch" task_root = task_dir.resolve() for field in ("reference_files", "data_files", "interview_files"): items = request_payload.get(field) or [] if not isinstance(items, list): return False, f"{field}_invalid" for item in items: local_path = (task_dir / str(item)).resolve() if task_root not in local_path.parents or not local_path.is_file(): return False, "input_missing" try: checkpoint_seq = int(task.get("checkpoint_seq") or 0) except (TypeError, ValueError): return False, "checkpoint_seq_invalid" if checkpoint_seq <= 0: return True, "ready" expected_stage = str(task.get("checkpoint_stage") or "").strip() try: candidates = sorted((task_dir / "checkpoints").glob(f"checkpoint_{checkpoint_seq:05d}_*.json")) except Exception: candidates = [] for candidate in candidates: try: payload = json.loads(candidate.read_text(encoding="utf-8")) if not isinstance(payload, dict): continue if str(payload.get("request_hash") or "").strip().lower() != request_hash: continue if int(payload.get("checkpoint_seq") or 0) != checkpoint_seq: continue if expected_stage and str(payload.get("checkpoint_stage") or "").strip() != expected_stage: continue return True, "ready" except Exception: continue return False, "checkpoint_missing" def _task_preview_path(task_id: str) -> str: """Return the unguessable local static preview URL for this task, if any.""" request_path = _task_dir(task_id) / "request.json" if not request_path.is_file(): return "" try: payload = json.loads(request_path.read_text(encoding="utf-8")) token = str(payload.get("preview_token") or "").strip() if not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", token): return "" return f"/app/static/previews/{token}.json" except Exception: return "" def _cleanup_task_artifacts(task_id: str) -> None: """Delete local stage artifacts only after authoritative cancellation.""" task_dir = _task_dir(task_id) token = "" request_path = task_dir / "request.json" if request_path.is_file(): try: payload = json.loads(request_path.read_text(encoding="utf-8")) token = str(payload.get("preview_token") or "").strip() except Exception: token = "" if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", token): try: (Path(__file__).resolve().parent / "static" / "previews" / f"{token}.json").unlink(missing_ok=True) except Exception as exc: logging.warning("[SP_UI] cancel_preview_cleanup_error task_id=%s error=%s", task_id, type(exc).__name__) shutil.rmtree(task_dir, ignore_errors=True) def _task_mode(task: dict) -> str: mode = str((task or {}).get("mode") or st.session_state.get("run_mode") or "asq").strip().lower() return "amj" if mode == "amj" else "asq" def _task_method_family(task: dict) -> str: """Return frozen new-task method_family, with legacy resume fallback.""" task_id = str((task or {}).get("task_id") or "").strip() if task_id: request_path = _task_dir(task_id) / "request.json" if request_path.is_file(): try: payload = json.loads(request_path.read_text(encoding="utf-8")) family = str(payload.get("method_family") or "").strip().casefold() if family in METHOD_FAMILIES: return family except Exception: pass mode = _task_mode(task) return method_family_for_legacy_lane(mode) or default_method_family() def _task_output_path(task: dict) -> Path | None: task_id = str(task.get("task_id") or "").strip() filename = os.path.basename(str(task.get("output_filename") or "").strip()) if not task_id or not filename: return None return _task_dir(task_id) / "outputs" / filename def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]: """Persist uploads without copying the entire file into one Python bytes object.""" saved = [] for index, item in enumerate(items or []): if item is None: continue original = os.path.basename(str(getattr(item, "name", f"input_{index}"))) safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", original)[:140] or f"input_{index}" filename = f"{prefix}_{index:02d}_{safe_name}" path = inputs_dir / filename try: item.seek(0) except Exception: pass if hasattr(item, "read"): with path.open("wb") as target: shutil.copyfileobj(item, target, length=1024 * 1024) else: path.write_bytes(bytes(item or b"")) saved.append(str(Path("inputs") / filename)) try: item.seek(0) except Exception: pass return saved def _research_material_upload_policy() -> dict: policy = dict(APP_CONFIG.get("research_material_upload_policy") or {}) research_limit = int(policy.get("research_material_file_limit") or 0) if research_limit <= 0: raise RuntimeError("research_material_upload_policy_invalid") return { **policy, "research_material_file_limit": research_limit, "research_material_file_max_bytes": max( 1024, int(policy.get("research_material_file_max_bytes") or 0) ), "research_material_total_max_bytes": max( 1024, int(policy.get("research_material_total_max_bytes") or 0) ), "qualitative_min_combined_chars": max( 1, int(policy.get("qualitative_min_combined_chars") or 1) ), "quantitative_bundle_max_chars": max( 1000, int(policy.get("quantitative_bundle_max_chars") or 20000) ), "qualitative_bundle_max_chars": max( 1000, int(policy.get("qualitative_bundle_max_chars") or APP_CONFIG.get("max_extract_chars", 150000)) ), } def _uploaded_file_identity(item, occurrence: int = 0) -> str: provider_id = str(getattr(item, "file_id", "") or "").strip() name = os.path.basename(str(getattr(item, "name", "") or "")) size = str(getattr(item, "size", "") or "") mime = str(getattr(item, "type", "") or "") source = provider_id or f"{name}|{size}|{mime}|{occurrence}" return hashlib.sha256(source.encode("utf-8")).hexdigest()[:24] def _uploaded_material_entries() -> list[dict]: """Return research-material-only upload entries for the current design.""" uploads = list(st.session_state.get("up_research_materials") or []) occurrences: dict[tuple[str, str, str], int] = {} entries: list[dict] = [] for item in uploads: name = os.path.basename(str(getattr(item, "name", "") or "")) size = str(getattr(item, "size", "") or "") mime = str(getattr(item, "type", "") or "") signature = (name, size, mime) occurrence = occurrences.get(signature, 0) occurrences[signature] = occurrence + 1 entries.append( { "item": item, "identity": _uploaded_file_identity(item, occurrence), "name": name, "extension": Path(name).suffix.lower(), "size_bytes": int(getattr(item, "size", 0) or 0), "role": "research", } ) return entries def _material_role_counts(entries: list[dict] | None = None) -> dict[str, int]: entries = list(entries if entries is not None else _uploaded_material_entries()) # Keep the legacy reference key only for backward diagnostic compatibility. # New-task UI exposes research materials only. return {"reference": 0, "research": len(entries)} def _material_limits_ok(entries: list[dict] | None = None) -> tuple[bool, dict[str, int], dict]: entries = list(entries if entries is not None else _uploaded_material_entries()) counts = _material_role_counts(entries) policy = _research_material_upload_policy() ok = counts["research"] <= policy["research_material_file_limit"] return ok, counts, policy def _research_material_extension_contract(method_family: str) -> tuple[str, ...]: capability = method_capability(method_family) extensions = tuple( str(item or "").strip().casefold().lstrip(".") for item in (capability.get("research_material_extensions") or ()) if str(item or "").strip() ) if len(extensions) != 1 or extensions[0] not in {"txt", "csv"}: raise RuntimeError("method_research_material_extension_unsupported") return extensions def _explicit_material_bucket(method_family: str, role: str = "research") -> str: if str(role or "research").strip().casefold() != "research": raise RuntimeError("reference_upload_not_supported") capability = method_capability(method_family) material_routing = dict(capability.get("material_routing") or {}) bucket = str(material_routing.get("user_research") or "").strip().casefold() if bucket not in {"data", "interview"}: raise RuntimeError("method_material_routing_unsupported") return bucket def _canonical_user_keywords(value: object) -> str: """Normalize all supported user separators while preserving phrase spaces.""" terms = [] seen = set() for raw in re.split(r"[;;,,\n|]+", str(value or "")): term = re.sub(r"\s+", " ", raw).strip() key = term.casefold() if not term or key in seen: continue terms.append(term) seen.add(key) return "; ".join(terms) def _template_content_fingerprints(template_profile: str) -> set[str]: out: set[str] = set() for language_code in LANGUAGE_OPTIONS.values(): content = str(get_research_material_template(language_code, template_profile).get("content") or "") if content.strip(): out.add(research_material_text_fingerprint(content)) return out def _material_preflight_report(method_family: str, entries: list[dict] | None = None) -> dict: """Validate research files and capacity before task creation/quota reservation.""" entries = list(entries if entries is not None else _uploaded_material_entries()) limits_ok, counts, policy = _material_limits_ok(entries) capability = method_capability(method_family) template_profile = str(capability.get("template_profile") or "").strip() expected_extensions = _research_material_extension_contract(method_family) research_bucket = _explicit_material_bucket(method_family) bundle_limit = int( policy["quantitative_bundle_max_chars"] if research_bucket == "data" else policy["qualitative_bundle_max_chars"] ) total_bytes = sum(max(0, int(entry.get("size_bytes") or 0)) for entry in entries) report = { "ok": bool(limits_ok), "counts": counts, "policy": policy, "failures": [], "research_chars": 0, "bundle_chars": 0, "total_bytes": total_bytes, "template_profile": template_profile, "expected_extensions": expected_extensions, "bundle_limit_chars": bundle_limit, } if not limits_ok: return report if total_bytes > int(policy["research_material_total_max_bytes"]): report["ok"] = False report["total_size_exceeded"] = True return report cache = st.session_state.get("sp_material_parse_cache") if not isinstance(cache, dict): cache = {} st.session_state["sp_material_parse_cache"] = cache template_fingerprints = _template_content_fingerprints(template_profile) live_cache_keys = set() successful_entries = 0 for entry in entries: item = entry["item"] ext = str(entry.get("extension") or "").casefold().lstrip(".") cache_key = f"{entry['identity']}|{template_profile}|r2" live_cache_keys.add(cache_key) size_bytes = max(0, int(entry.get("size_bytes") or 0)) if size_bytes > int(policy["research_material_file_max_bytes"]): result = { "ok": False, "filename": entry["name"], "text_chars": 0, "byte_size": size_bytes, "error": "research_material_file_too_large", } elif ext not in expected_extensions: result = { "ok": False, "filename": entry["name"], "text_chars": 0, "byte_size": size_bytes, "error": f"template_extension_mismatch:expected={expected_extensions[0]}", } else: result = cache.get(cache_key) if not isinstance(result, dict): result = validate_research_material_template( item, template_profile, max_chars=None, max_bytes=int(policy["research_material_file_max_bytes"]), disallowed_content_fingerprints=template_fingerprints, ) cache[cache_key] = dict(result) if not bool(result.get("ok")): report["failures"].append( { "name": entry["name"], "role": "research", "error": str(result.get("error") or "parse_failed"), } ) else: chars = int(result.get("text_chars") or 0) report["research_chars"] += chars report["bundle_chars"] += len(f"### Source: {entry['name']}\n") + chars successful_entries += 1 if successful_entries > 1: report["bundle_chars"] += 2 * (successful_entries - 1) st.session_state["sp_material_parse_cache"] = { key: value for key, value in cache.items() if key in live_cache_keys } if report["failures"]: report["ok"] = False if report["bundle_chars"] > bundle_limit: report["ok"] = False report["bundle_capacity_exceeded"] = True if ( report["ok"] and counts["research"] > 0 and research_bucket == "interview" and report["research_chars"] < policy["qualitative_min_combined_chars"] ): report["ok"] = False report["content_insufficient"] = True return report def _material_bundle_text(entries: list[dict], research_bucket: str, template_profile: str) -> str: if not entries: return "" policy = _research_material_upload_policy() if research_bucket == "data": total_budget = int(policy["quantitative_bundle_max_chars"]) elif research_bucket == "interview": total_budget = int(policy["qualitative_bundle_max_chars"]) else: raise RuntimeError("method_material_routing_unsupported") sections = [] for entry in entries: text = extract_research_material_content( entry["item"], template_profile, max_chars=total_budget, max_bytes=int(policy["research_material_file_max_bytes"]), ) sections.append(f"### Source: {entry['name']}\n{text.strip()}") bundle = "\n\n".join(sections).strip() if len(bundle) > total_budget: raise RuntimeError("research_material_bundle_capacity_exceeded") return bundle def _uploaded_material_descriptors() -> list[dict[str, str]]: out: list[dict[str, str]] = [] for item in st.session_state.get("up_research_materials") or []: name = os.path.basename(str(getattr(item, "name", "") or "")) mime_type = str(getattr(item, "type", "") or "") if name or mime_type: out.append({"name": name, "mime_type": mime_type}) return out def _normalized_research_intent_text(value: object) -> str: """Normalize intent text so whitespace-only edits do not reset design.""" return re.sub(r"\s+", " ", str(value or "")).strip() def _method_intent_signature() -> str: """Stable Title/Keywords identity used by design reconciliation and launch.""" payload = { "research_design_control": "single-authority-registry-projection-v1", "topic": _normalized_research_intent_text( st.session_state.get("sp_topic") or DEFAULT_TOPIC ), "keywords": _canonical_user_keywords( st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS ), } return hashlib.sha256( json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() def _reconcile_research_design_authority() -> tuple[str, str, dict]: """Maintain one mutable new-task Research Design authority. The selector is only a UI projection. Explicit selector changes win over a simultaneous Title/Keywords edit; otherwise a genuine intent edit resets to the registry-configured default. Product tier, word count, language, upload, payment, and Generate reruns do not participate in this reconciliation. """ default_family = default_method_family() authority = normalize_method_family(st.session_state.get("sp_research_design_authority")) if not authority: # One-time migration for live sessions created by the previous UI key. authority = ( normalize_method_family(st.session_state.get("sp_method_selector")) or default_family ) current_ui = ( normalize_method_family(st.session_state.get("sp_method_selector_ui")) or authority ) previous_ui = ( normalize_method_family(st.session_state.get("sp_research_design_ui_snapshot")) or authority ) current_signature = _method_intent_signature() previous_signature = str(st.session_state.get("sp_research_intent_snapshot") or "").strip() if not previous_signature: previous_signature = current_signature reconciliation = reconcile_method_authority( current_authority=authority, current_ui_family=current_ui, previous_ui_family=previous_ui, current_intent_signature=current_signature, previous_intent_signature=previous_signature, ) resolved_family = normalize_method_family( reconciliation.get("method_family"), fallback_to_default=True ) if reconciliation.get("ui_changed") or reconciliation.get("intent_changed"): # Any explicit design/intent change invalidates a not-yet-launched snapshot. st.session_state["sp_pending_launch"] = None st.session_state["sp_research_design_authority"] = resolved_family st.session_state["sp_method_selector_ui"] = resolved_family st.session_state["sp_research_design_ui_snapshot"] = resolved_family st.session_state["sp_research_intent_snapshot"] = current_signature return resolved_family, current_signature, reconciliation def _current_method_preflight(*, confirmed_method_family: str | None = None) -> tuple[dict, str]: """Validate the current/frozen authority without re-selecting a design.""" signature = _method_intent_signature() confirmed = normalize_method_family(confirmed_method_family) if not confirmed: confirmed = normalize_method_family( st.session_state.get("sp_research_design_authority"), fallback_to_default=True, ) preflight = resolve_generation_preflight( str(st.session_state.get("sp_topic") or DEFAULT_TOPIC), _canonical_user_keywords(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS), # P0-R7-F1: new tasks are intentionally no-upload. Never let stale # Streamlit widget/session state influence design resolution. materials=[], confirmed_method_family=confirmed, ) return preflight, signature def _clear_new_task_material_state() -> None: """Remove stale upload/session projections for the no-upload new-task UI. Historical/resumable tasks retain their already-frozen request.json inputs; this guard only prevents browser session residue from entering a new task. """ st.session_state["up_research_materials"] = [] st.session_state["sp_material_parse_cache"] = {} for family in METHOD_FAMILIES: st.session_state.pop(f"up_research_materials_{family}", None) def _classify_and_save_research_materials( method_family: str, inputs_dir: Path ) -> tuple[list[str], list[str], list[str], str, list[dict]]: """Persist template-compatible research materials for the selected design. New tasks no longer accept uploaded reference literature. Research files are combined into one deterministic text bundle so the frozen GenerationRunner continues using its proven single empirical-input seam. """ entries = _uploaded_material_entries() if not entries: return [], [], [], "none", [] report = _material_preflight_report(method_family, entries) if not report.get("ok"): raise RuntimeError("research_material_preflight_failed") original_research = _save_uploaded( [entry["item"] for entry in entries], inputs_dir, "research" ) data: list[str] = [] interviews: list[str] = [] capability = method_capability(method_family) research_bucket = _explicit_material_bucket(method_family) template_profile = str(capability.get("template_profile") or "").strip() bundle_text = _material_bundle_text(entries, research_bucket, template_profile) if not bundle_text: raise RuntimeError("research_material_bundle_empty") bundle_name = "research_materials_bundle.txt" bundle_path = inputs_dir / bundle_name bundle_path.write_text(bundle_text, encoding="utf-8") bundle_rel = str(Path("inputs") / bundle_name) if research_bucket == "data": data = [bundle_rel] else: interviews = [bundle_rel] role_manifest = [ { "name": entry["name"], "role": "research", "saved_path": original_research[index], } for index, entry in enumerate(entries) ] return [], data, interviews, "research_only_template_v2", role_manifest def _persist_task_request( task: dict, mode: str, lang_code: str, *, method_family: str, method_resolver_version: str, ) -> dict: task_id = str(task.get("task_id") or "").strip() numeric_id = (st.session_state.get("sp_user") or {}).get("numeric_id") if not task_id or not isinstance(numeric_id, int) or numeric_id <= 0: raise RuntimeError("task_identity_missing") task_dir = _task_dir(task_id) inputs_dir = task_dir / "inputs" inputs_dir.mkdir(parents=True, exist_ok=True) capability = method_capability(method_family) expected_lane = str(capability.get("legacy_policy_lane") or "").strip().lower() if not expected_lane or expected_lane != str(mode or "").strip().lower(): raise RuntimeError("method_family_policy_lane_mismatch") # P0-R7-F1 no-upload contract for every NEW request. Do not consult # Streamlit uploader/session state here: request freeze is the server-side # authority that closes hidden/stale upload paths. Legacy frozen tasks are # unaffected because they already own their immutable request.json. refs: list[str] = [] data: list[str] = [] interviews: list[str] = [] material_use_decision = "none" material_file_roles: list[dict] = [] request_data = { "task_id": task_id, "user_id": numeric_id, "mode": mode, "method_family": method_family, "method_resolver_version": str(method_resolver_version or METHOD_RESOLVER_VERSION), "derived_policy_lane": expected_lane, "material_use_decision": material_use_decision, "material_file_roles": material_file_roles, "interface_language": lang_code, "target_lang": get_target_language(lang_code), "output_suffix": get_output_suffix(lang_code), "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC), "keywords": _canonical_user_keywords(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS), "target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]), # High-entropy, display-only token. It is part of the immutable task # request/hash, but it grants no Worker/D1 authority. "preview_token": secrets.token_urlsafe(32), "reference_files": refs, "data_files": data, "interview_files": interviews, "created_at": datetime.now(timezone.utc).isoformat(), } request_data["request_hash"] = _canonical_request_hash(request_data) _atomic_json(task_dir / "request.json", request_data) return request_data def _pid_alive(pid: int) -> bool: if not isinstance(pid, int) or pid <= 0: return False try: os.kill(pid, 0) return True except OSError: return False def _pid_is_task_executor(pid: int, task_id: str, task_dir: Path) -> bool: """Best-effort Linux identity check before trusting/killing a launch PID. Hugging Face Spaces run on Linux. A stale PID can be reused, so a bare ``os.kill(pid, 0)`` is never enough to identify our executor. """ if not _pid_alive(pid): return False try: raw = (Path("/proc") / str(pid) / "cmdline").read_bytes() command = raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore") except Exception: return False return ( "task_executor.py" in command and f"--task-id {task_id}" in command and str(task_dir.resolve()) in command ) def _launch_executor(task_id: str, *, force: bool = False) -> int: """Start a detached executor without making Streamlit its lifecycle owner. ``subprocess.Popen`` only proves that the OS accepted the child process. R3.2 therefore records the PID and returns immediately. The executor owns claim/heartbeat/checkpoint/finalization; Worker/D1 owns task authority and startup-lease expiry. Streamlit never waits for claim and never terminates an executor because a UI-side timer expired. """ task_dir = _task_dir(task_id) request_path = task_dir / "request.json" if not request_path.is_file(): raise RuntimeError("task_request_manifest_missing") request_data = json.loads(request_path.read_text(encoding="utf-8")) numeric_id = int(request_data.get("user_id") or 0) request_hash = str(request_data.get("request_hash") or "").strip() if numeric_id <= 0 or not request_hash: raise RuntimeError("task_request_identity_invalid") launch_marker_path = task_dir / "executor_launch.json" claim_marker_path = task_dir / "executor_claim.json" if launch_marker_path.is_file(): try: marker = json.loads(launch_marker_path.read_text(encoding="utf-8")) pid = int(marker.get("pid") or 0) if _pid_is_task_executor(pid, task_id, task_dir) and not force: logging.info("[SP_UI] executor_already_spawned task_id=%s pid=%s", task_id, pid) return pid except Exception: pass # A stale local claim marker can never establish ownership for a new # launch/resume. Epoch fencing in Worker/D1, not process termination here, # prevents an older executor from overwriting a newer epoch. try: claim_marker_path.unlink(missing_ok=True) except TypeError: if claim_marker_path.exists(): claim_marker_path.unlink() log_path = task_dir / "executor.log" task_dir.mkdir(parents=True, exist_ok=True) command = [ sys.executable, str(Path(__file__).resolve().parent / "task_executor.py"), "--task-dir", str(task_dir.resolve()), "--task-id", str(task_id), "--user-id", str(numeric_id), ] executor_env = os.environ.copy() executor_env["SMARTPAPER_EXECUTOR_LOG_PATH"] = str(log_path.resolve()) process = subprocess.Popen( command, cwd=str(Path(__file__).resolve().parent), stdin=subprocess.DEVNULL, # Inherit stdout/stderr so executor bootstrap/errors are visible in HF # Container Logs; task_executor also writes the same log to executor.log. close_fds=True, start_new_session=True, env=executor_env, ) _atomic_json( launch_marker_path, { "pid": process.pid, "task_id": str(task_id), "request_hash": request_hash, "launched_at": datetime.now(timezone.utc).isoformat(), }, ) logging.info("[SP_UI] executor_spawned task_id=%s pid=%s", task_id, process.pid) return int(process.pid) def _process_pending_cancel() -> None: task_id = str(st.session_state.get("sp_pending_cancel_task_id") or "").strip() if not task_id: return result = cancel_generation_task(task_id) if result.get("cancelled"): _cleanup_task_artifacts(task_id) st.session_state["sp_pending_cancel_task_id"] = "" st.session_state["sp_generation_task"] = dict(result.get("task") or {}) or None st.rerun() def _process_pending_payment_dialog(lang_code: str) -> None: """Reopen purchase dialog until the initial browser bridge creates its trusted order.""" if not bool(st.session_state.get("sp_payment_dialog_pending")): return render_payment_purchase(lang_code) def _process_pending_resume() -> None: task_id = str(st.session_state.get("sp_pending_resume_task_id") or "").strip() if not task_id: return task = dict(st.session_state.get("sp_generation_task") or {}) capable, reason = _local_resume_capability(task) if str(task.get("task_id") or "").strip() != task_id or not capable: logging.warning("[SP_UI] resume_unavailable task_id=%s reason=%s", task_id, reason) st.session_state["sp_pending_resume_task_id"] = "" lang_code = str(st.session_state.get("sp_lang_code") or "zh") st.session_state["sp_terminal_notice"] = { "kind": "warning", "message": tr(lang_code, "resume_unavailable_runtime_replaced"), } st.rerun() _launch_executor(task_id, force=True) st.session_state["sp_pending_resume_task_id"] = "" st.session_state["sp_scroll_to_generation_action"] = True st.rerun() def _process_pending_launch(lang_code: str) -> None: pending = st.session_state.get("sp_pending_launch") if not isinstance(pending, dict): return pending_family = str(pending.get("method_family") or "").strip().casefold() pending_mode = str(pending.get("mode") or "").strip().lower() idempotency_key = str(pending.get("idempotency_key") or "").strip() pending_signature = str(pending.get("method_intent_signature") or "").strip() # Validate the frozen launch snapshot. This gate may reject stale state, but # it must never choose or rewrite Research Design after the user clicks Generate. current_authority = normalize_method_family( st.session_state.get("sp_research_design_authority") ) preflight, current_signature = _current_method_preflight( confirmed_method_family=pending_family ) resolved_family = str(preflight.get("method_family") or "").strip().casefold() resolved_mode = str(preflight.get("legacy_policy_lane") or "").strip().lower() capability = dict(preflight.get("capability") or {}) projection_matches = all([ str(pending.get("capability_registry_version") or "").strip() == str(capability.get("registry_version") or "").strip(), str(pending.get("engine_family") or "").strip() == str(capability.get("engine_family") or "").strip(), str(pending.get("template_profile") or "").strip() == str(capability.get("template_profile") or "").strip(), str(pending.get("material_profile") or "").strip() == str(capability.get("material_profile") or "").strip(), ]) if ( not bool(preflight.get("can_start_generation")) or not pending_family or pending_family != current_authority or pending_family != resolved_family or pending_mode != resolved_mode or not projection_matches or not pending_signature or pending_signature != current_signature ): st.session_state["sp_pending_launch"] = None reason = str(preflight.get("block_reason") or "method_preflight_changed") st.warning(tr(lang_code, "method_resolution_invalid")) return projection = get_product_ui_projection() selected_product_tier = ( str(st.session_state.get("sp_product_tier") or "").strip().upper() if projection.get("mode") == "a5_product" else None ) try: response = start_generation_task( idempotency_key=idempotency_key, mode=resolved_mode, interface_language=lang_code, target_words=int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]), product_tier=selected_product_tier, ) except Exception as exc: st.session_state["sp_pending_launch"] = None raw = str(exc or "") if "product_entitlement_required" in raw: st.session_state["sp_payment_required"] = True st.error(tr(lang_code, "product_entitlement_required")) elif "payment_required" in raw: st.session_state["sp_payment_required"] = True st.error(tr(lang_code, "quota_exhausted")) elif "a5_product_task_path_not_active" in raw or "a5_product_task_path_not_authorized" in raw: st.error(tr(lang_code, "product_generation_not_active")) else: st.error(raw) return task = dict(response.get("task") or {}) task_id = str(task.get("task_id") or "").strip() if not task_id: st.session_state["sp_pending_launch"] = None st.error("task_start_missing_task_id") return st.session_state["sp_generation_task"] = task st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "") st.session_state["run_mode"] = resolved_mode try: request_path = _task_dir(task_id) / "request.json" if not request_path.is_file(): _persist_task_request( task, resolved_mode, lang_code, method_family=resolved_family, method_resolver_version=str( (preflight.get("resolution") or {}).get("resolver_version") or METHOD_RESOLVER_VERSION ), ) _launch_executor(task_id) except Exception as exc: st.session_state["sp_pending_launch"] = None st.session_state["sp_pending_cancel_task_id"] = task_id st.session_state["sp_terminal_notice"] = {"kind": "error", "message": str(exc)[:800]} st.rerun() st.session_state["sp_pending_launch"] = None st.session_state["sp_payment_required"] = False st.session_state["sp_scroll_to_generation_action"] = True st.rerun() inject_wechat_browser_notice() DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning" DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized education; Educational technologies; Recommendation system;" def _current_language_code() -> str: choice = st.session_state.get("interface_language_choice") return LANGUAGE_OPTIONS.get(choice, "zh") authenticated_user = require_alipay_login(_current_language_code()) if not bool(st.session_state.get("sp_task_recovery_initialized")): recover_generation_task() st.session_state["sp_task_recovery_initialized"] = True _recovered_task = st.session_state.get("sp_generation_task") or {} _recovered_mode = str((_recovered_task or {}).get("mode") or "").strip().lower() if _recovered_mode in {"amj", "asq"}: # The task record, not the first visual tab, owns the research mode after refresh. st.session_state["run_mode"] = _recovered_mode _user_workspace = os.path.join(WORKSPACE_DIR, "users", _safe_key(authenticated_user.get("id"))) os.makedirs(_user_workspace, exist_ok=True) ACTIVE_TASK_STATUSES = {"created", "running", "resumable", "cancelling"} LIVE_TASK_STATUSES = {"created", "running", "cancelling"} def _localized_task_status(lang_code: str, status: str) -> str: key = f"task_status_{str(status or '').strip().lower()}" value = tr(lang_code, key) return value if value != key else tr(lang_code, "draft_stage_processing") def _localized_monitor_node(lang_code: str, node: str) -> str: """Translate stable executor progress codes without exposing engineering identifiers.""" value = str(node or "").strip() exact = { "inputs_and_evidence": "draft_stage_inputs", "S2 + OpenAlex": "draft_stage_search", "literature_synthesis": "draft_stage_synthesis", "research_logic": "draft_stage_research_logic", "working_front_matter": "draft_stage_working_front", "outline_planning": "draft_stage_outline", "citation_planning": "draft_stage_citation", "english_validation": "draft_stage_english_validation", "final_front_matter": "draft_stage_final_front", "translation": "draft_stage_translation", "translation_complete": "translation_complete_label", "packaging": "draft_stage_packaging", "package_validation": "draft_stage_package_validation", "package_validated": "draft_stage_package_validated", } if value in exact: return tr(lang_code, exact[value]) for prefix, key in ( ("writing:", "draft_stage_writing_unit"), ("citation_audit:", "draft_stage_citation_audit_unit"), ("citation_recovery:", "draft_stage_citation_recovery_unit"), ("writing_complete:", "draft_stage_writing_complete_unit"), ("translation_final_gate:", "draft_stage_translation"), ): if value.startswith(prefix): parts = value[len(prefix):].split(":") if key == "draft_stage_translation": return tr(lang_code, key) if len(parts) >= 2: return tr(lang_code, key, current=parts[0], total=parts[1]) return tr(lang_code, "draft_stage_processing") if value.startswith("translation_"): return tr(lang_code, "draft_stage_translation") if value.startswith("section_"): return tr(lang_code, "draft_stage_writing") return tr(lang_code, "draft_stage_processing") def _markdown_safe_html(markup: str) -> str: """Prevent Streamlit Markdown from reclassifying indented HTML as code. Only indentation before actual HTML tags is removed. Text rendered inside preview containers keeps its own leading whitespace. """ lines = [] for line in textwrap.dedent(str(markup or "")).strip().splitlines(): stripped = line.lstrip() lines.append(stripped if stripped.startswith("<") else line) return "\n".join(lines) def _render_monitor_box(task: dict, lang_code: str) -> None: """Render the approved monitor location with DOM targets for live updates.""" status = str(task.get("status") or "") node = str(task.get("current_node") or status or "") status_label = _localized_task_status(lang_code, status) node_label = _localized_monitor_node(lang_code, node) progress = max(0, min(100, int(task.get("progress_pct") or 0))) checkpoint_seq = int(task.get("checkpoint_seq") or 0) checkpoint_stage = str(task.get("checkpoint_stage") or "") ds_calls = int(task.get("ds_calls") or 0) s2_calls = int(task.get("s2_calls") or 0) oa_calls = int(task.get("oa_calls") or 0) progress_markup = "" if status in ACTIVE_TASK_STATUSES: progress_markup = textwrap.dedent(f"""