File size: 14,623 Bytes
377c1bc
0136798
377c1bc
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
377c1bc
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377c1bc
da8a68d
 
 
 
0136798
 
da8a68d
 
 
 
 
0136798
 
 
 
377c1bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
377c1bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377c1bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
377c1bc
0136798
377c1bc
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da8a68d
 
 
 
 
 
377c1bc
 
 
 
0136798
da8a68d
377c1bc
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377c1bc
da8a68d
377c1bc
 
da8a68d
0136798
 
 
 
 
 
 
 
 
377c1bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
"""Writing style analyser β€” extracts a WritingStyleProfile from surveyor documents.

Two analysis modes:
* ``analyze_writing_style`` β€” lightweight (1 200 tokens), used for per-tenant
  profiles built from the user's own uploaded documents.
* ``build_reference_style_profile`` β€” thorough (4 000 tokens), used once at
  startup to build the Behrang reference profile from the KB corpus.  Extracts
  actual example paragraphs for few-shot style injection in generation prompts.
"""

import json
import logging

from app.models.schemas import WritingStyleProfile

logger = logging.getLogger(__name__)

MAX_SAMPLE_TOKENS = 1200
MAX_REFERENCE_SAMPLE_TOKENS = 4000

# ---------------------------------------------------------------------------
# Lightweight per-tenant style analyser
# ---------------------------------------------------------------------------

_STYLE_SYSTEM_PROMPT = """\
You are an expert writing-style analyst specialising in professional UK property \
survey and technical reports written in British English. Analyse the sample text \
provided and return a single JSON object describing the author's writing style. \
All values in the JSON must themselves use British English spellings \
(e.g. "colour" not "color", "analyse" not "analyze", "organise" not "organize"). \
Output ONLY the JSON object, no markdown fences, no commentary.\
"""

_STYLE_USER_TEMPLATE = """\
Analyse the writing style of the following sample text and return a JSON \
object with exactly these keys:

{{
  "tone": "<formal | semi-formal | technical | casual>",
  "formality_level": "<professional | academic | conversational>",
  "avg_sentence_complexity": "<simple | moderate | complex>",
  "vocabulary_level": "<basic | intermediate | technical | specialist>",
  "common_phrases": ["<phrase 1>", "<phrase 2>", "<phrase 3>"],
  "structural_patterns": ["<pattern 1>", "<pattern 2>"],
  "writing_style_summary": "<One sentence description of the overall style>",
  "example_paragraphs": [
    "<Copy verbatim a SHORT paragraph (40–80 words) from the sample that best shows how this surveyor describes a building condition or defect. Choose factual prose, not headings or fragments. If no suitable paragraph exists in the sample, return an empty list.>",
    "<Optionally a second verbatim paragraph (40–80 words) showing how this surveyor phrases a recommendation or observation. Verbatim or empty list β€” do not paraphrase.>"
  ]
}}

IMPORTANT for example_paragraphs: copy text VERBATIM from the sample β€” do not paraphrase, \
do not invent, do not blend multiple sentences from different places. Each entry must be a \
contiguous extract that exists word-for-word in the sample. If the sample is too short or \
fragmentary to extract a clean paragraph, return an empty list.

SAMPLE TEXT:
{sample_text}
"""

# ---------------------------------------------------------------------------
# Thorough reference style analyser (used for KB / Behrang's reports)
# ---------------------------------------------------------------------------

_REFERENCE_STYLE_SYSTEM_PROMPT = """\
You are an expert writing-style analyst specialising in professional UK RICS \
Building Survey reports. You have been given extracts from a specific surveyor's \
completed reports. Your task is to capture their EXACT writing voice so that an \
AI can reproduce it faithfully when generating new survey sections.

Focus on:
- Characteristic sentence openings and transitions ("It is noted that…", "At the time of inspection…")
- How defects, condition ratings, and recommendations are phrased
- Preferred technical vocabulary and professional hedging phrases
- Paragraph rhythm, typical sentence length, and use of passive vs active voice
- How the surveyor opens and closes a section

All values must use British English spellings throughout.
Output ONLY the JSON object β€” no markdown, no commentary.\
"""

