""" Generation consent + dossier gate (F1). grounding_mode: - regulation: normal grounded generation - structure_only: user explicitly allowed generation without regulation pack - blocked: blind dossier and no valid consent Env: REQUIRE_DOSSIER_OR_CONSENT=true (default) — enforce gate ALLOW_STRUCTURE_WITHOUT_REGULATION=true (default) — allow consent path """ from __future__ import annotations import os from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple CONSENT_MODE_STRUCTURE = "structure_without_regulation" GROUNDING_REGULATION = "regulation" GROUNDING_STRUCTURE = "structure_only" GROUNDING_BLOCKED = "blocked" ERROR_CODE = "REGULATION_DOSSIER_REQUIRED" STRUCTURE_EXPORT_REASON = ( "Tryb strukturalny (brak regulaminu w bazie wiedzy) — wymagana weryfikacja człowieka " "przed złożeniem wniosku w instytucji." ) STRUCTURE_ONLY_PROMPT = """ === TRYB STRUKTURALNY (BEZ REGULAMINU NABORU W SYSTEMIE) === Użytkownik jawnie zgodził się na generację SZKIELETU wniosku bez ugruntowania w regulaminie. OBOWIĄZKOWE ZASADY: 1. NIE cytuj regulaminu naboru, NIE udawaj § / numerów aktów, których nie masz w kontekście. 2. Każdą regułę programową oznacz: [DO WERYFIKACJI: regulamin naboru]. 3. Opieraj się wyłącznie na opisie projektu, danych firmy i ogólnym szablonie sekcji. 4. Nie twierdź, że treść jest zgodna z wymogami operatora. 5. Preferuj konserwatywne sformułowania i listy braków do uzupełnienia. """ def require_dossier_or_consent_enabled() -> bool: return os.environ.get("REQUIRE_DOSSIER_OR_CONSENT", "true").lower() in ( "1", "true", "yes", ) def allow_structure_without_regulation() -> bool: return os.environ.get("ALLOW_STRUCTURE_WITHOUT_REGULATION", "true").lower() in ( "1", "true", "yes", ) def _dossier_level(ext: Dict[str, Any]) -> str: """best-effort readiness level from external_context.""" pd = ext.get("program_dossier") if isinstance(ext.get("program_dossier"), dict) else {} readiness = pd.get("readiness") if isinstance(pd.get("readiness"), dict) else {} level = (readiness.get("level") or "").lower().strip() if level in ("blind", "partial", "ready"): return level # Infer from documents / regulation URL docs = ext.get("regulation_documents") or pd.get("regulation_documents") or [] primary = ( ext.get("precise_regulation_url") or ext.get("regulation_url") or "" ) from core.grants.program_dossier import dossier_readiness if isinstance(docs, list) and docs: r = dossier_readiness(docs, str(primary)) return r.get("level") or "blind" if primary and str(primary).startswith("http"): # single URL may still be announcement — treat as partial if present try: from core.grants.regulation_url_quality import is_valid_regulation_url if is_valid_regulation_url(str(primary)) or str(primary).lower().endswith(".pdf"): return "partial" except Exception: if str(primary).lower().endswith(".pdf"): return "partial" return "blind" def has_valid_structure_consent(ext: Dict[str, Any]) -> bool: consent = ext.get("generation_consent") if not isinstance(consent, dict): return False if consent.get("mode") != CONSENT_MODE_STRUCTURE: return False if not consent.get("granted_at"): return False # Consent only valid while still blind at decision time OR still structure mode requested # After non-blind, resolve_grounding_mode ignores consent return True def resolve_grounding_mode(external_context: Optional[Dict[str, Any]]) -> str: """Pure decision: regulation | structure_only | blocked.""" ext = dict(external_context or {}) if not require_dossier_or_consent_enabled(): return GROUNDING_REGULATION level = _dossier_level(ext) if level in ("ready", "partial"): return GROUNDING_REGULATION # blind if has_valid_structure_consent(ext) and allow_structure_without_regulation(): return GROUNDING_STRUCTURE return GROUNDING_BLOCKED def consent_required(external_context: Optional[Dict[str, Any]]) -> bool: if not require_dossier_or_consent_enabled(): return False return resolve_grounding_mode(external_context) == GROUNDING_BLOCKED def build_generation_gate(external_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: """ Returns: allowed: bool grounding_mode: str dossier_readiness: str consent_required: bool code: optional error code message: human message structure_prompt: optional injection for structure_only """ ext = dict(external_context or {}) level = _dossier_level(ext) mode = resolve_grounding_mode(ext) out: Dict[str, Any] = { "allowed": mode != GROUNDING_BLOCKED, "grounding_mode": mode, "dossier_readiness": level, "consent_required": mode == GROUNDING_BLOCKED, "code": None, "message": "", "structure_prompt": None, "human_review_required": mode == GROUNDING_STRUCTURE, "export_reason": STRUCTURE_EXPORT_REASON if mode == GROUNDING_STRUCTURE else None, "require_flag_on": require_dossier_or_consent_enabled(), "allow_structure_flag_on": allow_structure_without_regulation(), } if mode == GROUNDING_BLOCKED: out["code"] = ERROR_CODE out["message"] = ( "Brak regulaminu naboru w systemie (dossier: blind). " "Dołącz regulamin (URL/PDF) albo jawnie zaakceptuj generację struktury bez reguł." ) elif mode == GROUNDING_STRUCTURE: out["message"] = ( "Tryb strukturalny: generacja bez ugruntowania w regulaminie (zgoda użytkownika)." ) out["structure_prompt"] = STRUCTURE_ONLY_PROMPT.strip() else: out["message"] = "Generacja ugruntowana w dokumentach naboru." return out def apply_structure_consent( external_context: Optional[Dict[str, Any]], *, ack: bool, mode: str, clerk_user_id: str, grant_id: str = "", ) -> Tuple[Dict[str, Any], Optional[str]]: """ Returns (new_ext, error_message). error_message set on validation failure. """ if not ack: return dict(external_context or {}), "Wymagane jawne potwierdzenie (ack=true)." if mode != CONSENT_MODE_STRUCTURE: return dict(external_context or {}), f"Nieobsługiwany mode={mode}." if not allow_structure_without_regulation(): return dict(external_context or {}), "Generacja bez regulaminu jest wyłączona (ALLOW_STRUCTURE_WITHOUT_REGULATION)." ext = dict(external_context or {}) level = _dossier_level(ext) if level in ("ready", "partial"): # No need for structure consent — force regulation ext.pop("generation_consent", None) ext["grounding_mode"] = GROUNDING_REGULATION return ext, "Dossier nie jest blind — zgoda na structure_only nie jest potrzebna." ext["generation_consent"] = { "mode": CONSENT_MODE_STRUCTURE, "granted_at": datetime.now(timezone.utc).isoformat(), "granted_by": clerk_user_id or "unknown", "dossier_level_at_consent": level, "grant_id": grant_id or str((ext.get("selected_grant") or {}).get("id") or ext.get("grant_id") or ""), "version": 1, } ext["grounding_mode"] = GROUNDING_STRUCTURE return ext, None def clear_structure_consent(external_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: ext = dict(external_context or {}) ext.pop("generation_consent", None) # recompute mode mode = resolve_grounding_mode(ext) ext["grounding_mode"] = mode if mode != GROUNDING_BLOCKED else GROUNDING_BLOCKED return ext def sync_grounding_after_dossier_update(external_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: """ Call after regulation attach / dossier ensure. If no longer blind, drop structure consent and set regulation mode. """ ext = dict(external_context or {}) level = _dossier_level(ext) if level in ("ready", "partial"): ext.pop("generation_consent", None) ext["grounding_mode"] = GROUNDING_REGULATION # store snapshot of readiness for GET pd = ext.get("program_dossier") if isinstance(ext.get("program_dossier"), dict) else {} readiness = pd.get("readiness") if isinstance(pd.get("readiness"), dict) else {} if not readiness: pd = dict(pd) pd["readiness"] = {"level": level} ext["program_dossier"] = pd return ext mode = resolve_grounding_mode(ext) ext["grounding_mode"] = mode return ext def project_generation_meta(external_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Fields to expose on GET project.""" gate = build_generation_gate(external_context) ext = dict(external_context or {}) return { "grounding_mode": gate["grounding_mode"], "dossier_readiness": gate["dossier_readiness"], "consent_required": gate["consent_required"], "generation_allowed": gate["allowed"], "generation_consent": ext.get("generation_consent"), "generation_gate_message": gate["message"], }