"""services/copilot/engine.py — Orchestrates retrieval → prompt → LLM → response. If no LLM provider is configured (or the call fails), falls back to a deterministic "here's what I found" extractive answer built directly from the retrieved passages — same graceful-degradation contract used throughout this backend (smishing_intel's ML-vs-heuristic split, behaviour engine's model-vs-fallback split): a missing/failing external dependency degrades the response, it never 500s the request. """ from __future__ import annotations import logging from sqlalchemy.ext.asyncio import AsyncSession from app.config import Settings from app.services.copilot import prompts from app.services.copilot.llm.base import LLMError from app.services.copilot.llm.factory import get_llm_provider from app.services.copilot.models import ChatRequest, ChatResponse, ChatTurn, Citation, RetrievedChunk from app.services.copilot.retrieval import hybrid_search logger = logging.getLogger(__name__) _TOP_K = 5 _SNIPPET_LENGTH = 220 def _citations_from(chunks: list[RetrievedChunk]) -> list[Citation]: return [ Citation( title=chunk.title, source_path=chunk.source_path, section=chunk.section, snippet=(chunk.content[:_SNIPPET_LENGTH] + "…") if len(chunk.content) > _SNIPPET_LENGTH else chunk.content, ) for chunk in chunks ] def _retrieval_only_answer(chunks: list[RetrievedChunk]) -> str: if not chunks: return ( "I couldn't find anything in Shield's knowledge base about that. Try rephrasing, " "or ask about a specific feature like APK scanning, threat history, or risk scores." ) lines = ["Here's what I found (an AI provider isn't configured, so this is retrieved directly):"] for i, chunk in enumerate(chunks, start=1): excerpt = chunk.content[:_SNIPPET_LENGTH] + ("…" if len(chunk.content) > _SNIPPET_LENGTH else "") lines.append(f"[{i}] {chunk.title}: {excerpt}") return "\n\n".join(lines) async def answer_chat(session: AsyncSession, request: ChatRequest, settings: Settings) -> ChatResponse: chunks = await hybrid_search(session, request.message, top_k=_TOP_K) citations = _citations_from(chunks) try: provider = get_llm_provider(settings) user_prompt = prompts.build_user_prompt(request.message, request.history, chunks) answer = await provider.generate(prompts.SYSTEM_PROMPT, user_prompt) return ChatResponse(answer=answer.strip(), citations=citations, method="llm", provider=provider.name) except LLMError as exc: logger.info("LLM generation unavailable (%s) — returning retrieval-only answer.", exc) return ChatResponse( answer=_retrieval_only_answer(chunks), citations=citations, method="retrieval_only", provider=None )