Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,22 @@ model Message {
@@index([conversationId])
}

model ContextDocument {
id String @id @default(uuid())
title String
category String
content String
tags String // Stored as JSON string
sourceUrl String?
isActive Boolean @default(true)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([category])
@@index([isActive])
}

model AiUsageMetric {
id String @id @default(uuid())
userId String?
Expand Down
4 changes: 3 additions & 1 deletion src/ai-assistant/ai-assistant.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Controller, Get, Post, Body, Param, Delete, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AiAssistantService } from './ai-assistant.service';
import { AiAssistantService } from './services/ai-assistant.service';
import { CreateConversationDto, SendMessageDto } from './dto/ai-assistant.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { ThrottleByWallet } from '../common/decorators/throttle-by-wallet.decorator';

@ApiTags('AI Assistant')
@ApiBearerAuth()
Expand Down Expand Up @@ -36,6 +37,7 @@ export class AiAssistantController {
@Post(':id/messages')
@ApiOperation({ summary: 'Send a message to the AI Assistant' })
@ApiResponse({ status: 201, description: 'AI Assistant response.' })
@ThrottleByWallet('ai')
async sendMessage(
@CurrentUser() user: any,
@Param('id') conversationId: string,
Expand Down
13 changes: 7 additions & 6 deletions src/ai-assistant/ai-assistant.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Module } from '@nestjs/common';
import { AiAssistantController } from './ai-assistant.controller';
import { AiAssistantService } from './ai-assistant.service';
import { LlmProviderService } from './llm-provider.service';
import { RagService } from './rag.service';
import { AiAssistantService } from './services/ai-assistant.service';
import { LlmProviderService } from './services/llm-provider.service';
import { RagService } from './services/rag.service';
import { SafetyGuardrailService } from './services/safety-guardrail.service';
import { PrismaModule } from '../prisma/prisma.module';
// Note: assuming PrismaModule is exported from '../prisma/prisma.module'
import { RedisModule } from '../redis/redis.module';

@Module({
imports: [PrismaModule],
imports: [PrismaModule, RedisModule],
controllers: [AiAssistantController],
providers: [AiAssistantService, LlmProviderService, RagService],
providers: [AiAssistantService, LlmProviderService, RagService, SafetyGuardrailService],
exports: [AiAssistantService],
})
export class AiAssistantModule {}
10 changes: 5 additions & 5 deletions src/ai-assistant/ai-assistant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class AiAssistantService {
});

// 3. RAG Retrieval
const context = await this.ragService.retrieveContext(dto.content);
const { content: context, citations } = await this.ragService.retrieveContext(dto.content);

