RandomZ / app /generator /style_analyzer.py
StormShadow308's picture
feat: async pipeline, job queue, generation hardening, and docs
732b14f
Raw
History Blame Contribute Delete
14.6 kB
"""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