f61c070428
- 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>
108 lines
3.5 KiB
TypeScript
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}`;
|
|
}
|
|
}
|