Skip to content
5 changes: 4 additions & 1 deletion public/locales/en/copilotChatBot.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"rateQuestion": "How would you rate this conversation ?",
"copilotWarning": "Assistant visible only in full page.",
"copilotChatBotErrorMessage": "An unexpected error occured while creating the report. Refresh the page and try again.",
"copilotTitle": "Aletheia Copilot",
"copilotClearHistoryTooltip": "Clear conversation history",
"copilotCloseSidebarTooltip": "Close sidebar"
"copilotCloseSidebarTooltip": "Close sidebar",
"sessionLoadingError": "Failed to load conversation. Please try again.",
"sessionCreateError": "Failed to start a new conversation. Please try again."
}
5 changes: 4 additions & 1 deletion public/locales/pt/copilotChatBot.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"rateQuestion": "Como você avaliaria esta conversa ?",
"copilotWarning": "Assistente apenas disponivel na página completa.",
"copilotChatBotErrorMessage": "Ocorreu um erro inesperado ao criar o relatório. Atualize a página e tente novamente.",
"copilotTitle": "Aletheia Copilot",
"copilotClearHistoryTooltip": "Limpar histórico de conversa",
"copilotCloseSidebarTooltip": "Fechar menu lateral"
"copilotCloseSidebarTooltip": "Fechar menu lateral",
"sessionLoadingError": "Falha ao carregar conversa. Por favor, tente novamente.",
"sessionCreateError": "Falha ao iniciar nova conversa. Por favor, tente novamente."
}
195 changes: 176 additions & 19 deletions server/automated-fact-checking/automated-fact-checking.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Inject, Injectable, Scope } from "@nestjs/common";
import { Inject, Injectable, Logger, Scope } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { BaseRequest } from "../types";
import { REQUEST } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";

export interface AgenciaResponse {
stream: string;
json: any;
executionId?: string;
}