_REFERENCE_STYLE_USER_TEMPLATE = """\
Analyse the following extracts from a professional RICS Building Surveyor's \
completed reports. Capture their writing style precisely.

Return a JSON object with exactly these keys:

{{
  "tone": "<formal | semi-formal | technical | casual>",
  "formality_level": "<professional | academic | conversational>",
  "avg_sentence_complexity": "<simple | moderate | complex>",
  "vocabulary_level": "<basic | intermediate | technical | specialist>",
  "common_phrases": [
    "<characteristic phrase 1>",
    "<phrase 2>",
    "<phrase 3>",
    "<phrase 4>",
    "<phrase 5>"
  ],
  "structural_patterns": [
    "<writing pattern 1 β€” e.g. how sentences begin>",
    "<pattern 2 β€” e.g. how defects are described>",
    "<pattern 3 β€” e.g. how recommendations are worded>"
  ],
  "writing_style_summary": "<Two sentences describing this surveyor's exact voice and approach>",
  "example_paragraphs": [
    "<Copy verbatim a SHORT paragraph (40–80 words) from the sample that best shows how this surveyor describes a building condition or defect>",
    "<Copy verbatim a SECOND paragraph from a different section β€” ideally a recommendation or next-step statement>",
    "<Copy verbatim a THIRD paragraph showing how they introduce or conclude a section>"
  ]
}}

SURVEYOR'S REPORT EXTRACTS:
{sample_text}
"""

_MOCK_PROFILE = WritingStyleProfile(
    tone="formal",
    formality_level="professional",
    avg_sentence_complexity="moderate",
    vocabulary_level="technical",
    common_phrases=[
        "The property",
        "It is noted that",
        "In respect of",
        "At the time of inspection",
        "We would recommend",
    ],
    structural_patterns=[
        "Topic sentence followed by supporting detail and recommendation",
        "Passive voice preferred for observations; active for recommendations",
        "Condition stated first, cause second, implication third",
    ],
    writing_style_summary=(
        "Professional RICS surveyor style with measured, factual language and passive voice. "
        "Observations are precise and conservative; recommendations are proportionate."
    ),
    example_paragraphs=[],
)


def _truncate_to_tokens(texts: list[str], max_tokens: int) -> str:
    """Concatenate texts up to ``max_tokens`` using exact tiktoken counts."""
    import tiktoken

    enc = tiktoken.get_encoding("cl100k_base")
    parts: list[str] = []
    total = 0
    for text in texts:
        token_ids = enc.encode(text)
        count = len(token_ids)
        if total + count > max_tokens:
            remaining = max(0, max_tokens - total)
            if remaining > 0:
                parts.append(enc.decode(token_ids[:remaining]))
            break
        parts.append(text)
        total += count
    return "\n\n".join(parts)


async def analyze_writing_style_async(
    sample_texts: list[str],
    openai_api_key: str = "",
    chat_model: str = "gpt-4o-mini",
) -> WritingStyleProfile:
    """Async style analysis using throttled OpenAI chat (async pipeline)."""
    if not sample_texts:
        return _MOCK_PROFILE
    if not (openai_api_key or "").strip():
        return _MOCK_PROFILE

    sample = _truncate_to_tokens(sample_texts, MAX_SAMPLE_TOKENS)
    user_prompt = _STYLE_USER_TEMPLATE.format(sample_text=sample)
    try:
        from app.llm.openai_chat import chat_completions_create

        raw = await chat_completions_create(
            messages=[
                {"role": "system", "content": _STYLE_SYSTEM_PROMPT},
                {"role": "user", "content": user_prompt},
            ],
            model=chat_model,
            max_tokens=800,
            temperature=0.0,
            response_format={"type": "json_object"},
            phase="style_analysis",
        )
        data = json.loads(raw or "{}")
        data.setdefault("example_paragraphs", [])
        profile = WritingStyleProfile(**data)
        verified: list[str] = []
        for p in profile.example_paragraphs or []:
            if isinstance(p, str) and p.strip() and p.strip()[:80] in sample:
                verified.append(p.strip())
        if verified:
            profile = WritingStyleProfile(
                **{**profile.model_dump(), "example_paragraphs": verified}
            )
        return profile
    except Exception as exc:  # noqa: BLE001
        logger.warning("Async style analysis failed (%s) β€” mock profile", exc)
        return _MOCK_PROFILE


def _call_openai_style(
    system: str,
    user: str,
    api_key: str,
    model: str,
    max_tokens: int = 600,
) -> WritingStyleProfile:
    """Shared OpenAI call for both style analyser variants."""
    from openai import OpenAI

    client = OpenAI(api_key=api_key)
    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        max_tokens=max_tokens,
        temperature=0.0,
        response_format={"type": "json_object"},
    )
    raw = (response.choices[0].message.content or "").strip()
    data = json.loads(raw)
    # example_paragraphs may not be returned by lightweight prompt β€” default to []
    data.setdefault("example_paragraphs", [])
    profile = WritingStyleProfile(**data)
    return profile


