Spaces:
Runtime error
Runtime error
| """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() | |
| 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) | |