// 4. Construct Prompt Pipeline
const systemPrompt = `You are the TruthBounty AI Assistant. You help contributors navigate the protocol.
Expand All @@ -91,7 +91,7 @@ ${context}

const messagesToLlm: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
{ role: 'system', content: systemPrompt },
...history.map(msg => ({
...history.map((msg) => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
})),
Expand All @@ -101,7 +101,7 @@ ${context}

// 5. Orchestrate LLM request
const llmResponse = await this.llmProvider.generateResponse(messagesToLlm);

const latencyMs = Date.now() - startTime;

// 6. Save assistant response
Expand Down Expand Up @@ -139,8 +139,8 @@ ${context}
provider: llmResponse.provider,
latencyMs,
tokens: llmResponse.usage?.total_tokens || 0,
citations: ['MOCKED_CITATION_1', 'MOCKED_CITATION_2'] // Placeholder for standardizing API
}
citations,
},
};
}

Expand Down
32 changes: 17 additions & 15 deletions src/ai-assistant/rag.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,24 @@ export class RagService {

constructor(private prisma: PrismaService) {}

async retrieveContext(query: string): Promise<string> {
async retrieveContext(query: string): Promise<{ content: string; citations: string[] }> {
this.logger.debug(`Retrieving context for query: ${query}`);

// In a real implementation, this would:
// 1. Embed the query
// 2. Perform a vector search against pgvector or external vector DB
// 3. Fetch verified data from DB (Claims, Governance Proposals, etc.)

// For now, returning a mock context string that simulates a RAG retrieval
const mockedProtocolData = `
TruthBounty Protocol Guidelines:
- A claim can only be verified by users with a reputation score of at least 100.
- Governance proposals require a quorum of 5% of total circulating tokens.
- Disputes are resolved by the Supreme Court which consists of 7 randomly selected high-reputation members.
`;

return mockedProtocolData;
// Simple keyword-based retrieval for SQLite
const words = query.split(' ').filter((w) => w.length > 3);
const documents = await this.prisma.contextDocument.findMany({
where: {
isActive: true,
OR: words.map((word) => ({
content: { contains: word },
})),
},
take: 5,
});

const context = documents.map((d) => `Source (${d.title}): ${d.content}`).join('\n\n');
const citations = documents.map((d) => d.title);

return { content: context || 'No relevant protocol information found.', citations };
}
}
193 changes: 193 additions & 0 deletions src/ai-assistant/services/ai-assistant.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Injectable, NotFoundException, Logger, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { LlmProviderService } from './llm-provider.service';
import { RagService } from './rag.service';
import { SafetyGuardrailService } from './safety-guardrail.service';
import { CreateConversationDto, SendMessageDto } from '../dto/ai-assistant.dto';

@Injectable()
export class AiAssistantService {
private readonly logger = new Logger(AiAssistantService.name);

constructor(
private prisma: PrismaService,
private llmProvider: LlmProviderService,
private ragService: RagService,
private safetyGuardrail: SafetyGuardrailService,
) {}

async createConversation(userId: string, dto: CreateConversationDto) {
return this.prisma.conversation.create({
data: {
userId,
title: dto.title || 'New Conversation',
},
});
}

async getConversations(userId: string) {
return this.prisma.conversation.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
});
}

async getConversationMessages(userId: string, conversationId: string) {
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
});

if (!conversation) {
throw new NotFoundException('Conversation not found');
}

if (conversation.userId !== userId) {
throw new ForbiddenException('You do not have access to this conversation');
}

return this.prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
});
}

async sendMessage(userId: string, conversationId: string, dto: SendMessageDto) {
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
});

if (!conversation) {
throw new NotFoundException('Conversation not found');
}

if (conversation.userId !== userId) {
throw new ForbiddenException('You do not have access to this conversation');
}

// 0. Safety Check
const safetyCheck = this.safetyGuardrail.checkContent(dto.content);

// 1. Save user message
const userMessage = await this.prisma.message.create({
data: {
conversationId,
role: 'user',
content: dto.content,
},
});

if (safetyCheck.flagged) {
const assistantMessage = await this.prisma.message.create({
data: {
conversationId,
role: 'assistant',
content: 'I cannot answer this request.',
},
});

return {
message: assistantMessage,
metadata: {
provider: 'none',
latencyMs: 0,
tokens: 0,
citations: [],
flagged: true,
flagReason: safetyCheck.reason,
}
};
}

// 2. Retrieve Conversation History
const history = await this.prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
take: 10, // Short-term conversation memory limit
});

// 3. RAG Retrieval
const { context, citations } = await this.ragService.retrieveContext(dto.content);

// 4. Construct Prompt Pipeline
const systemPrompt = `You are the TruthBounty AI Assistant. You help contributors navigate the protocol.
Your answers must be grounded ONLY in verified protocol information.
Do not fabricate protocol state or execute operations.
Protocol Context:
${context}
`;

const messagesToLlm: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
{ role: 'system', content: systemPrompt },
...history.map(msg => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
})),
];

const startTime = Date.now();

// 5. Orchestrate LLM request
const llmResponse = await this.llmProvider.generateResponse(messagesToLlm);

const latencyMs = Date.now() - startTime;

// 6. Save assistant response
const assistantMessage = await this.prisma.message.create({
data: {
conversationId,
role: 'assistant',
content: llmResponse.content,
},
});

// 7. Update conversation updated at
await this.prisma.conversation.update({
where: { id: conversationId },
data: { updatedAt: new Date() },
});

// 8. Track Usage Metrics
await this.prisma.aiUsageMetric.create({
data: {
userId,
provider: llmResponse.provider,
model: llmResponse.model,
promptTokens: llmResponse.usage?.prompt_tokens || 0,
completionTokens: llmResponse.usage?.completion_tokens || 0,
totalTokens: llmResponse.usage?.total_tokens || 0,
latencyMs,
},
});

// Standardized API response
return {
message: assistantMessage,
metadata: {
provider: llmResponse.provider,
latencyMs,
tokens: llmResponse.usage?.total_tokens || 0,
citations
}
};
}

async deleteConversation(userId: string, conversationId: string) {
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
});

if (!conversation) {
throw new NotFoundException('Conversation not found');
}

if (conversation.userId !== userId) {
throw new ForbiddenException('You do not have access to this conversation');
}

await this.prisma.conversation.delete({
where: { id: conversationId },
});

return { success: true };
}
}
Loading