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:
2026-06-17 17:03:01 +05:30
parent 6d89d8a609
commit f61c070428
12 changed files with 378 additions and 1 deletions
@@ -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;
+21
View File
@@ -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])
}
+37
View File
@@ -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}`;
}
}
+12
View File
@@ -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);
+2 -1
View File
@@ -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 {}
+20
View File
@@ -0,0 +1,20 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/ask');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await memberApiFetch('/my/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
interface Citation {
messageId: string;
snippet: string;
senderName: string;
sourceGroupName: string;
approvedAt: number;
}
interface QA {
id: string;
question: string;
answer: string;
citations: Citation[];
createdAt: string;
}
export function AskChat({ initialHistory }: { initialHistory: QA[] }) {
const [history, setHistory] = useState<QA[]>(initialHistory);
const [question, setQuestion] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const q = question.trim();
if (!q || loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/my/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q }),
});
if (!res.ok) throw new Error('Could not get an answer. Try again.');
const data = (await res.json()) as QA;
setHistory((h) => [data, ...h]);
setQuestion('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-5">
<form onSubmit={submit} className="space-y-2">
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(e); }}
placeholder="Ask anything about your community — events, decisions, who to contact…"
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Answers are grounded in your group&apos;s approved messages.</span>
<button
type="submit"
disabled={loading || !question.trim()}
className="rounded-lg bg-indigo-600 text-white text-sm font-medium px-5 py-2 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Thinking…' : 'Ask'}
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</form>
{history.length === 0 && !loading && (
<div className="text-center py-12 text-gray-400 text-sm">
<p>Ask your first question above.</p>
</div>
)}
<div className="space-y-4">
{history.map((qa) => (
<div key={qa.id} className="bg-white rounded-xl border border-gray-200 p-5">
<p className="text-sm font-semibold text-gray-900">{qa.question}</p>
<p className="text-sm text-gray-700 mt-2 whitespace-pre-line leading-relaxed">{qa.answer}</p>
{qa.citations.length > 0 && (
<div className="mt-4 border-t border-gray-100 pt-3 space-y-2">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Sources</p>
{qa.citations.slice(0, 5).map((c, i) => (
<div key={c.messageId} className="text-xs text-gray-500">
<span className="font-medium text-gray-600">[{i + 1}] {c.sourceGroupName}</span>
{' · '}{c.senderName}
<p className="text-gray-500 mt-0.5 line-clamp-2">{c.snippet}</p>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { AskChat } from './AskChat';
import Link from 'next/link';
interface Citation {
messageId: string;
snippet: string;
senderName: string;
sourceGroupName: string;
approvedAt: number;
}
interface QA {
id: string;
question: string;
answer: string;
citations: Citation[];
createdAt: string;
}
async function fetchHistory(token: string): Promise<QA[]> {
const res = await fetch(`${getApiBaseUrl()}/my/ask`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return [];
return (await res.json()) as QA[];
}
export default async function AskPage() {
const token = await getMemberToken();
if (!token) return null;
const history = await fetchHistory(token);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">Ask AI</h1>
<p className="text-sm text-gray-500 mt-0.5">Answers from your community&apos;s knowledge.</p>
</div>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
<AskChat initialHistory={history} />
</div>
);
}
+1
View File
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/ask', label: 'Ask AI' },
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },