"""Writing style analyser — extracts a WritingStyleProfile from surveyor documents. Two analysis modes: * ``analyze_writing_style`` — lightweight (1 200 tokens), used for per-tenant profiles built from the user's own uploaded documents. * ``build_reference_style_profile`` — thorough (4 000 tokens), used once at startup to build the Behrang reference profile from the KB corpus. Extracts actual example paragraphs for few-shot style injection in generation prompts. """ import json import logging from app.models.schemas import WritingStyleProfile logger = logging.getLogger(__name__) MAX_SAMPLE_TOKENS = 1200 MAX_REFERENCE_SAMPLE_TOKENS = 4000 # --------------------------------------------------------------------------- # Lightweight per-tenant style analyser # --------------------------------------------------------------------------- _STYLE_SYSTEM_PROMPT = """\ You are an expert writing-style analyst specialising in professional UK property \ survey and technical reports written in British English. Analyse the sample text \ provided and return a single JSON object describing the author's writing style. \ All values in the JSON must themselves use British English spellings \ (e.g. "colour" not "color", "analyse" not "analyze", "organise" not "organize"). \ Output ONLY the JSON object, no markdown fences, no commentary.\ """ _STYLE_USER_TEMPLATE = """\ Analyse the writing style of the following sample text and return a JSON \ object with exactly these keys: {{ "tone": "", "formality_level": "", "avg_sentence_complexity": "", "vocabulary_level": "", "common_phrases": ["", "", ""], "structural_patterns": ["", ""], "writing_style_summary": "", "example_paragraphs": [ "", "" ] }} IMPORTANT for example_paragraphs: copy text VERBATIM from the sample — do not paraphrase, \ do not invent, do not blend multiple sentences from different places. Each entry must be a \ contiguous extract that exists word-for-word in the sample. If the sample is too short or \ fragmentary to extract a clean paragraph, return an empty list. SAMPLE TEXT: {sample_text} """ # --------------------------------------------------------------------------- # Thorough reference style analyser (used for KB / Behrang's reports) # --------------------------------------------------------------------------- _REFERENCE_STYLE_SYSTEM_PROMPT = """\ You are an expert writing-style analyst specialising in professional UK RICS \ Building Survey reports. You have been given extracts from a specific surveyor's \ completed reports. Your task is to capture their EXACT writing voice so that an \ AI can reproduce it faithfully when generating new survey sections. Focus on: - Characteristic sentence openings and transitions ("It is noted that…", "At the time of inspection…") - How defects, condition ratings, and recommendations are phrased - Preferred technical vocabulary and professional hedging phrases - Paragraph rhythm, typical sentence length, and use of passive vs active voice - How the surveyor opens and closes a section All values must use British English spellings throughout. Output ONLY the JSON object — no markdown, no commentary.\ """ _REFERENCE_STYLE_USER_TEMPLATE = """\ Analyse the following extracts from a professional RICS Building Surveyor's \ completed reports. Capture their writing style precisely. Return a JSON object with exactly these keys: {{ "tone": "", "formality_level": "", "avg_sentence_complexity": "", "vocabulary_level": "", "common_phrases": [ "", "", "", "", "" ], "structural_patterns": [ "", "", "" ], "writing_style_summary": "", "example_paragraphs": [ "", "", "" ] }} SURVEYOR'S REPORT EXTRACTS: {sample_text} """ _MOCK_PROFILE = WritingStyleProfile( tone="formal", formality_level="professional", avg_sentence_complexity="moderate", vocabulary_level="technical", common_phrases=[ "The property", "It is noted that", "In respect of", "At the time of inspection", "We would recommend", ], structural_patterns=[ "Topic sentence followed by supporting detail and recommendation", "Passive voice preferred for observations; active for recommendations", "Condition stated first, cause second, implication third", ], writing_style_summary=( "Professional RICS surveyor style with measured, factual language and passive voice. " "Observations are precise and conservative; recommendations are proportionate." ), example_paragraphs=[], ) def _truncate_to_tokens(texts: list[str], max_tokens: int) -> str: """Concatenate texts up to ``max_tokens`` using exact tiktoken counts.""" import tiktoken enc = tiktoken.get_encoding("cl100k_base") parts: list[str] = [] total = 0 for text in texts: token_ids = enc.encode(text) count = len(token_ids) if total + count > max_tokens: remaining = max(0, max_tokens - total) if remaining > 0: parts.append(enc.decode(token_ids[:remaining])) break parts.append(text) total += count return "\n\n".join(parts) async def analyze_writing_style_async( sample_texts: list[str], openai_api_key: str = "", chat_model: str = "gpt-4o-mini", ) -> WritingStyleProfile: """Async style analysis using throttled OpenAI chat (async pipeline).""" if not sample_texts: return _MOCK_PROFILE if not (openai_api_key or "").strip(): return _MOCK_PROFILE sample = _truncate_to_tokens(sample_texts, MAX_SAMPLE_TOKENS) user_prompt = _STYLE_USER_TEMPLATE.format(sample_text=sample) try: from app.llm.openai_chat import chat_completions_create raw = await chat_completions_create( messages=[ {"role": "system", "content": _STYLE_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], model=chat_model, max_tokens=800, temperature=0.0, response_format={"type": "json_object"}, phase="style_analysis", ) data = json.loads(raw or "{}") data.setdefault("example_paragraphs", []) profile = WritingStyleProfile(**data) verified: list[str] = [] for p in profile.example_paragraphs or []: if isinstance(p, str) and p.strip() and p.strip()[:80] in sample: verified.append(p.strip()) if verified: profile = WritingStyleProfile( **{**profile.model_dump(), "example_paragraphs": verified} ) return profile except Exception as exc: # noqa: BLE001 logger.warning("Async style analysis failed (%s) — mock profile", exc) return _MOCK_PROFILE def _call_openai_style( system: str, user: str, api_key: str, model: str, max_tokens: int = 600, ) -> WritingStyleProfile: """Shared OpenAI call for both style analyser variants.""" from openai import OpenAI client = OpenAI(api_key=api_key) response = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": system}, {"role": "user", "content": user}, ], max_tokens=max_tokens, temperature=0.0, response_format={"type": "json_object"}, ) raw = (response.choices[0].message.content or "").strip() data = json.loads(raw) # example_paragraphs may not be returned by lightweight prompt — default to [] data.setdefault("example_paragraphs", []) profile = WritingStyleProfile(**data) return profile def analyze_writing_style( sample_texts: list[str], openai_api_key: str = "", chat_model: str = "gpt-4o-mini", ) -> WritingStyleProfile: """Lightweight style analysis from the user's own uploaded documents. Uses up to 1 200 tokens of sample text. Falls back to the mock profile when ``openai_api_key`` is empty or the call fails. Args: sample_texts: Ordered list of text chunks sampled from user documents. openai_api_key: OpenAI API key; empty string triggers mock fallback. chat_model: Chat model name used for analysis. Returns: :class:`~app.models.schemas.WritingStyleProfile` describing the style. """ if not sample_texts: logger.debug("No sample texts — returning mock style profile") return _MOCK_PROFILE if not openai_api_key: logger.debug("No OpenAI key — returning mock style profile") return _MOCK_PROFILE sample = _truncate_to_tokens(sample_texts, MAX_SAMPLE_TOKENS) user_prompt = _STYLE_USER_TEMPLATE.format(sample_text=sample) try: # Bumped 500 → 800 to fit two verbatim 40–80-word example paragraphs in # the JSON output. Without this, the model returned the tone/formality # fields fine but produced an empty example_paragraphs list, and the # downstream LCEL/inspector prompts had no surveyor-voice anchor to # mirror — which is why generated reports never sounded "like the # uploaded ones". profile = _call_openai_style( system=_STYLE_SYSTEM_PROMPT, user=user_prompt, api_key=openai_api_key, model=chat_model, max_tokens=800, ) # Defence-in-depth: drop any "example" the LLM hallucinated (i.e. a # paragraph that does NOT appear verbatim in the sample text). The # purpose of these examples is to mirror real surveyor voice; an # invented paragraph would teach the downstream LLM to copy a style # the surveyor never used. Strict substring containment is fine # because the prompt asks for verbatim copies; if the model paraphrases # we'd rather drop the example than poison style mirroring. verified: list[str] = [] for p in profile.example_paragraphs or []: if isinstance(p, str) and p.strip() and p.strip()[:80] in sample: verified.append(p.strip()) if len(verified) != len(profile.example_paragraphs or []): logger.debug( "Style example sanitisation: kept %d / %d (dropped non-verbatim entries)", len(verified), len(profile.example_paragraphs or []), ) # Pydantic models are immutable by default; rebuild via dict. profile = WritingStyleProfile(**{**profile.model_dump(), "example_paragraphs": verified}) logger.info( "Style analysis complete: tone=%s formality=%s examples=%d", profile.tone, profile.formality_level, len(profile.example_paragraphs), ) return profile except json.JSONDecodeError as exc: logger.warning("Style analysis returned non-JSON — using mock: %s", exc) return _MOCK_PROFILE except Exception as exc: logger.warning("Style analysis failed (%s) — using mock profile", exc) return _MOCK_PROFILE def build_reference_style_profile( sample_texts: list[str], openai_api_key: str = "", chat_model: str = "gpt-4o-mini", ) -> WritingStyleProfile: """Thorough style analysis from Behrang's reference reports (KB corpus). Uses up to 4 000 tokens of sample text and extracts actual example paragraphs for use as few-shot style guidance in generation prompts. Falls back to the mock profile when ``openai_api_key`` is empty. Args: sample_texts: Chunks retrieved from the KB tenant vector store. openai_api_key: OpenAI API key; empty string triggers mock fallback. chat_model: Chat model name used for analysis. Returns: Rich :class:`~app.models.schemas.WritingStyleProfile` with ``example_paragraphs`` populated. """ if not sample_texts: logger.debug("No KB sample texts — returning mock style profile") return _MOCK_PROFILE if not openai_api_key: logger.debug("No OpenAI key — returning mock style profile for reference") return _MOCK_PROFILE sample = _truncate_to_tokens(sample_texts, MAX_REFERENCE_SAMPLE_TOKENS) user_prompt = _REFERENCE_STYLE_USER_TEMPLATE.format(sample_text=sample) try: profile = _call_openai_style( system=_REFERENCE_STYLE_SYSTEM_PROMPT, user=user_prompt, api_key=openai_api_key, model=chat_model, max_tokens=900, ) logger.info( "Reference style analysis complete: tone=%s formality=%s examples=%d", profile.tone, profile.formality_level, len(profile.example_paragraphs), ) return profile except json.JSONDecodeError as exc: logger.warning("Reference style analysis returned non-JSON — using mock: %s", exc) return _MOCK_PROFILE except Exception as exc: logger.warning("Reference style analysis failed (%s) — using mock profile", exc) return _MOCK_PROFILE