"""Heuristic AI involvement transparency for section payloads. Percentages are *estimates* derived from mode + AI level, not cryptographic proof. They communicate how much of the output is attributable to user bullets, retrieved chunks from the tenant's own uploads, versus LLM synthesis. """ from __future__ import annotations import logging from typing import Any from app.models.schemas import GenerationMode logger = logging.getLogger(__name__) def _mode_value(mode: str | GenerationMode) -> str: if isinstance(mode, GenerationMode): return mode.value return str(mode).lower() def compute_ai_transparency( mode: str | GenerationMode, ai_level: int, ai_percent: int | None = None, measured_ai_percent: int | None = None, ) -> dict[str, Any]: """Return a JSON-serialisable transparency dict for API responses. Args: mode: ``generate`` | ``proofread`` | ``enhance``. ai_level: 1–5 (RAG-only … full AI) — used when ``ai_percent`` is not set. ai_percent: If provided for **generate** mode, ``ai_involvement_percent`` is aligned to the user slider (0–100) so the UI matches behaviour. Returns: Dict with ``ai_involvement_percent``, ``breakdown``, ``summary``, ``transformations``, and ``plain_language``. """ level = max(1, min(5, int(ai_level))) mv = _mode_value(mode) if mv == GenerationMode.proofread.value: # Mostly your text; AI edits language only. ai_pct = 8 + (level - 1) * 2 user_pct = 100 - ai_pct return { "ai_involvement_percent": ai_pct, "breakdown": { "user_original_text_percent": user_pct, "ai_language_review_percent": ai_pct, "retrieved_context_percent": 0, }, "summary": ( "Proofread mode: your section text is preserved; the AI applies " "grammar, clarity, and style adjustments only." ), "transformations": [ "Grammar and spelling corrections", "Clarity and readability improvements", "Alignment with your detected writing style", ], "plain_language": ( f"About {ai_pct}% of the wording may be adjusted by the AI for " "readability; meaning and facts should stay aligned with your bullets." ), } if mv == GenerationMode.enhance.value: # Original text + retrieved evidence + expansion. base_ai = 28 + (level - 1) * 6 retrieval_pct = min(35, 22 + level * 2) user_pct = max(0, 100 - base_ai - retrieval_pct) return { "ai_involvement_percent": base_ai, "breakdown": { "user_original_and_bullets_percent": user_pct, "your_documents_retrieval_percent": retrieval_pct, "ai_technical_expansion_percent": base_ai, }, "summary": ( "Enhance mode: your existing text is expanded using additional " "passages retrieved *only from documents you uploaded* for this tenant." ), "transformations": [ "Semantic search across your uploaded RICS / survey documents", "Technical depth and professional phrasing added", "missing-information phrasing when facts cannot be confirmed", ], "plain_language": ( f"Roughly {retrieval_pct}% of the signal comes from retrieved " f"snippets from your own files; about {base_ai}% reflects AI synthesis " "and bridging prose." ), } # generate — align headline % with the user's ai_percent slider when present. logger.debug( "compute_ai_transparency: mode=%r ai_level=%r ai_percent=%r (type=%s)", mv, level, ai_percent, type(ai_percent).__name__, ) if ai_percent is not None: try: slider = max(0, min(100, int(ai_percent))) except Exception: # noqa: BLE001 slider = ai_level_to_approx_percent(level) # Split remainder between bullets vs retrieval heuristically (not additive with slider). bullet_pct = max(10, round(42 - slider * 0.22)) retrieval_pct = max(15, round(48 - slider * 0.18)) synth_pct = max(0, 100 - bullet_pct - retrieval_pct) total = bullet_pct + retrieval_pct + synth_pct bullet_pct = round(100 * bullet_pct / total) retrieval_pct = round(100 * retrieval_pct / total) synth_pct = 100 - bullet_pct - retrieval_pct low_mode = slider <= 12 measured: int | None = None if measured_ai_percent is not None: try: measured = max(0, min(100, int(measured_ai_percent))) except Exception: # noqa: BLE001 measured = None # Prefer measured involvement when available (it is computed from verbatim overlap), # but keep the user's requested slider value alongside it. headline = measured if measured is not None else slider return { "ai_involvement_percent": headline, "requested_ai_involvement_percent": slider, "measured_ai_involvement_percent": measured, "breakdown": { "user_bullets_percent": bullet_pct, "your_documents_retrieval_percent": retrieval_pct, "ai_synthesis_and_style_percent": synth_pct, }, "summary": ( "Low AI involvement: assembly from your notes and retrieved standard " "wording with minimal new prose." if low_mode else "Generate mode: your bullets and retrieved firm/standard passages are primary; " "AI contribution scales with the involvement slider." ), "transformations": [ "Vector similarity search over your ingested uploads (retrieval-first)", "Section skeleton + non-fictional notes", "LLM task controlled by AI involvement: verbatim assembly (0–12%) → full drafting (75–100%)", ], "plain_language": ( f"Your slider is {slider}% AI involvement. " f"About {bullet_pct}% of the signal is attributed to your notes, " f"{retrieval_pct}% to retrieved text from your documents, " f"and {synth_pct}% to AI synthesis and connectors — exact split is an estimate." ), } # generate (legacy): infer from 1–5 level only bullet_pct = max(18, 32 - level * 2) retrieval_pct = max(22, 38 - (level - 3) * 3) ai_pct = max(5, min(55, 100 - bullet_pct - retrieval_pct)) total = bullet_pct + retrieval_pct + ai_pct bullet_pct = round(100 * bullet_pct / total) retrieval_pct = round(100 * retrieval_pct / total) ai_pct = 100 - bullet_pct - retrieval_pct return { "ai_involvement_percent": ai_pct, "breakdown": { "user_bullets_percent": bullet_pct, "your_documents_retrieval_percent": retrieval_pct, "ai_synthesis_and_style_percent": ai_pct, }, "summary": ( "Generate mode: your fact bullets drive content; similar text is " "retrieved from *your uploaded documents only* (same tenant), then " "the AI adapts prose to RICS style and your writing profile." ), "transformations": [ "Notes interpretation / expansion (unless AI level 1 — RAG only)", "Vector similarity search over your ingested uploads", "Reranking for relevance", "LLM adaptation to section skeleton + style profile", ], "plain_language": ( f"Approximately {bullet_pct}% derives from your bullets, " f"{retrieval_pct}% from retrieved excerpts of your own files, " f"and {ai_pct}% from AI wording and structure." ), } def ai_level_to_approx_percent(level: int) -> int: """Map legacy 1–5 scale to a midpoint percent for fallback messaging.""" return min(100, max(0, (max(1, min(5, int(level))) - 1) * 25 + 12))