Spaces:
Runtime error
Runtime error
| """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…"], | |
| ) | |
| """ | |
| 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). | |
| """ | |
| ... | |
| 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. | |
| """ | |
| ... | |
| 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). | |
| """ | |
| ... | |
| 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.""" | |
| ... | |
| 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 | |