File

apps/recallassess/recallassess-api/src/api/integration/crai-bat-regeneration.service.ts

Description

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.

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, assessmentService: CLAssessmentService, httpService: HttpService, configService: ConfigService, http500Notification: BNestSystemLogHttp500NotificationService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
assessmentService CLAssessmentService No
httpService HttpService No
configService ConfigService No
http500Notification BNestSystemLogHttp500NotificationService No

Methods

Private buildPostBatPayload
buildPostBatPayload(fullBatData: FormattedBatData)

Send the full pre+post payload flagged as POST_BAT (same as the POST_BAT completion listener).

Parameters :
Name Type Optional
fullBatData FormattedBatData No
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 :
Name Type Optional
fullBatData FormattedBatData No
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 { success, message } (CRAI failures are surfaced as success: false rather than thrown, so the caller can show a friendly message).

Parameters :
Name Type Optional Description
learningGroupId number No

Learning group the participant belongs to.

participantId number No

Participant whose feedback should be regenerated.

position BatPosition No

PRE_BAT or POST_BAT.

Returns : Promise<literal type>

Properties

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,
      },
    };
  }
}

results matching ""

    No results matching ""