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