grantforge-api / backend /core /projects /generation_consent.py
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
9.4 kB
"""
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"],
}