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>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "AskAIQuery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"question" TEXT NOT NULL,
|
||||
"answer" TEXT NOT NULL,
|
||||
"citations" JSONB NOT NULL DEFAULT '[]',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AskAIQuery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "AskAIQuery_tenantId_createdAt_idx" ON "AskAIQuery"("tenantId", "createdAt");
|
||||
CREATE INDEX "AskAIQuery_userId_createdAt_idx" ON "AskAIQuery"("userId", "createdAt");
|
||||
|
||||
ALTER TABLE "AskAIQuery" ADD CONSTRAINT "AskAIQuery_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "AskAIQuery" ADD CONSTRAINT "AskAIQuery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -95,6 +95,7 @@ model Tenant {
|
||||
sevaOpportunities SevaOpportunity[]
|
||||
gamificationEvents GamificationEvent[]
|
||||
circles Circle[]
|
||||
askAIQueries AskAIQuery[]
|
||||
}
|
||||
|
||||
enum AdminRole {
|
||||
@@ -422,6 +423,7 @@ model TowerUser {
|
||||
sevaEntries SevaEntry[]
|
||||
gamificationEvents GamificationEvent[]
|
||||
circleMemberships CircleMembership[]
|
||||
askAIQueries AskAIQuery[]
|
||||
|
||||
@@unique([tenantId, phoneHash])
|
||||
@@index([phoneHash])
|
||||
@@ -730,3 +732,22 @@ model CircleMembership {
|
||||
@@unique([circleId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ask AI (RAG over the message archive)
|
||||
// ============================================================================
|
||||
|
||||
model AskAIQuery {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
question String
|
||||
answer String
|
||||
citations Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||
const DEFAULT_MODEL = 'google/gemini-3.5-flash';
|
||||
|
||||
/**
|
||||
* Minimal OpenRouter chat completion. Mirrors the worker's llm-client so digest
|
||||
* and Ask AI share one calling convention. Returns the assistant message text.
|
||||
*/
|
||||
export async function callLLM(
|
||||
prompt: string,
|
||||
apiKey: string,
|
||||
model = DEFAULT_MODEL,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': 'https://tower.insignia.app',
|
||||
'X-Title': 'TOWER Community Platform',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`OpenRouter error ${res.status}: ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { choices: Array<{ message: { content: string } }> };
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('OpenRouter returned empty content');
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AskAIService } from './ask-ai.service';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
|
||||
@Module({
|
||||
imports: [SearchModule],
|
||||
providers: [AskAIService],
|
||||
exports: [AskAIService],
|
||||
})
|
||||
export class AskAIModule {}
|
||||
@@ -0,0 +1,107 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestj
|
||||
import { MyService } from './my.service';
|
||||
import { SevaService } from '../seva/seva.service';
|
||||
import { CirclesService } from '../circles/circles.service';
|
||||
import { AskAIService } from '../ask-ai/ask-ai.service';
|
||||
import { MemberAuth } from '../auth/member-auth.decorator';
|
||||
import { CurrentMember } from '../auth/current-member.decorator';
|
||||
import type { MemberJwtPayload } from '@tower/types';
|
||||
@@ -28,8 +29,19 @@ export class MyController {
|
||||
private readonly service: MyService,
|
||||
private readonly seva: SevaService,
|
||||
private readonly circles: CirclesService,
|
||||
private readonly askAI: AskAIService,
|
||||
) {}
|
||||
|
||||
@Get('ask')
|
||||
askHistory(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.askAI.history(member.sub, member.tenantId);
|
||||
}
|
||||
|
||||
@Post('ask')
|
||||
ask(@CurrentMember() member: MemberJwtPayload, @Body() body: { question: string }) {
|
||||
return this.askAI.ask(member.sub, member.tenantId, body.question);
|
||||
}
|
||||
|
||||
@Get('seva')
|
||||
sevaList(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.seva.listForMember(member.sub, member.tenantId);
|
||||
|
||||
@@ -5,9 +5,10 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { SevaModule } from '../seva/seva.module';
|
||||
import { GamificationModule } from '../gamification/gamification.module';
|
||||
import { CirclesModule } from '../circles/circles.module';
|
||||
import { AskAIModule } from '../ask-ai/ask-ai.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule],
|
||||
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule],
|
||||
controllers: [MyController],
|
||||
providers: [MyService],
|
||||
})
|
||||
|
||||
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
Reference in New Issue
Block a user