File size: 5,620 Bytes
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Per-tenant writing style profile from the reference corpus."""

from __future__ import annotations

import json
import logging
import threading
from dataclasses import asdict, dataclass, field
from pathlib import Path

from backend.config import settings
from backend.core.rag_store import TIER_REFERENCE, get_rag_store
from backend.llm import openai_client
from backend.utils import tenant_store

logger = logging.getLogger(__name__)

_lock = threading.RLock()


@dataclass
class StyleProfile:
    tone: str = "formal"
    formality_level: str = "professional"
    avg_sentence_complexity: str = "moderate"
    vocabulary_level: str = "technical"
    common_phrases: list[str] = field(default_factory=lambda: [
        "The property",
        "It is noted that",
        "In respect of",
        "At the time of inspection",
        "We would recommend",
    ])
    structural_patterns: list[str] = field(default_factory=lambda: [
        "Topic sentence followed by supporting detail and recommendation",
        "Passive voice preferred for observations; active for recommendations",
    ])
    writing_style_summary: str = (
        "Formal UK RICS survey prose with measured technical vocabulary and professional hedging."
    )
    example_paragraphs: list[str] = field(default_factory=list)

    def to_payload(self) -> dict:
        return asdict(self)


DEFAULT_PROFILE = StyleProfile()


def _cache_path(tenant_id: str) -> Path:
    return tenant_store.tenant_root(tenant_id) / "style_profile.json"


def _sample_reference_text(tenant_id: str, max_chars: int = 4800) -> str:
    store = get_rag_store()
    parts: list[str] = []
    used = 0
    for text in store.sample_chunk_texts(tenant_id, TIER_REFERENCE, limit=60):
        if used + len(text) + 2 > max_chars:
            break
        parts.append(text)
        used += len(text) + 2
    return "\n\n".join(parts)


def _heuristic_profile(sample: str) -> StyleProfile:
    if not sample.strip():
        return DEFAULT_PROFILE
    lower = sample.lower()
    tone = "formal"
    if "recommend" in lower or "advise" in lower:
        tone = "semi-formal"
    vocab = "technical"
    if sample.count("£") + sample.count("mm") + sample.count("dpc") >= 3:
        vocab = "specialist"
    sentences = [s.strip() for s in sample.replace("\n", " ").split(".") if len(s.strip()) > 20]
    avg_len = sum(len(s.split()) for s in sentences) / max(len(sentences), 1)
    complexity = "moderate"
    if avg_len > 22:
        complexity = "complex"
    elif avg_len < 14:
        complexity = "simple"
    examples = []
    for block in sample.split("\n\n"):
        words = block.split()
        if 40 <= len(words) <= 120:
            examples.append(block.strip())
        if len(examples) >= 2:
            break
    return StyleProfile(
        tone=tone,
        avg_sentence_complexity=complexity,
        vocabulary_level=vocab,
        writing_style_summary=(
            "Derived from uploaded past reports: measured UK surveyor voice with "
            f"{complexity} sentences and {vocab} vocabulary."
        ),
        example_paragraphs=examples,
    )


def _llm_profile(sample: str) -> StyleProfile | None:
    if not openai_client.is_available() or len(sample) < 200:
        return None
    prompt = f"""Analyse this UK RICS survey sample and return JSON with keys:
tone, formality_level, avg_sentence_complexity, vocabulary_level,
common_phrases (array), structural_patterns (array), writing_style_summary,
example_paragraphs (array of verbatim short extracts).

SAMPLE:
{sample[:4000]}"""
    try:
        raw = openai_client.chat_json(
            [
                {
                    "role": "system",
                    "content": "You analyse UK RICS report writing style. Output JSON only.",
                },
                {"role": "user", "content": prompt},
            ],
            model=settings.mapping_model,
            max_tokens=800,
        )
        return StyleProfile(
            tone=str(raw.get("tone") or "formal"),
            formality_level=str(raw.get("formality_level") or "professional"),
            avg_sentence_complexity=str(raw.get("avg_sentence_complexity") or "moderate"),
            vocabulary_level=str(raw.get("vocabulary_level") or "technical"),
            common_phrases=list(raw.get("common_phrases") or DEFAULT_PROFILE.common_phrases),
            structural_patterns=list(raw.get("structural_patterns") or DEFAULT_PROFILE.structural_patterns),
            writing_style_summary=str(
                raw.get("writing_style_summary") or DEFAULT_PROFILE.writing_style_summary
            ),
            example_paragraphs=list(raw.get("example_paragraphs") or []),
        )
    except Exception as exc:  # noqa: BLE001
        logger.warning("LLM style profile failed (%s) — using heuristics", exc)
        return None


def get_style_profile(tenant_id: str, *, force_refresh: bool = False) -> StyleProfile:
    with _lock:
        path = _cache_path(tenant_id)
        if not force_refresh and path.is_file():
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
                return StyleProfile(**data)
            except Exception:  # noqa: BLE001
                pass

        sample = _sample_reference_text(tenant_id)
        profile = _llm_profile(sample) or _heuristic_profile(sample)
        path.write_text(json.dumps(profile.to_payload(), indent=2), encoding="utf-8")
        return profile


def invalidate_style_profile(tenant_id: str) -> None:
    path = _cache_path(tenant_id)
    if path.is_file():
        path.unlink(missing_ok=True)