import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; import { SearchService } from '../search/search.service'; import { callLLM } from '../../common/llm-client'; export interface Citation { messageId: string; snippet: string; senderName: string; sourceGroupName: string; approvedAt: number; } @Injectable() export class AskAIService { private readonly logger = new Logger(AskAIService.name); constructor( private readonly prisma: PrismaService, private readonly search: SearchService, private readonly config: ConfigService, ) {} async ask(userId: string, tenantId: string, question: string) { // 1. Retrieve: pull the most relevant approved messages from the tenant's index. const { hits } = await this.search.search(tenantId, question, undefined, undefined, 1, 8); const citations: Citation[] = hits.map((h) => ({ messageId: h.id, snippet: h.content.slice(0, 240), senderName: h.senderName || 'Unknown', sourceGroupName: h.sourceGroupName, approvedAt: h.approvedAt, })); // 2. Generate: ground the LLM in the retrieved snippets. Degrade gracefully // if no AI key is configured or no context was found. const apiKey = this.config.get('OPENROUTER_API_KEY'); let answer: string; if (citations.length === 0) { answer = "I couldn't find anything in your community's messages about that yet. Try rephrasing, or ask an admin."; } else if (!apiKey) { answer = this.fallbackAnswer(citations); } else { try { answer = await callLLM(this.buildPrompt(question, citations), apiKey); } catch (err) { this.logger.warn({ err }, 'Ask AI LLM call failed — returning snippet fallback'); answer = this.fallbackAnswer(citations); } } // 3. Log the query for history + future tuning. const record = await this.prisma.askAIQuery.create({ data: { tenantId, userId, question, answer, citations: citations as unknown as object }, }); return { id: record.id, question, answer, citations, createdAt: record.createdAt.toISOString(), }; } async history(userId: string, tenantId: string) { const queries = await this.prisma.askAIQuery.findMany({ where: { userId, tenantId }, orderBy: { createdAt: 'desc' }, take: 20, }); return queries.map((q) => ({ id: q.id, question: q.question, answer: q.answer, citations: q.citations as unknown as Citation[], createdAt: q.createdAt.toISOString(), })); } private buildPrompt(question: string, citations: Citation[]): string { const context = citations .map((c, i) => `[${i + 1}] (${c.sourceGroupName}, by ${c.senderName}): ${c.snippet}`) .join('\n'); return [ 'You are a helpful assistant for a community group. Answer the member\'s question', 'using ONLY the message excerpts below. If the excerpts do not contain the answer,', 'say you don\'t have that information. Cite sources inline as [1], [2], etc.', 'Keep the answer concise and friendly.', '', 'Message excerpts:', context, '', `Question: ${question}`, '', 'Answer:', ].join('\n'); } private fallbackAnswer(citations: Citation[]): string { const top = citations.slice(0, 3).map((c, i) => `[${i + 1}] ${c.snippet}`).join('\n\n'); return `Here are the most relevant messages I found:\n\n${top}`; } }