318 lines
15 KiB
Python
318 lines
15 KiB
Python
"""
|
|
Copyright 2024-2026 ChatterMate
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import json
|
|
from typing import Callable, List, Optional
|
|
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from app.agents.structured_output import (
|
|
build_groq_json_tool,
|
|
lenient_json_load,
|
|
salvage_groq_json_error,
|
|
)
|
|
from app.agents.guardrail_policy import visitor_data_block
|
|
from app.core.config import settings
|
|
from app.core.logger import get_logger
|
|
from app.models.schemas.faq import MAX_ANSWER_LENGTH, MAX_QUESTION_LENGTH
|
|
from app.utils.agno_utils import create_model
|
|
from app.utils.model_context import faq_batch_chars_for, output_tokens_for
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Groq reasoning models spend output tokens before emitting the tool call;
|
|
# match the chat agent's headroom. Floor — large batches reserve more.
|
|
FAQ_MAX_TOKENS = 4000
|
|
|
|
|
|
class GeneratedFAQ(BaseModel):
|
|
question: str = Field(max_length=MAX_QUESTION_LENGTH)
|
|
answer: str = Field(max_length=MAX_ANSWER_LENGTH)
|
|
category: str = Field(max_length=100)
|
|
|
|
|
|
class FAQExtractionResult(BaseModel):
|
|
# No max_length here: an over-eager model must not fail response-model
|
|
# validation for the whole batch — _validate() trims to the batch cap.
|
|
faqs: List[GeneratedFAQ] = Field(default_factory=list)
|
|
|
|
|
|
_FAQ_JSON_TOOL_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"faqs": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"question": {"type": "string", "description": "The question, as a customer would ask it."},
|
|
"answer": {"type": "string", "description": "A clear, self-contained answer. Simple Markdown allowed (numbered steps, bullet lists, **bold**, links from the content); no headings or images."},
|
|
"category": {"type": "string", "description": "Short topic grouping, e.g. 'Getting started', 'Billing'."},
|
|
},
|
|
"required": ["question", "answer", "category"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
},
|
|
"required": ["faqs"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
_GROQ_FAQ_INSTRUCTION = (
|
|
"\n\nCRITICAL OUTPUT RULE: You MUST end your turn by calling the `json` tool exactly once "
|
|
"with the extracted FAQs. Never write the FAQs as plain text — always deliver them through "
|
|
"the `json` tool call."
|
|
)
|
|
|
|
_JSON_ONLY_INSTRUCTION = (
|
|
"\n\nOUTPUT FORMAT: Respond with ONLY a JSON object of the shape "
|
|
'{"faqs": [{"question": "...", "answer": "...", "category": "..."}]} '
|
|
"— no prose, no explanations, no code fences."
|
|
)
|
|
|
|
|
|
class _UnparseableOutput(Exception):
|
|
"""Native structured output came back in a shape we can't parse — signals
|
|
the plain-JSON fallback (distinct from a legitimately empty FAQ list)."""
|
|
|
|
|
|
def _first_json_object(text: str) -> dict:
|
|
"""The first {...} object in a text blob (tolerates code fences, prose and
|
|
truncation via the shared lenient parser — the providers that need this
|
|
fallback are exactly the ones likely to emit imperfect JSON). Raises
|
|
ValueError when nothing parseable exists — a batch failure upstream."""
|
|
start = text.find("{")
|
|
if start == -1:
|
|
raise ValueError("no JSON object in model output")
|
|
data = lenient_json_load(text[start:])
|
|
if not isinstance(data, dict):
|
|
raise ValueError("no JSON object in model output")
|
|
return data
|
|
|
|
_GENERATE_INSTRUCTIONS = """You turn product documentation into a public help-center FAQ.
|
|
|
|
Read the CONTENT below and draft frequently-asked questions a customer would realistically ask, each with a clear answer.
|
|
|
|
Rules:
|
|
- Ground every answer strictly in the CONTENT. Never invent facts, prices, limits or URLs that are not present.
|
|
- Write questions the way a customer would phrase them, and answers in second person ("you"), plain language.
|
|
- Answers may use simple Markdown when it helps: numbered steps, bullet lists, **bold**, and links that appear in the CONTENT. No headings, no images, no invented URLs. Keep short answers to 1-4 sentences.
|
|
- Extract between 1 and {max_faqs} FAQs; prefer the most useful ones. If the content contains nothing FAQ-worthy, return an empty list.
|
|
- Assign each FAQ a short category. {category_rule}
|
|
- Do NOT produce any question that duplicates or trivially rephrases one of the EXISTING QUESTIONS listed below."""
|
|
|
|
_IMPORT_INSTRUCTIONS = """You are migrating an existing FAQ/help-center web page into a structured FAQ list.
|
|
|
|
Read the PAGE TEXT below and extract every question-and-answer pair that is actually present on the page.
|
|
|
|
Rules:
|
|
- Preserve the original wording as closely as possible; only trim navigation debris, repeated headings and boilerplate.
|
|
- Preserve the page's list/step structure in answers as Markdown (numbered steps, bullet lists, **bold**, links present in the PAGE TEXT).
|
|
- Only extract real Q&A pairs — skip marketing copy, navigation links and unrelated text.
|
|
- Use the page's own section headings as categories where they exist; otherwise use "General".
|
|
- Extract at most {max_faqs} pairs per call.
|
|
- Do NOT extract any question that duplicates one of the EXISTING QUESTIONS listed below."""
|
|
|
|
|
|
class FAQGeneratorAgent:
|
|
"""Stateless one-shot LLM extraction of FAQs from text, using the org's
|
|
configured model. Groq gets the shared `json`-tool structured-output path;
|
|
every other provider uses agno's native response_model."""
|
|
|
|
def __init__(self, api_key: str, model_name: str, model_type: str, base_url: str = None):
|
|
self.model_type = model_type
|
|
self.model_name = model_name
|
|
self.api_key = api_key
|
|
self.base_url = base_url
|
|
self._use_groq_json_tool = model_type.upper() == "GROQ"
|
|
# Batch size + FAQ yield scale with the selected model's context
|
|
# window (fewer, larger LLM calls on big-context models).
|
|
self.batch_chars, self.max_faqs = faq_batch_chars_for(model_type, model_name)
|
|
self.max_tokens = max(FAQ_MAX_TOKENS, output_tokens_for(self.max_faqs))
|
|
# Metering hook, fired once per ACTUAL provider call (including the
|
|
# plain-JSON fallback's second call) — set by the job runner.
|
|
self.on_llm_call: Optional[Callable[[], None]] = None
|
|
|
|
def _count_call(self) -> None:
|
|
if self.on_llm_call:
|
|
self.on_llm_call()
|
|
|
|
async def generate_from_text(
|
|
self,
|
|
content: str,
|
|
existing_questions: Optional[List[str]] = None,
|
|
existing_categories: Optional[List[str]] = None,
|
|
) -> List[GeneratedFAQ]:
|
|
"""Draft grounded FAQs from knowledge-base content."""
|
|
if existing_categories:
|
|
category_rule = (
|
|
"Prefer one of the existing categories when it fits: "
|
|
+ ", ".join(existing_categories[:20])
|
|
+ ". Introduce a new category only when none fit."
|
|
)
|
|
else:
|
|
category_rule = 'Good examples: "Getting started", "Billing", "Account & security", "Integrations".'
|
|
instructions = _GENERATE_INSTRUCTIONS.format(
|
|
max_faqs=self.max_faqs, category_rule=category_rule
|
|
)
|
|
message = self._build_message(instructions, "CONTENT", content, existing_questions)
|
|
return await self._extract(instructions, message)
|
|
|
|
async def extract_from_faq_page(
|
|
self,
|
|
content: str,
|
|
existing_questions: Optional[List[str]] = None,
|
|
) -> List[GeneratedFAQ]:
|
|
"""Extract verbatim-ish Q&A pairs from an external FAQ page."""
|
|
instructions = _IMPORT_INSTRUCTIONS.format(max_faqs=self.max_faqs)
|
|
message = self._build_message(instructions, "PAGE TEXT", content, existing_questions)
|
|
return await self._extract(instructions, message)
|
|
|
|
@staticmethod
|
|
def _build_message(instructions: str, content_label: str, content: str, existing_questions: Optional[List[str]]) -> str:
|
|
parts = []
|
|
if existing_questions:
|
|
parts.append("EXISTING QUESTIONS (do not repeat):\n" + "\n".join(f"- {q}" for q in existing_questions))
|
|
# Crawled pages / imported docs are attacker-influencable content —
|
|
# fence them as data so embedded instructions are never followed. The
|
|
# limit is generous: batching upstream already bounds the content.
|
|
parts.append(visitor_data_block(content_label, content, limit=200_000))
|
|
return "\n\n".join(parts)
|
|
|
|
def _build_agent(self, instructions: str, tools: list, response_model, structured_outputs: bool):
|
|
from agno.agent import Agent
|
|
|
|
model = create_model(
|
|
model_type=self.model_type,
|
|
api_key=self.api_key,
|
|
model_name=self.model_name,
|
|
max_tokens=self.max_tokens,
|
|
base_url=self.base_url,
|
|
)
|
|
return Agent(
|
|
name="FAQ Generator",
|
|
model=model,
|
|
tools=tools,
|
|
instructions=instructions,
|
|
markdown=False,
|
|
response_model=response_model,
|
|
structured_outputs=structured_outputs,
|
|
debug_mode=settings.ENVIRONMENT == "development",
|
|
)
|
|
|
|
async def _extract(self, instructions: str, message: str) -> List[GeneratedFAQ]:
|
|
capture: dict = {}
|
|
tools = []
|
|
response_model = FAQExtractionResult
|
|
structured_outputs = True
|
|
# Keep the original instructions for the plain-JSON fallback — the Groq
|
|
# branch augments a separate copy with tool-calling guidance.
|
|
agent_instructions = instructions
|
|
if self._use_groq_json_tool:
|
|
tools.append(
|
|
build_groq_json_tool(
|
|
capture,
|
|
_FAQ_JSON_TOOL_SCHEMA,
|
|
description="Return the final extracted FAQ list. Call exactly once.",
|
|
)
|
|
)
|
|
agent_instructions = instructions + _GROQ_FAQ_INSTRUCTION
|
|
response_model = None
|
|
structured_outputs = False
|
|
|
|
agent = self._build_agent(agent_instructions, tools, response_model, structured_outputs)
|
|
|
|
try:
|
|
self._count_call()
|
|
response = await agent.arun(message=message, stream=False)
|
|
except Exception as arun_exc:
|
|
if self._use_groq_json_tool:
|
|
salvaged = salvage_groq_json_error(arun_exc)
|
|
if salvaged:
|
|
logger.warning("Groq FAQ json tool call unparseable (likely truncated); salvaging fields")
|
|
return self._validate(salvaged)
|
|
# Tool call errored with nothing to salvage — fall back to the
|
|
# provider-agnostic plain-JSON path rather than failing the batch.
|
|
logger.warning(f"Groq FAQ json tool call failed ({arun_exc}); falling back to plain-JSON extraction")
|
|
return await self._extract_plain_json(instructions, message)
|
|
# Some providers (Ollama/HuggingFace/Mistral via agno) fail on
|
|
# native structured outputs — retry once with a plain-JSON prompt.
|
|
logger.warning(
|
|
f"Native structured output failed for {self.model_type}/{self.model_name} "
|
|
f"({arun_exc}); retrying with plain-JSON instruction"
|
|
)
|
|
return await self._extract_plain_json(instructions, message)
|
|
|
|
if self._use_groq_json_tool:
|
|
faqs = self._validate(capture)
|
|
if faqs:
|
|
return faqs
|
|
# Groq answered but never emitted a usable tool call (common with
|
|
# some Groq models) — `capture` is empty, so the batch would be
|
|
# silently dropped. Fall back to plain-JSON extraction.
|
|
logger.warning("Groq FAQ json tool produced no output; falling back to plain-JSON extraction")
|
|
return await self._extract_plain_json(instructions, message)
|
|
try:
|
|
return self._parse_native(response)
|
|
except _UnparseableOutput:
|
|
logger.warning(
|
|
f"Unparseable structured output from {self.model_type}/{self.model_name}; "
|
|
"retrying with plain-JSON instruction"
|
|
)
|
|
return await self._extract_plain_json(instructions, message)
|
|
|
|
async def _extract_plain_json(self, instructions: str, message: str) -> List[GeneratedFAQ]:
|
|
"""Provider-agnostic fallback: no response_model, just a strict
|
|
respond-only-with-JSON instruction, parsed from the raw text."""
|
|
agent = self._build_agent(
|
|
instructions + _JSON_ONLY_INSTRUCTION, tools=[],
|
|
response_model=None, structured_outputs=False,
|
|
)
|
|
self._count_call()
|
|
response = await agent.arun(message=message, stream=False)
|
|
content = getattr(response, "content", response)
|
|
return self._validate(_first_json_object(content if isinstance(content, str) else str(content)))
|
|
|
|
def _parse_native(self, response) -> List[GeneratedFAQ]:
|
|
content = getattr(response, "content", response)
|
|
if isinstance(content, FAQExtractionResult):
|
|
return self._validate(content.model_dump())
|
|
if isinstance(content, str):
|
|
try:
|
|
return self._validate(json.loads(content))
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise _UnparseableOutput("model returned unparseable text instead of structured output")
|
|
raise _UnparseableOutput(f"unexpected response content type {type(content)}")
|
|
|
|
def _validate(self, data: dict) -> List[GeneratedFAQ]:
|
|
"""Validate model output, dropping malformed entries rather than the
|
|
whole batch. Truncates over-long fields instead of rejecting them."""
|
|
raw_items = (data or {}).get("faqs") or []
|
|
faqs: List[GeneratedFAQ] = []
|
|
for item in raw_items[: self.max_faqs]:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
question = str(item.get("question") or "").strip()[:MAX_QUESTION_LENGTH]
|
|
answer = str(item.get("answer") or "").strip()[:MAX_ANSWER_LENGTH]
|
|
category = str(item.get("category") or "").strip()[:100]
|
|
if not question or not answer:
|
|
continue
|
|
try:
|
|
faqs.append(GeneratedFAQ(question=question, answer=answer, category=category or "General"))
|
|
except ValidationError:
|
|
continue
|
|
return faqs
|