File size: 16,117 Bytes
dc1b199
 
b76f199
dc1b199
b76f199
 
dc1b199
 
 
 
 
 
 
faa8fb3
dc1b199
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da8a68d
62f2173
 
 
 
da8a68d
 
 
 
 
 
62f2173
 
 
 
 
 
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
62f2173
da8a68d
 
 
62f2173
 
da8a68d
 
 
 
 
62f2173
 
 
 
 
 
 
 
 
 
da8a68d
 
dc1b199
 
 
b76f199
dc1b199
 
 
 
 
 
b76f199
 
dc1b199
 
 
62f2173
dc1b199
 
 
 
 
752c1cd
62f2173
dc1b199
 
 
62f2173
dc1b199
 
 
 
 
752c1cd
62f2173
dc1b199
 
 
b76f199
dc1b199
 
 
 
 
 
b76f199
 
dc1b199
 
 
752c1cd
dc1b199
752c1cd
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cfebbc
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c31a2a
 
 
 
 
b76f199
3c31a2a
faa8fb3
3c31a2a
 
b76f199
3c31a2a
 
 
 
 
 
 
 
 
 
 
 
 
 
faa8fb3
3c31a2a
 
 
 
 
 
2cfebbc
 
3c31a2a
 
2cfebbc
 
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
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
"""Tests for the LLM adapter, prompt building, and post-processor.

All OpenAI calls are mocked. These tests verify:
1. The prompt context stays within MAX_CONTEXT_TOKENS.
2. The post-processor replaces invented facts with approved missing-info phrases.
3. Verifiable facts are NOT replaced.
"""

import pytest

from app.chunking.splitter import count_tokens
from app.generator.adapter import MockLLMAdapter
from app.generator.postprocess import enforce_verify, strip_verify_tags
from app.generator.prompts import _trim_snippets, build_user_prompt

# ── Prompt building tests ─────────────────────────────────────────────────────

def test_build_user_prompt_contains_skeleton() -> None:
    """The assembled prompt should include the skeleton text."""
    prompt = build_user_prompt(
        skeleton="[Location]: [description].",
        bullets=["Semi-detached, NW3"],
        snippets=[],
        max_context_tokens=400,
    )
    assert "[Location]" in prompt
    assert "DOCUMENT-LEVEL CONTEXT" in prompt
    assert "SECTION-LEVEL CONTEXT" in prompt
    assert "PARAGRAPH-LEVEL EVIDENCE" in prompt
    assert "PROPERTY IDENTITY" in prompt


def test_build_user_prompt_contains_bullets() -> None:
    """Bullets should appear in the assembled prompt."""
    prompt = build_user_prompt(
        skeleton="skeleton",
        bullets=["Semi-detached", "95 sqm"],
        snippets=[],
        max_context_tokens=400,
    )
    assert "Semi-detached" in prompt
    assert "95 sqm" in prompt


def test_trim_snippets_respects_token_limit() -> None:
    """_trim_snippets should not exceed max_tokens."""
    long_snippets = ["word " * 100 for _ in range(10)]
    result = _trim_snippets(long_snippets, max_tokens=400)
    assert count_tokens(result) <= 400 + 20  # small tolerance for label overhead


def test_trim_snippets_empty() -> None:
    """_trim_snippets with empty list should return empty string."""
    assert _trim_snippets([], max_tokens=400) == ""


def test_context_tokens_within_limit() -> None:
    """Total context tokens in the examples block must not exceed MAX_CONTEXT_TOKENS."""
    from app.config import settings

    snippets = ["This is a retrieved snippet. " * 20 for _ in range(5)]
    result = _trim_snippets(snippets, max_tokens=settings.max_context_tokens)
    assert count_tokens(result) <= settings.max_context_tokens + 30


