RICS / backend /core /style_profile.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
5.62 kB
"""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)