Files
tower/apps/api/src/modules/ask-ai/ask-ai.service.ts
T
maaz519 f61c070428 feat: member portal Sprint 9 — Ask AI (RAG over message archive)
- AskAIQuery model + migration (logs question/answer/citations per member)
- AskAIService: RAG pipeline — retrieve via tenant-isolated SearchService (Meili),
  ground LLM in top-8 snippets with inline [n] citations, degrade gracefully when
  OPENROUTER_API_KEY absent or no context (snippet fallback)
- Shared api/common/llm-client.ts (mirrors worker's OpenRouter client)
- SearchModule now exports SearchService
- Member endpoints: GET/POST /my/ask (history + ask)
- /my/ask page + AskChat client component: question box, answer cards with sources
- Ask AI nav item (top of member nav)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:03:01 +05:30

108 lines
3.5 KiB
TypeScript

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<string>('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}`;
}
}