apps/recallassess/recallassess-api/src/api/integration/crai-bat-regeneration.service.ts
CraiBatRegenerationService
Re-sends a participant's BAT data to the external CRAI (CodeRythm) API
(POST {CRAI_API_URL}/generate-feedback) so the AI feedback/analysis is
regenerated. CRAI later calls back into /api/integration/cr/* which stores
ai_analysis / post_ai_analysis and, from there, feeds the BAT PDFs.
This is the single shared implementation used by both the participant portal ("Regenerate" on My Course) and the admin panel ("Regenerate" on a participant) so the two flows can never drift apart.
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, assessmentService: CLAssessmentService, httpService: HttpService, configService: ConfigService, http500Notification: BNestSystemLogHttp500NotificationService)
|
||||||||||||||||||
|
Parameters :
|
| Private buildPostBatPayload | ||||||
buildPostBatPayload(fullBatData: FormattedBatData)
|
||||||
|
Send the full pre+post payload flagged as POST_BAT (same as the POST_BAT completion listener).
Parameters :
Returns :
FormattedBatData
|
| Private buildPreBatOnlyPayload | ||||||
buildPreBatOnlyPayload(fullBatData: FormattedBatData)
|
||||||
|
Strip the payload down to PRE-BAT only: keep PRE answers, clear POST answers, and zero out the POST quotient/change so CRAI only sees pre-training data.
Parameters :
Returns :
FormattedBatData
|
| Async regenerateBatAnalysis | ||||||||||||||||
regenerateBatAnalysis(learningGroupId: number, participantId: number, position: BatPosition)
|
||||||||||||||||
|
Regenerate the CRAI AI feedback for a participant's PRE or POST BAT. The returned shape mirrors the participant portal endpoint: HTTP 200 with
Parameters :
Returns :
Promise<literal type>
|
| Private Readonly craiApiToken |
Type : string
|
| Private Readonly craiApiUrl |
Type : string
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(CraiBatRegenerationService.name)
|
import { CLAssessmentService } from "@api/client/assessment/assessment.service";
import { BNestPrismaService } from "@bish-nest/prisma";
import { BNestSystemLogHttp500NotificationService } from "@bish-nest/system-log";
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma, SysmtemLogOperationType, SystemLogEntityType } from "@prisma/client";
import { RECALLASSESS_DEPLOYMENT_URLS } from "@recallassess/shared-ng";
import { firstValueFrom } from "rxjs";
/** Which BAT assessment the regenerated feedback belongs to. */
export type BatPosition = "PRE_BAT" | "POST_BAT";
/** Shape returned by {@link CLAssessmentService.formatPrePostBatDataForIntegration}. */
type FormattedBatData = Awaited<ReturnType<CLAssessmentService["formatPrePostBatDataForIntegration"]>>;
/**
* CraiBatRegenerationService
*
* Re-sends a participant's BAT data to the external CRAI (CodeRythm) API
* (`POST {CRAI_API_URL}/generate-feedback`) so the AI feedback/analysis is
* regenerated. CRAI later calls back into `/api/integration/cr/*` which stores
* `ai_analysis` / `post_ai_analysis` and, from there, feeds the BAT PDFs.
*
* This is the single shared implementation used by both the participant portal
* ("Regenerate" on My Course) and the admin panel ("Regenerate" on a participant)
* so the two flows can never drift apart.
*/
@Injectable()
export class CraiBatRegenerationService {
private readonly logger = new Logger(CraiBatRegenerationService.name);
private readonly craiApiUrl: string;
private readonly craiApiToken: string;
constructor(
private readonly prisma: BNestPrismaService,
private readonly assessmentService: CLAssessmentService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly http500Notification: BNestSystemLogHttp500NotificationService,
) {
this.craiApiUrl =
this.configService.get<string>("CRAI_API_URL") || RECALLASSESS_DEPLOYMENT_URLS.uat.craiApiBaseUrl;
this.craiApiToken = this.configService.get<string>("CRAI_API_TOKEN") || "";
}
/**
* Regenerate the CRAI AI feedback for a participant's PRE or POST BAT.
*
* The returned shape mirrors the participant portal endpoint: HTTP 200 with
* `{ success, message }` (CRAI failures are surfaced as `success: false`
* rather than thrown, so the caller can show a friendly message).
*
* @param learningGroupId Learning group the participant belongs to.
* @param participantId Participant whose feedback should be regenerated.
* @param position `PRE_BAT` or `POST_BAT`.
*/
async regenerateBatAnalysis(
learningGroupId: number,
participantId: number,
position: BatPosition,
): Promise<{ success: boolean; message: string }> {
const label = position === "PRE_BAT" ? "PRE-BAT" : "POST-BAT";
let systemLogId: number | null = null;
let requestStartTime = 0;
try {
const fullBatData = await this.assessmentService.formatPrePostBatDataForIntegration(
learningGroupId,
participantId,
);
const batData =
position === "PRE_BAT" ? this.buildPreBatOnlyPayload(fullBatData) : this.buildPostBatPayload(fullBatData);
const apiUrl = `${this.craiApiUrl}/generate-feedback`;
const headers: { "Content-Type": string; Authorization?: string } = {
"Content-Type": "application/json",
};
// Create system log row for outbound audit, then update with status_code after the HTTP call.
try {
const createdLog = await this.prisma.client.systemLog.create({
data: {
entity_type: SystemLogEntityType.LEARNING_GROUP,
operation_type: SysmtemLogOperationType.EXPORT,
learning_group_id: learningGroupId,
participant_id: participantId,
request_body: batData as Prisma.InputJsonValue,
request_endpoint: apiUrl,
request_method: "POST",
timestamp: new Date(),
},
});
systemLogId = createdLog.id;
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : String(logErr);
this.logger.warn(`Failed to create outbound system log (non-fatal): ${msg}`);
}
if (this.craiApiToken) {
headers.Authorization = `Bearer ${this.craiApiToken}`;
}
this.logger.log(
`[GENERATE_FEEDBACK][${label}] sending regenerate request lg=${learningGroupId} participant=${participantId}`,
);
requestStartTime = Date.now();
const response = await firstValueFrom(
this.httpService.post(apiUrl, batData, {
headers,
timeout: 30000, // 30 second timeout
}),
);
const requestDuration = Date.now() - requestStartTime;
if (systemLogId !== null) {
try {
await this.prisma.client.systemLog.update({
where: { id: systemLogId },
data: {
status_code: response.status,
response_time_ms: requestDuration,
error_message: null,
},
});
} catch (updateErr) {
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
this.logger.warn(`Failed to update outbound system log status_code (non-fatal): ${msg}`);
}
}
this.logger.log(
`[GENERATE_FEEDBACK][${label}] succeeded lg=${learningGroupId} participant=${participantId} status=${response.status} duration=${requestDuration}ms`,
);
// Return the actual CR API success message
const message =
response.data?.message || response.data?.status || `${label} analysis regeneration triggered successfully`;
return {
success: true,
message,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`[GENERATE_FEEDBACK][${label}] failed lg=${learningGroupId} participant=${participantId}: ${message}`,
error instanceof Error ? error.stack : undefined,
);
// Prefer CRAI body (FastAPI uses `detail`; others use message/error), else generic fallback.
let errorMessage = `Failed to regenerate ${label} analysis. Please try again later.`;
let statusCode: number | null = null;
const requestDuration = requestStartTime ? Date.now() - requestStartTime : null;
if (error && typeof error === "object" && "response" in error) {
const httpError = error as {
response?: { status?: number; data?: { detail?: unknown; message?: string; error?: string } | string };
};
statusCode = httpError.response?.status ?? null;
const data = httpError.response?.data;
if (typeof data === "string" && data.trim()) {
errorMessage = data.trim();
} else if (data && typeof data === "object") {
if (typeof data.detail === "string" && data.detail.trim()) {
errorMessage = data.detail.trim();
} else if (Array.isArray(data.detail) && data.detail.length > 0) {
// FastAPI validation errors: [{ loc, msg, type }, ...]
errorMessage = JSON.stringify(data.detail);
} else if (typeof data.message === "string" && data.message.trim()) {
errorMessage = data.message.trim();
} else if (typeof data.error === "string" && data.error.trim()) {
errorMessage = data.error.trim();
} else {
errorMessage = JSON.stringify(data);
}
}
}
if (systemLogId !== null) {
try {
await this.prisma.client.systemLog.update({
where: { id: systemLogId },
data: {
status_code: statusCode,
response_time_ms: requestDuration,
error_message: errorMessage,
},
});
} catch (updateErr) {
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
this.logger.warn(`Failed to update outbound system log error fields (non-fatal): ${msg}`);
}
// Same alert path as inbound HTTP 500 system logs (email.errorNotificationRecipients).
if (statusCode === 500) {
void this.http500Notification.notifyForLogIds([systemLogId]).catch((err) => {
this.logger.warn(
`HTTP 500 system log email notification failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
}
}
// Don't throw - CRAI API failure should not affect the main flow
return {
success: false,
message: errorMessage,
};
}
}
/**
* Strip the payload down to PRE-BAT only: keep PRE answers, clear POST answers,
* and zero out the POST quotient/change so CRAI only sees pre-training data.
*/
private buildPreBatOnlyPayload(fullBatData: FormattedBatData): FormattedBatData {
return {
...fullBatData,
metadata: {
...fullBatData.metadata,
plj_position: "PRE_BAT" as const,
},
data: fullBatData.data.map((participant) => {
const modules: FormattedBatData["data"][number]["modules"] = {};
Object.entries(participant.modules).forEach(([code, moduleData]) => {
modules[code] = {
...moduleData,
// Keep PRE_BAT answers, clear POST_BAT so CR only sees PRE data
PRE_BAT: moduleData.PRE_BAT,
POST_BAT: [],
};
});
return {
...participant,
// Keep PRE quotient, clear POST/change to avoid confusion
metadata: {
...participant.metadata,
individual_quotient_post: 0,
individual_quotient_change: 0,
},
modules,
};
}),
};
}
/**
* Send the full pre+post payload flagged as POST_BAT (same as the
* POST_BAT completion listener).
*/
private buildPostBatPayload(fullBatData: FormattedBatData): FormattedBatData {
return {
...fullBatData,
metadata: {
...fullBatData.metadata,
plj_position: "POST_BAT" as const,
},
};
}
}