From f61c07042821f48c62d19fd14d715b9077e1b3a6 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 17:03:01 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=209=20?= =?UTF-8?q?=E2=80=94=20Ask=20AI=20(RAG=20over=20message=20archive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../20260617240000_add_ask_ai/migration.sql | 16 +++ apps/api/prisma/schema.prisma | 21 ++++ apps/api/src/common/llm-client.ts | 37 ++++++ apps/api/src/modules/ask-ai/ask-ai.module.ts | 10 ++ apps/api/src/modules/ask-ai/ask-ai.service.ts | 107 ++++++++++++++++++ apps/api/src/modules/my/my.controller.ts | 12 ++ apps/api/src/modules/my/my.module.ts | 3 +- apps/api/src/modules/search/search.module.ts | 1 + apps/web/app/api/my/ask/route.ts | 20 ++++ apps/web/app/my/ask/AskChat.tsx | 102 +++++++++++++++++ apps/web/app/my/ask/page.tsx | 49 ++++++++ apps/web/app/my/layout.tsx | 1 + 12 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260617240000_add_ask_ai/migration.sql create mode 100644 apps/api/src/common/llm-client.ts create mode 100644 apps/api/src/modules/ask-ai/ask-ai.module.ts create mode 100644 apps/api/src/modules/ask-ai/ask-ai.service.ts create mode 100644 apps/web/app/api/my/ask/route.ts create mode 100644 apps/web/app/my/ask/AskChat.tsx create mode 100644 apps/web/app/my/ask/page.tsx diff --git a/apps/api/prisma/migrations/20260617240000_add_ask_ai/migration.sql b/apps/api/prisma/migrations/20260617240000_add_ask_ai/migration.sql new file mode 100644 index 0000000..1d7de79 --- /dev/null +++ b/apps/api/prisma/migrations/20260617240000_add_ask_ai/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f216ccd..de940d6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/common/llm-client.ts b/apps/api/src/common/llm-client.ts new file mode 100644 index 0000000..4b85c40 --- /dev/null +++ b/apps/api/src/common/llm-client.ts @@ -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 { + 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; +} diff --git a/apps/api/src/modules/ask-ai/ask-ai.module.ts b/apps/api/src/modules/ask-ai/ask-ai.module.ts new file mode 100644 index 0000000..9ee82ea --- /dev/null +++ b/apps/api/src/modules/ask-ai/ask-ai.module.ts @@ -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 {} diff --git a/apps/api/src/modules/ask-ai/ask-ai.service.ts b/apps/api/src/modules/ask-ai/ask-ai.service.ts new file mode 100644 index 0000000..f8e2c0b --- /dev/null +++ b/apps/api/src/modules/ask-ai/ask-ai.service.ts @@ -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('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}`; + } +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index 947c2b7..b675580 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -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); diff --git a/apps/api/src/modules/my/my.module.ts b/apps/api/src/modules/my/my.module.ts index f59de79..5a61bd6 100644 --- a/apps/api/src/modules/my/my.module.ts +++ b/apps/api/src/modules/my/my.module.ts @@ -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], }) diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index 238d0e8..fa74573 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -5,5 +5,6 @@ import { SearchService } from './search.service'; @Module({ controllers: [SearchController], providers: [SearchService], + exports: [SearchService], }) export class SearchModule {} diff --git a/apps/web/app/api/my/ask/route.ts b/apps/web/app/api/my/ask/route.ts new file mode 100644 index 0000000..f430fe3 --- /dev/null +++ b/apps/web/app/api/my/ask/route.ts @@ -0,0 +1,20 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/ask'); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function POST(request: Request): Promise { + 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); +} diff --git a/apps/web/app/my/ask/AskChat.tsx b/apps/web/app/my/ask/AskChat.tsx new file mode 100644 index 0000000..a26d5a1 --- /dev/null +++ b/apps/web/app/my/ask/AskChat.tsx @@ -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(initialHistory); + const [question, setQuestion] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+