def test_max_output_tokens_for_survey_level_scales_with_tier() -> None:
    """Per-section token floors should reflect whole-report totals.

    Because L1 has far fewer sections overall, its per-section allowance can
    be higher than L2 while still yielding a smaller full report.
    """
    from app.generator.prompts import max_output_tokens_for_survey_level

    l1 = max_output_tokens_for_survey_level(1)
    l2 = max_output_tokens_for_survey_level(2)
    l3 = max_output_tokens_for_survey_level(3)
    # Expected profile: L1 > L2, and L3 > L2.
    assert l1 > l2
    assert l3 > l2
    assert l1 >= 900
    assert l2 >= 650
    assert l3 >= 850
    # None defaults to L3 (matches resolve_generate_system_prompt fallback).
    assert max_output_tokens_for_survey_level(None) == l3


def test_max_context_tokens_for_survey_level_scales_with_tier() -> None:
    """Same shape: L3 needs more retrieved evidence than L1. Without this,
    only ~400 tokens of RAG snippets reach the LLM, and it falls back to
    pre-training defaults β€” that's where invented addresses come from.
    """
    from app.generator.prompts import max_context_tokens_for_survey_level

    l1 = max_context_tokens_for_survey_level(1)
    l2 = max_context_tokens_for_survey_level(2)
    l3 = max_context_tokens_for_survey_level(3)
    assert l1 <= l2 <= l3
    assert l3 >= 1500


def test_word_target_for_survey_level_matches_full_report_totals() -> None:
    """Word targets are per-section budgets aligned to user-provided full totals."""
    from app.generator.prompts import _word_target_for_survey_level

    min_l1, max_l1 = _word_target_for_survey_level(1)
    min_l2, max_l2 = _word_target_for_survey_level(2)
    min_l3, max_l3 = _word_target_for_survey_level(3)
    # L1 (5 sections total) can be longer per section than L2 (~33 sections).
    assert min_l1 > min_l2
    assert max_l1 > max_l2
    # L3 should stay slightly above L2 per section for diagnostic depth.
    assert min_l3 >= min_l2
    assert max_l3 >= max_l2
    # Sanity windows
    assert 250 <= min_l1 <= 320 and 480 <= max_l1 <= 560
    assert 90 <= min_l2 <= 130 and 160 <= max_l2 <= 220
    assert 120 <= min_l3 <= 170 and 210 <= max_l3 <= 280


# ── Post-processor tests ──────────────────────────────────────────────────────

def test_enforce_verify_passes_through_verified_number() -> None:
    """A number present in the bullets should NOT be replaced."""
    result = enforce_verify(
        text="The property has 95 sqm floor area.",
        bullets=["Floor area is 95 sqm"],
        snippets=[],
    )
    assert "[VERIFY" not in result
    assert "Information not provided in source document." not in result
    assert "We were unable to verify this during inspection." not in result


def test_enforce_verify_wraps_invented_number() -> None:
    """An invented number should be removed from the output."""
    result = enforce_verify(
        text="The property has 120 sqm floor area.",
        bullets=["Floor area is 95 sqm"],
        snippets=[],
    )
    assert "120" not in result
    assert result == "The property has  floor area."


def test_enforce_verify_wraps_invented_entity() -> None:
    """An invented named entity should be removed from the output."""
    result = enforce_verify(
        text="The property is located near Hampstead Heath.",
        bullets=["Property in NW3"],
        snippets=[],
    )
    assert "Hampstead Heath" not in result
    assert result == "The property is located near ."


def test_enforce_verify_passes_through_entity_in_snippets() -> None:
    """A named entity present in a retrieved snippet should NOT be replaced."""
    result = enforce_verify(
        text="The property is located near Hampstead Heath.",
        bullets=["Property in NW3"],
        snippets=["This area is adjacent to Hampstead Heath park."],
    )
    assert "Hampstead Heath" in result
    assert "Information not provided in source document." not in result
    assert "We were unable to verify this during inspection." not in result


def test_strip_verify_tags_extracts_values() -> None:
    """strip_verify_tags is a legacy stub β€” always returns empty list."""
    text = "Area [VERIFY: 120 sqm] near [VERIFY: Canary Wharf]."
    flagged = strip_verify_tags(text)
    assert flagged == []


def test_strip_verify_tags_empty_when_no_tags() -> None:
    """strip_verify_tags on clean text should return an empty list."""
    assert strip_verify_tags("Clean output with no issues.") == []


