Spaces:
Sleeping
Sleeping
File size: 14,623 Bytes
377c1bc 0136798 377c1bc 0136798 377c1bc 0136798 377c1bc da8a68d 0136798 da8a68d 0136798 377c1bc 0136798 377c1bc 0136798 732b14f 377c1bc 0136798 377c1bc 0136798 377c1bc 0136798 da8a68d 377c1bc 0136798 da8a68d 377c1bc da8a68d 377c1bc da8a68d 377c1bc da8a68d 0136798 377c1bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | """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
|