Spaces:
Runtime error
Runtime error
| """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": "<formal | semi-formal | technical | casual>", | |
| "formality_level": "<professional | academic | conversational>", | |
| "avg_sentence_complexity": "<simple | moderate | complex>", | |
| "vocabulary_level": "<basic | intermediate | technical | specialist>", | |
| "common_phrases": ["<phrase 1>", "<phrase 2>", "<phrase 3>"], | |
| "structural_patterns": ["<pattern 1>", "<pattern 2>"], | |
| "writing_style_summary": "<One sentence description of the overall style>", | |
| "example_paragraphs": [ | |
| "<Copy verbatim a SHORT paragraph (40β80 words) from the sample that best shows how this surveyor describes a building condition or defect. Choose factual prose, not headings or fragments. If no suitable paragraph exists in the sample, return an empty list.>", | |
| "<Optionally a second verbatim paragraph (40β80 words) showing how this surveyor phrases a recommendation or observation. Verbatim or empty list β do not paraphrase.>" | |
| ] | |
| }} | |
| 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": "<formal | semi-formal | technical | casual>", | |
| "formality_level": "<professional | academic | conversational>", | |
| "avg_sentence_complexity": "<simple | moderate | complex>", | |
| "vocabulary_level": "<basic | intermediate | technical | specialist>", | |
| "common_phrases": [ | |
| "<characteristic phrase 1>", | |
| "<phrase 2>", | |
| "<phrase 3>", | |
| "<phrase 4>", | |
| "<phrase 5>" | |
| ], | |
| "structural_patterns": [ | |
| "<writing pattern 1 β e.g. how sentences begin>", | |
| "<pattern 2 β e.g. how defects are described>", | |
| "<pattern 3 β e.g. how recommendations are worded>" | |
| ], | |
| "writing_style_summary": "<Two sentences describing this surveyor's exact voice and approach>", | |
| "example_paragraphs": [ | |
| "<Copy verbatim a SHORT paragraph (40β80 words) from the sample that best shows how this surveyor describes a building condition or defect>", | |
| "<Copy verbatim a SECOND paragraph from a different section β ideally a recommendation or next-step statement>", | |
| "<Copy verbatim a THIRD paragraph showing how they introduce or conclude a section>" | |
| ] | |
| }} | |
| 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 | |