# ── MockLLMAdapter tests ──────────────────────────────────────────────────────

def test_mock_adapter_uses_override() -> None:
    """MockLLMAdapter with response_override should return exactly that string."""
    adapter = MockLLMAdapter(response_override="FIXTURE output text.")
    result = adapter.generate_section("skeleton", ["bullet"], ["snippet"])
    assert result == "FIXTURE output text."


def test_mock_adapter_default_uses_bullets() -> None:
    """MockLLMAdapter without override should include bullet content in output."""
    adapter = MockLLMAdapter()
    result = adapter.generate_section("skeleton", ["semi-detached house"], [])
    assert "semi-detached house" in result


def test_mock_adapter_includes_style_note_when_profile_given() -> None:
    """generate_section with a style_profile should embed the tone in the output."""
    from app.models.schemas import WritingStyleProfile

    profile = WritingStyleProfile(tone="technical")
    adapter = MockLLMAdapter()
    result = adapter.generate_section("skeleton", ["bullet"], [], style_profile=profile)
    assert "technical" in result


# ── build_user_prompt β€” style-aware vs plain ──────────────────────────────────

def test_build_user_prompt_plain_when_no_style_profile() -> None:
    """Without a style_profile the plain template must be used (no WRITING STYLE PROFILE header)."""
    prompt = build_user_prompt(
        skeleton="[Location]: [description].",
        bullets=["Semi-detached, NW3"],
        snippets=[],
        max_context_tokens=400,
    )
    assert "WRITING STYLE PROFILE" not in prompt
    assert "[Location]" in prompt


def test_build_user_prompt_includes_style_anchor() -> None:
    """Optional draft paragraph should appear in the user prompt."""
    prompt = build_user_prompt(
        skeleton="[E4]: [content].",
        bullets=["Brick cavity walls"],
        snippets=["Localised cracking to the flank elevation."],
        max_context_tokens=400,
        style_anchor="The external walls are of cavity brick construction with some localised cracking.",
    )
    assert "SURVEYOR'S DRAFT PARAGRAPH" in prompt
    assert "cavity brick" in prompt


def test_build_user_prompt_style_aware_when_profile_given() -> None:
    """With a style_profile the style-aware template must inject profile fields."""
    from app.models.schemas import WritingStyleProfile

    profile = WritingStyleProfile(
        tone="semi-formal",
        formality_level="academic",
        writing_style_summary="Concise academic prose.",
    )
    prompt = build_user_prompt(
        skeleton="[Location]: [description].",
        bullets=["Semi-detached, NW3"],
        snippets=[],
        max_context_tokens=400,
        style_profile=profile,
    )
    assert "polished RICS report section text of" in prompt  # word target injected
    assert "WRITING STYLE PROFILE" in prompt
    assert "semi-formal" in prompt
    assert "academic" in prompt
    assert "Concise academic prose." in prompt


# ── build_proofread_prompt ────────────────────────────────────────────────────

def test_build_proofread_prompt_contains_text_and_bullets() -> None:
    """The proofread prompt must contain the text to review and the bullets."""
    from app.generator.prompts import build_proofread_prompt

    prompt = build_proofread_prompt(
        text="The property is a semi-detached house.",
        bullets=["Semi-detached, NW3"],
    )
    assert "semi-detached house" in prompt
    assert "Semi-detached, NW3" in prompt


def test_build_proofread_prompt_uses_style_profile() -> None:
    """When a style_profile is supplied its tone must appear in the proofread prompt."""
    from app.generator.prompts import build_proofread_prompt
    from app.models.schemas import WritingStyleProfile

    profile = WritingStyleProfile(tone="casual", writing_style_summary="Conversational style.")
    prompt = build_proofread_prompt(
        text="Some text.",
        bullets=["fact one"],
        style_profile=profile,
    )
    assert "casual" in prompt
    assert "Conversational style." in prompt


def test_build_proofread_prompt_falls_back_to_mock_profile() -> None:
    """Without a style_profile the mock profile defaults should still appear."""
    from app.generator.prompts import build_proofread_prompt

    prompt = build_proofread_prompt(text="Some text.", bullets=[])
    assert "formal" in prompt  # mock profile default tone