def analyze_writing_style(
    sample_texts: list[str],
    openai_api_key: str = "",
    chat_model: str = "gpt-4o-mini",
) -> WritingStyleProfile:
    """Lightweight style analysis from the user's own uploaded documents.

    Uses up to 1 200 tokens of sample text. Falls back to the mock profile
    when ``openai_api_key`` is empty or the call fails.

    Args:
        sample_texts: Ordered list of text chunks sampled from user documents.
        openai_api_key: OpenAI API key; empty string triggers mock fallback.
        chat_model: Chat model name used for analysis.

    Returns:
        :class:`~app.models.schemas.WritingStyleProfile` describing the style.
    """
    if not sample_texts:
        logger.debug("No sample texts β€” returning mock style profile")
        return _MOCK_PROFILE

    if not openai_api_key:
        logger.debug("No OpenAI key β€” returning mock style profile")
        return _MOCK_PROFILE

    sample = _truncate_to_tokens(sample_texts, MAX_SAMPLE_TOKENS)
    user_prompt = _STYLE_USER_TEMPLATE.format(sample_text=sample)

    try:
        # Bumped 500 β†’ 800 to fit two verbatim 40–80-word example paragraphs in
        # the JSON output. Without this, the model returned the tone/formality
        # fields fine but produced an empty example_paragraphs list, and the
        # downstream LCEL/inspector prompts had no surveyor-voice anchor to
        # mirror β€” which is why generated reports never sounded "like the
        # uploaded ones".
        profile = _call_openai_style(
            system=_STYLE_SYSTEM_PROMPT,
            user=user_prompt,
            api_key=openai_api_key,
            model=chat_model,
            max_tokens=800,
        )

        # Defence-in-depth: drop any "example" the LLM hallucinated (i.e. a
        # paragraph that does NOT appear verbatim in the sample text). The
        # purpose of these examples is to mirror real surveyor voice; an
        # invented paragraph would teach the downstream LLM to copy a style
        # the surveyor never used. Strict substring containment is fine
        # because the prompt asks for verbatim copies; if the model paraphrases
        # we'd rather drop the example than poison style mirroring.
        verified: list[str] = []
        for p in profile.example_paragraphs or []:
            if isinstance(p, str) and p.strip() and p.strip()[:80] in sample:
                verified.append(p.strip())
        if len(verified) != len(profile.example_paragraphs or []):
            logger.debug(
                "Style example sanitisation: kept %d / %d (dropped non-verbatim entries)",
                len(verified), len(profile.example_paragraphs or []),
            )
        # Pydantic models are immutable by default; rebuild via dict.
        profile = WritingStyleProfile(**{**profile.model_dump(), "example_paragraphs": verified})

        logger.info(
            "Style analysis complete: tone=%s formality=%s examples=%d",
            profile.tone,
            profile.formality_level,
            len(profile.example_paragraphs),
        )
        return profile

    except json.JSONDecodeError as exc:
        logger.warning("Style analysis returned non-JSON β€” using mock: %s", exc)
        return _MOCK_PROFILE
    except Exception as exc:
        logger.warning("Style analysis failed (%s) β€” using mock profile", exc)
        return _MOCK_PROFILE


def build_reference_style_profile(
    sample_texts: list[str],
    openai_api_key: str = "",
    chat_model: str = "gpt-4o-mini",
) -> WritingStyleProfile:
    """Thorough style analysis from Behrang's reference reports (KB corpus).

    Uses up to 4 000 tokens of sample text and extracts actual example
    paragraphs for use as few-shot style guidance in generation prompts.

    Falls back to the mock profile when ``openai_api_key`` is empty.

    Args:
        sample_texts: Chunks retrieved from the KB tenant vector store.
        openai_api_key: OpenAI API key; empty string triggers mock fallback.
        chat_model: Chat model name used for analysis.

    Returns:
        Rich :class:`~app.models.schemas.WritingStyleProfile` with
        ``example_paragraphs`` populated.
    """
    if not sample_texts:
        logger.debug("No KB sample texts β€” returning mock style profile")
        return _MOCK_PROFILE

    if not openai_api_key:
        logger.debug("No OpenAI key β€” returning mock style profile for reference")
        return _MOCK_PROFILE

    sample = _truncate_to_tokens(sample_texts, MAX_REFERENCE_SAMPLE_TOKENS)
    user_prompt = _REFERENCE_STYLE_USER_TEMPLATE.format(sample_text=sample)

    try:
        profile = _call_openai_style(
            system=_REFERENCE_STYLE_SYSTEM_PROMPT,
            user=user_prompt,
            api_key=openai_api_key,
            model=chat_model,
            max_tokens=900,
        )
        logger.info(
            "Reference style analysis complete: tone=%s formality=%s examples=%d",
            profile.tone,
            profile.formality_level,
            len(profile.example_paragraphs),
        )
        return profile

    except json.JSONDecodeError as exc:
        logger.warning("Reference style analysis returned non-JSON β€” using mock: %s", exc)
        return _MOCK_PROFILE
    except Exception as exc:
        logger.warning("Reference style analysis failed (%s) β€” using mock profile", exc)
        return _MOCK_PROFILE