""" Copyright 2024-2026 ReplyPier 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. Public help-center domain logic: resolve the Host header to an org's published help center, group its FAQs, and answer "Ask AI" questions grounded in the mapped agent's knowledge. No admin auth here — everything must stay read-only against published data. """ import asyncio from typing import List, Optional, Tuple from sqlalchemy.orm import Session from app.agents.guardrail_policy import ( GuardrailContext, INJECTION_CLAUSE, visitor_data_block, ) from app.core.help_center_host import normalize_host, slug_for_host # noqa: F401 — canonical host helpers re-exported for the public app from app.core.logger import get_logger from app.utils.guardrail_runtime import Surface, check_inbound from app.database import SessionLocal from app.models.faq import FAQ from app.models.help_center import HelpCenterSettings from app.repositories.faq import FAQRepository from app.repositories.help_center import HelpCenterQueryRepository, HelpCenterRepository from app.services.help_center_access import help_center_allowed logger = get_logger(__name__) MAX_QUESTION_CHARS = 500 ANSWER_MAX_TOKENS = 600 # Unauthenticated LLM calls are expensive: bound how many run at once per # worker so /ask can't drain the DB pool or the provider budget. _ask_concurrency = asyncio.Semaphore(4) _ASK_INSTRUCTIONS = """You are the support assistant on a company's public help center. A visitor has asked a question. You have tools to look up the answer: - search_faqs: search the published help-center FAQs. - search_knowledge_base: search the company's wider knowledge base (only available when the help center is mapped to an agent). ALWAYS use the tools before answering — start with search_faqs, and also try search_knowledge_base for anything the FAQs don't cover. You may call a tool more than once with different wording if the first result isn't relevant. Answer ONLY from what the tools return. When they surface a relevant answer, reply in 1-4 short sentences of plain text (no markdown), in a friendly second-person voice. If, after genuinely searching, you find nothing relevant, briefly say you couldn't find that in the help center and suggest starting a chat or contacting support. Never invent facts, prices, policies, or URLs. """ + INJECTION_CLAUSE def resolve_help_center( db: Session, host: Optional[str] = None, slug: Optional[str] = None ) -> Optional[HelpCenterSettings]: """Enabled help center row, or None (renders as 404). Resolves by explicit `slug` (path dispatch) when given, else by Host header (subdomain slug or a verified custom domain). Custom domains only match once verified; plan lapses hide the site.""" repo = HelpCenterRepository(db) if slug is not None: row = repo.get_by_slug(slug) else: host_slug = slug_for_host(host or "") if host_slug: row = repo.get_by_slug(host_slug) else: row = repo.get_by_verified_domain(host or "") if not row or not row.enabled: return None if not help_center_allowed(db, row.organization_id): return None return row # Topic accent colors, applied by category display order (matches the index and # article templates). Kept here so list cards, sidebar topics, the article tag # and related cards all resolve the SAME color for a given category. CATEGORY_PALETTE = [ "#6d5bd0", "#0e8c8c", "#cf5b38", "#2a6fdb", "#1f8a5b", "#b0468a", "#c98a1e", "#3a6f8f", ] def published_faq_groups( db: Session, row: HelpCenterSettings, search: Optional[str] = None ) -> List[Tuple[str, List[FAQ]]]: """Published FAQs grouped by category, in display order.""" faqs = FAQRepository(db).get_published_for_org(row.organization_id, search=search) groups: dict = {} for faq in faqs: groups.setdefault(faq.category, []).append(faq) return list(groups.items()) def category_colors(categories) -> dict: """category name -> accent hex, by display order.""" return {c: CATEGORY_PALETTE[i % len(CATEGORY_PALETTE)] for i, c in enumerate(categories)} def get_published_article(db: Session, row: HelpCenterSettings, slug: str) -> Optional[FAQ]: """A single published FAQ by slug for the /a/{slug} article page.""" return FAQRepository(db).get_published_by_slug(row.organization_id, slug) def get_published_article_by_path( db: Session, row: HelpCenterSettings, url_path: str ) -> Optional[FAQ]: """A single published FAQ by its preserved original path — the lookup for a URL the org carried over from the help center it migrated from.""" return FAQRepository(db).get_published_by_url_path(row.organization_id, url_path) def related_articles(db: Session, row: HelpCenterSettings, faq: FAQ, limit: int = 4) -> List[FAQ]: """Other published FAQs in the same category — the article's related list.""" return FAQRepository(db).get_published_related( row.organization_id, faq.category, faq.id, limit=limit ) def contrast_ink(hex_color: str) -> str: """Dark or light text over the brand color (WCAG relative luminance).""" try: value = hex_color.lstrip("#") if len(value) == 3: value = "".join(ch * 2 for ch in value) r, g, b = (int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)) def linear(c: float) -> float: return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) return "#12131A" if luminance > 0.45 else "#FFFFFF" except (ValueError, IndexError): return "#FFFFFF" def widget_id_for(row: HelpCenterSettings) -> Optional[str]: """The chat widget embedded on the page: the mapped agent's widget. Gated by its own toggle, independent of the AI quick-summary in search.""" if not (row.chat_widget_enabled and row.agent and row.agent.widgets): return None return row.agent.widgets[0].id def ask_available(row: HelpCenterSettings) -> bool: return bool(row.ai_search_enabled and row.agent_id) async def answer_question(organization_id, agent_id, question: str) -> Optional[str]: """Grounded answer for a public help-center question. The agent is given FAQ + knowledge search tools and decides what to look up, so it can find and combine answers instead of relying on a single pre-stuffed context. Returns None when no confident answer could be produced. Writes the ask-log row (metering/analytics); never touches chat history or the KB. The whole tool-using run happens in a worker thread: the tools do synchronous DB work, and holding a pooled connection through a multi-second model call would let this unauthenticated endpoint drain the pool for the backend. """ from app.core.security import decrypt_api_key from app.repositories.ai_config import AIConfigRepository question = question.strip()[:MAX_QUESTION_CHARS] # Pre-LLM guardrail on the only unauthenticated LLM entry point: a # blocked question never reaches the semaphore, the DB pool, or the model. # None renders as the standard "couldn't find that" fallback. guardrail_ctx = GuardrailContext( org_id=str(organization_id), agent_id=str(agent_id) if agent_id else None ) if check_inbound(question, ctx=guardrail_ctx, surface=Surface.HELP_CENTER).block: return None async with _ask_concurrency: with SessionLocal() as db: config = AIConfigRepository(db).get_active_config(organization_id) if not config: return None model_type = config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type) model_name = config.model_name api_key = decrypt_api_key(config.encrypted_api_key) base_url = (config.settings or {}).get('base_url') def _run() -> Optional[str]: from agno.agent import Agent from app.tools.faq_search import FAQSearchTool from app.tools.knowledge_search_byagent import KnowledgeSearchByAgent from app.utils.agno_utils import create_model tools = [FAQSearchTool(organization_id=organization_id)] if agent_id: tools.append(KnowledgeSearchByAgent(agent_id=str(agent_id), org_id=organization_id)) model = create_model( model_type=model_type, api_key=api_key, model_name=model_name, max_tokens=ANSWER_MAX_TOKENS, base_url=base_url ) agent = Agent( name="Help Center Answers", model=model, tools=tools, instructions=_ASK_INSTRUCTIONS, markdown=False, ) response = agent.run( message=visitor_data_block("VISITOR QUESTION", question), stream=False, ) content = getattr(response, "content", None) return content.strip() if isinstance(content, str) and content.strip() else None answer = None try: answer = await asyncio.to_thread(_run) except Exception as e: logger.error(f"Help center ask failed for org {organization_id}: {e}") finally: try: with SessionLocal() as log_db: HelpCenterQueryRepository(log_db).log(organization_id, question, answered=bool(answer)) except Exception as log_err: logger.error(f"Help center ask log failed: {log_err}") return answer