# ── build_enhance_prompt ──────────────────────────────────────────────────────

def test_build_enhance_prompt_contains_current_text() -> None:
    """The enhance prompt must contain the existing section text."""
    from app.generator.prompts import build_enhance_prompt

    prompt = build_enhance_prompt(
        text="The roof is in poor condition.",
        bullets=["Roof: felt tiles, poor condition"],
        snippets=["Felt tiles typically last 15–20 years."],
        max_context_tokens=400,
    )
    assert "roof is in poor condition" in prompt
    assert "felt tiles" in prompt.lower()


def test_build_enhance_prompt_includes_evidence_snippets() -> None:
    """Retrieved evidence snippets must appear in the enhance prompt."""
    from app.generator.prompts import build_enhance_prompt

    prompt = build_enhance_prompt(
        text="Short text.",
        bullets=["bullet"],
        snippets=["UNIQUE_SNIPPET_MARKER structural survey evidence"],
        max_context_tokens=400,
    )
    assert "UNIQUE_SNIPPET_MARKER" in prompt


def test_build_enhance_prompt_uses_style_profile() -> None:
    """A supplied style_profile must inject its fields into the enhance prompt."""
    from app.generator.prompts import build_enhance_prompt
    from app.models.schemas import WritingStyleProfile

    profile = WritingStyleProfile(tone="technical", writing_style_summary="Expert surveyor voice.")
    prompt = build_enhance_prompt(
        text="Existing text.",
        bullets=["fact"],
        snippets=[],
        max_context_tokens=400,
        style_profile=profile,
    )
    assert "technical" in prompt
    assert "Expert surveyor voice." in prompt


# ── MockLLMAdapter β€” proofread and enhance ────────────────────────────────────

def test_mock_proofread_contains_notes_separator() -> None:
    """MockLLMAdapter.proofread must include the ---NOTES--- separator."""
    adapter = MockLLMAdapter()
    result = adapter.proofread("Original text.", bullets=["fact"])
    assert "---NOTES---" in result
    assert "Original text." in result


def test_mock_enhance_appends_no_key_notice() -> None:
    """MockLLMAdapter.enhance must append a notice about the missing API key."""
    adapter = MockLLMAdapter()
    result = adapter.enhance("Existing text.", bullets=["fact"], snippets=["snippet"])
    assert "Existing text." in result
    assert "OPENAI_API_KEY" in result


def test_mock_enhance_notes_snippet_count() -> None:
    """MockLLMAdapter.enhance should mention the number of retrieved snippets."""
    adapter = MockLLMAdapter()
    result = adapter.enhance("text", bullets=[], snippets=["s1", "s2", "s3"])
    assert "3" in result


# ── GenerateRequest schema validators ─────────────────────────────────────────

def test_generate_request_rejects_invalid_template_id() -> None:
    """template_id must be a valid RICS section code (union of all product tiers)."""
    from pydantic import ValidationError

    from app.models.schemas import GenerateRequest

    with pytest.raises(ValidationError, match="not a valid RICS section code"):
        GenerateRequest(template_id="B1", bullets=["a fact"])


def test_generate_request_accepts_valid_template_id() -> None:
    """A correct RICS section code (e.g. E4) must pass validation."""
    from app.models.schemas import GenerateRequest

    req = GenerateRequest(template_id="E4", bullets=["Solid brick walls, DPC visible"])
    assert req.template_id == "E4"


def test_generate_request_rejects_blank_template_id() -> None:
    """An empty or whitespace-only template_id must be rejected."""
    from pydantic import ValidationError

    from app.models.schemas import GenerateRequest

    with pytest.raises(ValidationError):
        GenerateRequest(template_id="  ", bullets=["a fact"])


def test_generate_request_allows_empty_bullets() -> None:
    """Empty bullets are allowed (backend may persist a blank section)."""
    from app.models.schemas import GenerateRequest

    req = GenerateRequest(template_id="D", bullets=["   ", ""])
    assert req.bullets == []