@Injectable({ scope: Scope.REQUEST })
export class AutomatedFactCheckingService {
agenciaURL: string;
private readonly logger = new Logger("AutomatedFactCheckingService");

constructor(
@Inject(REQUEST) private req: BaseRequest,
Expand All @@ -18,15 +25,22 @@ export class AutomatedFactCheckingService {
);
}

async getResponseFromAgents(data): Promise<{ stream: string; json: any }> {
private getAgenciaToken(): string {
const { access_token } = this.configService.get("agencia");
return this.jwtService.sign(
{ sub: this.req.user._id },
{ secret: access_token, expiresIn: "15m" }
);
}

async getResponseFromAgents(
data,
sessionId?: string
): Promise<AgenciaResponse> {
try {
const { access_token } = this.configService.get("agencia");
const agenciaAccessToken = this.jwtService.sign(
{ sub: this.req.user._id },
{ secret: access_token, expiresIn: "15m" }
);
const agenciaAccessToken = this.getAgenciaToken();

const params = {
const params: any = {
input: {
claim: data.claim,
context: data.context,
Expand All @@ -38,6 +52,11 @@ export class AutomatedFactCheckingService {
},
};

// Include session_id at top level if provided
if (sessionId) {
params.session_id = sessionId;
}

const response = await fetch(`${this.agenciaURL}/invoke`, {
method: "POST",
body: JSON.stringify(params),
Expand All @@ -48,32 +67,170 @@ export class AutomatedFactCheckingService {
keepalive: true,
});

let reader = response.body.getReader();
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Agencia returned HTTP ${response.status}: ${errorBody.substring(0, 200)}`
);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();

let streamResponse = "";
let done, value;

while (!done) {
({ done, value } = await reader.read());
streamResponse += new TextDecoder().decode(value, {
stream: true,
});
streamResponse += decoder.decode(value, { stream: true });
}

// Parse NDJSON: each line is a JSON object
const lines = streamResponse.trim().split("\n").filter(Boolean);

// Extract execution_id from the "started" line (first line)
let executionId: string | undefined;
const firstLine = JSON.parse(lines[0]);
if (firstLine.status === "started" && firstLine.execution_id) {
executionId = firstLine.execution_id;
this.logger.log(
`Agencia execution started: ${executionId}`
);
}

// Last line has the final result
const lastLine = JSON.parse(lines[lines.length - 1]);

if (lastLine.status === "error") {
throw new Error(lastLine.detail || "Agencia processing error");
}

const jsonResponse = JSON.parse(streamResponse);
const result = lastLine.message;

if (jsonResponse?.detail) {
return { stream: jsonResponse.detail, json: {} };
if (result?.detail) {
return { stream: result.detail, json: {}, executionId };
}

if (jsonResponse?.message) {
const report = JSON.parse(jsonResponse?.message?.messages);
return { stream: streamResponse, json: { messages: report } };
if (result?.messages) {
const report = JSON.parse(result.messages);
return {
stream: streamResponse,
json: { messages: report },
executionId,
};
}

return { stream: streamResponse, json: { messages: {} } };
return {
stream: streamResponse,
json: { messages: {} },
executionId,
};
} catch (error) {
throw new Error(`"Agencia's server error": ${error}`);
}
}

private validateSessionId(sessionId: string): void {
// Allow only a safe subset of characters for session identifiers.
// Adjust this regex to match the actual expected format if needed.
const SESSION_ID_REGEX = /^[A-Za-z0-9_-]+$/;
if (
typeof sessionId !== "string" ||
sessionId.length === 0 ||
sessionId.length > 256 ||
!SESSION_ID_REGEX.test(sessionId)
) {
throw new Error("Invalid sessionId");
}
}

private validateExecutionId(executionId: string): void {
// Allow only a safe subset of characters for execution identifiers.
const EXECUTION_ID_REGEX = /^[A-Za-z0-9_-]+$/;
if (
typeof executionId !== "string" ||
executionId.length === 0 ||
executionId.length > 256 ||
!EXECUTION_ID_REGEX.test(executionId)
) {
throw new Error("Invalid executionId");
}
}

/**
* List all executions for a given session.
* Calls GET /executions/{session_id} on Agencia.
*/
async getExecutions(sessionId: string): Promise<any> {
try {
this.validateSessionId(sessionId);
const safeSessionId = encodeURIComponent(sessionId);
const agenciaAccessToken = this.getAgenciaToken();

const response = await fetch(
`${this.agenciaURL}/executions/${safeSessionId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${agenciaAccessToken}`,
},
}
);

if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Agencia returned HTTP ${response.status}: ${errorBody.substring(0, 200)}`
);
}

return await response.json();
} catch (error) {
this.logger.error(
`Failed to fetch executions for session ${sessionId}: ${error}`
);
throw new Error(`"Agencia's server error": ${error}`);
}
}

/**
* Get a specific execution by session and execution ID.
* Calls GET /executions/{session_id}/{execution_id} on Agencia.
*/
async getExecution(
sessionId: string,
executionId: string
): Promise<any> {
try {
this.validateSessionId(sessionId);
this.validateExecutionId(executionId);
const safeSessionId = encodeURIComponent(sessionId);
const safeExecutionId = encodeURIComponent(executionId);
const agenciaAccessToken = this.getAgenciaToken();

const response = await fetch(
`${this.agenciaURL}/executions/${safeSessionId}/${safeExecutionId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${agenciaAccessToken}`,
},
}
);

if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Agencia returned HTTP ${response.status}: ${errorBody.substring(0, 200)}`
);
}

return await response.json();
} catch (error) {
this.logger.error(
`Failed to fetch execution ${executionId}: ${error}`
);
throw new Error(`"Agencia's server error": ${error}`);
}
}
}
105 changes: 80 additions & 25 deletions server/copilot/copilot-chat.controller.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,99 @@
/**
* Controller for Langchain Chat operations.
*
* Handles HTTP requests for context-aware chat interactions
* in the Langchain application. This controller is responsible for
* validating incoming request data and orchestrating chat interactions through the LangchainChatService.
* It supports endpoints for initiating context-aware chat
* and ensuring a versatile chat service experience.
*
* @class LangchainChatController
*
* @method contextAwareChat - Initiates a context-aware chat interaction. Accepts POST requests with a ContextAwareMessagesDto to manage chat context.
* Leverages LangchainChatService for processing.
* @param {ContextAwareMessagesDto} contextAwareMessagesDto - DTO for managing chat context.
* @returns Contextual chat response from the LangchainChatService.
*
* This controller uses decorators to define routes and their configurations, ensuring proper request handling and response formatting. It also integrates file upload handling for PDF documents, enabling document-context chat functionalities.
*/

import { Body, Controller, Post, Req } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Req,
} from "@nestjs/common";
import { CopilotChatService } from "./copilot-chat.service";
import { ContextAwareMessagesDto } from "./dtos/context-aware-messages.dto";
import {
CreateSessionDto,
SessionAgentChatDto,
} from "./dtos/context-aware-messages.dto";
import { FactCheckerOnly } from "../auth/decorators/auth.decorator";
import { CopilotSessionService } from "./copilot-session.service";
import { AutomatedFactCheckingService } from "../automated-fact-checking/automated-fact-checking.service";

@Controller()
export class CopilotChatController {
constructor(private readonly copilotChatService: CopilotChatService) {}
constructor(
private readonly copilotChatService: CopilotChatService,
private readonly copilotSessionService: CopilotSessionService,
private readonly automatedFactCheckingService: AutomatedFactCheckingService
) {}

@FactCheckerOnly()
@Get("api/copilot-session")
async getSession(
@Query("claimReviewDataHash") claimReviewDataHash: string,
@Req() req
) {
const session = await this.copilotSessionService.getActiveSession(
req.user._id,
claimReviewDataHash
);
return { session };
}

@FactCheckerOnly()
@Post("api/copilot-session")
async createSession(
@Body() createSessionDto: CreateSessionDto,
@Req() req
) {
const session = await this.copilotSessionService.createSession(
req.user._id,
createSessionDto.claimReviewDataHash,
createSessionDto.context
);
return { session };
}

@FactCheckerOnly()
@Post("api/copilot-session/:id/clear")
async clearSession(@Param("id") id: string) {
await this.copilotSessionService.deactivateSession(id);
return { success: true };
}

@FactCheckerOnly()
@Post("api/agent-chat")
async agentChat(
@Body() contextAwareMessagesDto: ContextAwareMessagesDto,
@Body() sessionAgentChatDto: SessionAgentChatDto,
@Req() req
) {
try {
return await this.copilotChatService.agentChat(
contextAwareMessagesDto,
req.language
sessionAgentChatDto,
req.language,
req.user._id
);
} catch (e) {
throw new Error(e);
}
}

@FactCheckerOnly()
@Get("api/copilot-session/:sessionId/executions")
async getExecutions(@Param("sessionId") sessionId: string) {
const executions =
await this.automatedFactCheckingService.getExecutions(sessionId);
return { executions };
}

@FactCheckerOnly()
@Get("api/copilot-session/:sessionId/executions/:executionId")
async getExecution(
@Param("sessionId") sessionId: string,
@Param("executionId") executionId: string
) {
const execution =
await this.automatedFactCheckingService.getExecution(
sessionId,
executionId
);
return { execution };
}
}
Loading
Loading