File size: 8,345 Bytes
eda3d74
 
 
 
 
 
 
 
 
3f6016e
faa8fb3
 
eda3d74
 
3f6016e
 
eda3d74
 
 
 
 
 
 
295512e
 
 
 
0b42403
295512e
eda3d74
 
 
 
295512e
 
 
eda3d74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
eda3d74
 
 
 
 
 
 
 
295512e
3f6016e
 
 
 
 
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
 
 
0b42403
 
 
 
 
 
 
 
 
295512e
0b42403
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eda3d74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""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))