"""LLM adapter interface and concrete implementations. * **generate_section** — LangChain LCEL: ``RICS_PROMPT | ChatOpenAI | StrOutputParser`` with the same rich prompts as before (style profile, creativity hint, temperature). * **proofread** / **enhance** — OpenAI Chat Completions API (unchanged behaviour). """ import logging from abc import ABC, abstractmethod from typing import TYPE_CHECKING from app.config import settings from app.generator.prompts import ( ENHANCE_SYSTEM_PROMPT, PROOFREAD_SYSTEM_PROMPT, RICS_PROMPT, VALIDATE_SYSTEM_PROMPT, build_enhance_prompt, build_lcel_invoke_vars, build_proofread_prompt, build_validate_prompt, max_context_tokens_for_survey_level, max_output_tokens_for_survey_level, top_p_for_ai_involvement, ) if TYPE_CHECKING: from app.models.schemas import WritingStyleProfile logger = logging.getLogger(__name__) class LLMAdapter(ABC): """Abstract interface for all three LLM generation modes. Example:: adapter = get_llm_adapter() text = adapter.generate_section( skeleton="[Location]: [description].", bullets=["Semi-detached, NW3", "95 sqm"], snippets=["The property is located near…"], ) """ @abstractmethod def generate_section( self, skeleton: str, bullets: list[str], snippets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.2, creativity_hint: str = "", document_context: list[str] | None = None, style_anchor: str | None = None, hierarchy_section_snippets: list[str] | None = None, paragraph_snippets: list[str] | None = None, identity_facts: str | None = None, survey_level: int | None = None, reference_only_context: bool = False, ai_percent: int | None = None, interference_level: str | None = None, scope_fence: str | None = None, ) -> str: """Adapt ``skeleton`` using ``bullets`` as facts and ``snippets`` as examples. Args: skeleton: RICS section template with placeholder markers. bullets: Ordered list of user-supplied fact bullets. snippets: Fine-grained evidence when ``paragraph_snippets`` is omitted (backward compatible). style_profile: Optional detected writing style; applied when present. temperature: LLM sampling temperature (controlled by ai_level). creativity_hint: Short instruction appended to the user prompt. document_context: Optional broader excerpts (whole-PDF / report narrative). style_anchor: Optional surveyor draft paragraph for tone and structure. hierarchy_section_snippets: Mid-tier passages (e.g. page / section scope). paragraph_snippets: Paragraph-tier evidence; defaults to ``snippets`` when unset. identity_facts: Optional pinned identity block (address / property type / occupancy) that must remain consistent. interference_level: Optional qualitative mode (minimum | medium | maximum) for prompts and budgets. Returns: Plain-text adapted section (no placeholders; missing facts are stated explicitly). """ ... @abstractmethod def proofread( self, text: str, bullets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.15, creativity_hint: str = "", ) -> str: """Review ``text`` for grammar, clarity, and style consistency. Args: text: Previously generated section text to proofread. bullets: Original fact bullets (factual reference). style_profile: Detected writing style profile. temperature: LLM sampling temperature (controlled by ai_level). creativity_hint: Short instruction appended to the user prompt. Returns: Corrected text followed by ``---NOTES---`` and brief editor notes. """ ... @abstractmethod def enhance( self, text: str, bullets: list[str], snippets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.2, creativity_hint: str = "", ) -> str: """Expand ``text`` with additional technical depth from ``snippets``. Args: text: Existing section text to enrich. bullets: Original fact bullets (primary trusted source). snippets: Additional retrieved chunks for technical enrichment. style_profile: Detected writing style profile. temperature: LLM sampling temperature (controlled by ai_level). creativity_hint: Short instruction appended to the user prompt. Returns: Enhanced plain-text section (no placeholders; missing facts are stated explicitly). """ ... @abstractmethod def validate_section_compliance( self, *, survey_level: int | None, section_code: str, bullets: list[str], evidence_snippets: list[str], text: str, ) -> str: """Return "PASS" or "FAIL: ..." for survey-level compliance.""" ... @abstractmethod def constrained_weave( self, *, section_code: str, section_title: str | None, bullets: list[str], standard_passages: list[str], survey_level: int | None = None, ) -> str: """Structural-router LLM call for ``ai_percent == 0``. Takes the firm's RAG-retrieved STANDARD PASSAGES and the inspector's RAW NOTES (``bullets``) and produces the standard wording with the notes' specifics woven into the appropriate slots — no creative writing, no new sentences. Returns plain text or "" on failure. """ ... class OpenAIAdapter(LLMAdapter): """Generate mode via LangChain LCEL; proofread/enhance via OpenAI Chat Completions.""" def __init__(self) -> None: from langchain_openai import ChatOpenAI from openai import OpenAI self._client = OpenAI(api_key=settings.openai_api_key) self._model = settings.chat_model self._lc_llm = ChatOpenAI( model=self._model, temperature=0.2, max_tokens=settings.max_output_tokens, api_key=settings.openai_api_key, max_retries=3, ) def _call( self, system: str, user: str, max_tokens: int | None = None, temperature: float = 0.2, *, phase: str = "chat", survey_level: int | None = None, interference_level: str | None = None, tenant_id: str | None = None, section_id: str | None = None, ) -> str: """Make a single Chat Completions call and return the text.""" from app.llm.llm_throttle import throttled_sync_llm_call from app.llm.prompt_cache import ( build_chat_messages, log_openai_cache_usage, openai_extra_kwargs, prompt_caching_active, ) messages = build_chat_messages(system=system, user=user) extra = openai_extra_kwargs( phase=phase, model=self._model, survey_level=survey_level, interference_level=interference_level, tenant_id=tenant_id, ) def _invoke(): response = self._client.chat.completions.create( model=self._model, messages=messages, max_tokens=max_tokens or settings.max_output_tokens, temperature=temperature, **extra, ) if prompt_caching_active(): log_openai_cache_usage(response, phase=phase, section_id=section_id) return (response.choices[0].message.content or "").strip() return throttled_sync_llm_call(phase=phase, section_id=section_id, call=_invoke) def generate_section( self, skeleton: str, bullets: list[str], snippets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.2, creativity_hint: str = "", document_context: list[str] | None = None, style_anchor: str | None = None, hierarchy_section_snippets: list[str] | None = None, paragraph_snippets: list[str] | None = None, identity_facts: str | None = None, survey_level: int | None = None, reference_only_context: bool = False, ai_percent: int | None = None, interference_level: str | None = None, tenant_id: str | None = None, scope_fence: str | None = None, ) -> str: from langchain_core.output_parsers import StrOutputParser fine = paragraph_snippets if paragraph_snippets is not None else snippets # Tier-aware budgets. Previously the LCEL chain was bound to # `settings.max_output_tokens` at construct time (default 300), so # *every* section — Level 1 condition note, Level 2 buyer summary, # Level 3 diagnostic narrative — was capped at the same ~225-word # ceiling. The prompt's word target for L3 is now 300–700 words; the # adapter has to be allowed to actually emit that. We rebind both # context (input-side) and max_tokens (output-side) per call so a # single shared adapter instance can serve all tiers without sharing # an L1-sized output budget. out_tokens = max_output_tokens_for_survey_level( survey_level, interference_level=interference_level ) ctx_tokens = max_context_tokens_for_survey_level( survey_level, interference_level=interference_level ) vars_ = build_lcel_invoke_vars( skeleton=skeleton, bullets=bullets, snippets=None, max_context_tokens=ctx_tokens, style_profile=style_profile, creativity_hint=creativity_hint, document_snippets=document_context, section_snippets=None, hierarchy_section_snippets=hierarchy_section_snippets, paragraph_snippets=fine, style_anchor=style_anchor, identity_facts=identity_facts, survey_level=survey_level, reference_only_context=reference_only_context, ai_percent=ai_percent, interference_level=interference_level, scope_fence=scope_fence, ) top_p = top_p_for_ai_involvement(ai_percent) from app.llm.prompt_cache import ( build_chat_messages, log_openai_cache_usage, openai_extra_kwargs, prompt_caching_active, ) if prompt_caching_active(): from app.llm.llm_throttle import throttled_sync_llm_call extra = openai_extra_kwargs( phase="generate_section", model=self._model, survey_level=survey_level, interference_level=interference_level, tenant_id=tenant_id, ) def _invoke() -> str: response = self._client.chat.completions.create( model=self._model, messages=build_chat_messages( system=str(vars_.get("system_content") or ""), user=str(vars_.get("user_content") or ""), ), max_tokens=out_tokens, temperature=temperature, top_p=top_p, **extra, ) log_openai_cache_usage(response, phase="generate_section", section_id=None) return (response.choices[0].message.content or "").strip() text = throttled_sync_llm_call( phase="generate_section", section_id=None, call=_invoke ) else: from langchain_core.output_parsers import StrOutputParser chain = ( RICS_PROMPT | self._lc_llm.bind(temperature=temperature, max_tokens=out_tokens, top_p=top_p) | StrOutputParser() ) # Sync adapter runs inside run_sync_in_executor from generation_facade. text = (chain.invoke(vars_) or "").strip() logger.debug( "generate_section (LCEL): %d chars (model=%s, temp=%.3f, top_p=%.2f, max_out=%d, ctx=%d, lvl=%s)", len(text), self._model, temperature, top_p, out_tokens, ctx_tokens, survey_level, ) return text def proofread( self, text: str, bullets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.15, creativity_hint: str = "", ) -> str: user_prompt = build_proofread_prompt( text=text, bullets=bullets, style_profile=style_profile, creativity_hint=creativity_hint, ) # Proofread output should fit the input it's correcting. A 600-token # cap here truncated proofread output of long L3 sections. We size # the cap to the input length plus a small margin so proofread can # never *shrink* the user's text just because of a fixed budget. from app.chunking.splitter import count_tokens out_cap = max(700, min(2400, count_tokens(text or "") + 200)) result = self._call(PROOFREAD_SYSTEM_PROMPT, user_prompt, max_tokens=out_cap, temperature=temperature) logger.debug("proofread: %d chars output (cap=%d)", len(result), out_cap) return result def enhance( self, text: str, bullets: list[str], snippets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.2, creativity_hint: str = "", ) -> str: user_prompt = build_enhance_prompt( text=text, bullets=bullets, snippets=snippets, max_context_tokens=settings.max_context_tokens, style_profile=style_profile, creativity_hint=creativity_hint, ) # Enhance is supposed to *add* technical depth, so it must be allowed # to grow well past the input. 500 tokens (~375 words) caps enhance # at less than a paragraph of new content for L3 sections — defeats # the purpose. Match generate-mode's tier ceiling instead. from app.chunking.splitter import count_tokens out_cap = max(900, min(2400, count_tokens(text or "") + 800)) result = self._call(ENHANCE_SYSTEM_PROMPT, user_prompt, max_tokens=out_cap, temperature=temperature) logger.debug("enhance: %d chars output (cap=%d)", len(result), out_cap) return result def validate_section_compliance( self, *, survey_level: int | None, section_code: str, bullets: list[str], evidence_snippets: list[str], text: str, ) -> str: user_prompt = build_validate_prompt( survey_level=survey_level, section_code=section_code, bullets=bullets, evidence_snippets=evidence_snippets, text=text, ) result = self._call(VALIDATE_SYSTEM_PROMPT, user_prompt, max_tokens=220, temperature=0.0) return (result or "").strip() def constrained_weave( self, *, section_code: str, section_title: str | None, bullets: list[str], standard_passages: list[str], survey_level: int | None = None, tenant_id: str | None = None, ) -> str: from app.generator.prompts import _ASSEMBLY_SYSTEM_CORE # tier-aware from app.llm.llm_throttle import throttled_sync_llm_call from app.llm.prompt_cache import ( build_chat_messages, log_openai_cache_usage, openai_extra_kwargs, prompt_caching_active, ) cleaned_passages = [str(p).strip() for p in (standard_passages or []) if str(p).strip()] cleaned_bullets = [str(b).strip() for b in (bullets or []) if str(b).strip()] if not cleaned_passages or not cleaned_bullets: return "" title_part = f" — {section_title}" if section_title else "" user = ( f"SECTION: {section_code}{title_part}\n\n" "STANDARD SOURCE PASSAGES (preserve wording; weave NOTES facts into the slots):\n" + "\n".join(f"- {p}" for p in cleaned_passages) + "\n\nINSPECTOR'S RAW NOTES (substitute these specifics into the standards):\n" + "\n".join(f"- {b}" for b in cleaned_bullets) + "\n\nProduce the structurally-routed output now. Standard wording stays, " "note facts replace generic slots, no new sentences, no new claims." ) try: phase = "constrained_weave" extra = openai_extra_kwargs( phase=phase, model=self._model, survey_level=survey_level, tenant_id=tenant_id, ) msgs = build_chat_messages(system=_ASSEMBLY_SYSTEM_CORE, user=user) def _invoke() -> str: response = self._client.chat.completions.create( model=self._model, messages=msgs, max_tokens=600, temperature=0.0, top_p=0.1, **extra, ) if prompt_caching_active(): log_openai_cache_usage(response, phase=phase, section_id=section_code) return (response.choices[0].message.content or "").strip() return throttled_sync_llm_call( phase=phase, section_id=section_code, call=_invoke ) except Exception as exc: # noqa: BLE001 logger.warning("constrained_weave failed: %s", exc) return "" class MockLLMAdapter(LLMAdapter): """Deterministic mock adapter for tests and no-key environments. Accepts an optional ``response_override`` for injection in specific tests. Example:: adapter = MockLLMAdapter(response_override="The property is a house.") text = adapter.generate_section(skeleton="", bullets=[], snippets=[]) assert text == "The property is a house." """ def __init__(self, response_override: str | None = None) -> None: self._override = response_override def generate_section( self, skeleton: str, bullets: list[str], snippets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.2, creativity_hint: str = "", document_context: list[str] | None = None, style_anchor: str | None = None, hierarchy_section_snippets: list[str] | None = None, paragraph_snippets: list[str] | None = None, identity_facts: str | None = None, survey_level: int | None = None, reference_only_context: bool = False, ai_percent: int | None = None, interference_level: str | None = None, tenant_id: str | None = None, scope_fence: str | None = None, ) -> str: if self._override is not None: return self._override style_note = f" (style: {style_profile.tone})" if style_profile else "" summary = "; ".join(bullets[:3]) if bullets else "No facts provided" return f"Based on the available information{style_note}: {summary}." def proofread( self, text: str, bullets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.15, creativity_hint: str = "", ) -> str: return ( f"{text}\n---NOTES---\n" "No OpenAI key configured — proofreading not available in mock mode. " "Add OPENAI_API_KEY to your .env file to enable real proofreading." ) def enhance( self, text: str, bullets: list[str], snippets: list[str], style_profile: "WritingStyleProfile | None" = None, temperature: float = 0.2, creativity_hint: str = "", ) -> str: extra = f" Additional context from {len(snippets)} retrieved source(s) noted." if snippets else "" return ( f"{text}{extra} " "[No OpenAI key configured — full technical enhancement requires OPENAI_API_KEY.]" ) def validate_section_compliance( self, *, survey_level: int | None, section_code: str, bullets: list[str], evidence_snippets: list[str], text: str, ) -> str: return "PASS" def constrained_weave( self, *, section_code: str, section_title: str | None, bullets: list[str], standard_passages: list[str], survey_level: int | None = None, tenant_id: str | None = None, ) -> str: return "" # mock returns empty; caller falls back to deterministic stitch _llm_adapter_instance: LLMAdapter | None = None def get_llm_adapter() -> LLMAdapter: """Return a singleton :class:`LLMAdapter`. The adapter is created once on first call and reused thereafter to avoid allocating a new ``openai.OpenAI`` connection pool on every generation call. Uses the real OpenAI adapter when ``settings.openai_api_key`` is set; falls back to the mock adapter otherwise. Returns: Configured :class:`LLMAdapter`. """ global _llm_adapter_instance if _llm_adapter_instance is not None: return _llm_adapter_instance if settings.openai_api_key: _llm_adapter_instance = OpenAIAdapter() else: _llm_adapter_instance = MockLLMAdapter() return _llm_adapter_instance