"""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)