import { Investigation } from "@models/investigation/investigation.model";
import { Metadata } from "@models/Metadata.model";
import { Prompt } from "@models/Prompt.model";
import { getLogger } from "@utils/asyncLocalStorage";
import yamlModule from "js-yaml";
import { getInvestigationById, updateInvestigation } from "./crud";
import { updateInvestigationByVersion } from "./versions";
import { convertInvestigationFromModel } from "../../helpers/investigation";
import { AssistantInvestigationFormat } from "../../schemas/assistant.validation";
/**
* Regenerates text fields (without steps and objects) based on a specific field value.
* This is Step 1 of the 3-step regeneration process.
* @category Services
* @param {AIProcessor} aiProcessor - AI processor instance.
* @param {string} fieldName - The name of the field to base regeneration on.
* @param {string} fieldValue - The value of the field to base regeneration on.
* @param {string} investigation - The current investigation in YAML format.
* @param {string} investigationMetadata - The metadata/guide in YAML format.
* @param {string} [history] - Chat history in YAML format (optional, defaults to "-").
* @param {object} [contradictionInfo] - Optional contradiction information.
* @param {string | null} [contradictionInfo.contradictionReason] - Reason for contradiction.
* @param {string | null} [contradictionInfo.targetFieldName] - Target field name for contradiction.
* @param {boolean} [contradictionInfo.isContradicting] - Whether the field is contradicting.
* @returns {Promise<IAssistantInvestigationFormat | null>} - The regenerated text fields only.
* @throws {Error} If the prompt is not found or regeneration fails.
*/
export async function regenerateOtherFieldsWithoutStepsAndObjects(aiProcessor, fieldName, fieldValue, investigation, investigationMetadata, history = "-", contradictionInfo) {
const logger = getLogger();
try {
logger.info(`Regenerating text fields (without steps and objects) based on ${fieldName}`);
// Try to use dedicated prompt, fallback to existing prompt
let prompt = await Prompt.findOne({
name: "regenerate_other_fields_text_only",
});
if (!prompt) {
prompt = await Prompt.findOne({
name: "generate_investigation_without_steps_and_objects",
});
if (!prompt) {
throw new Error("generate_investigation_without_steps_and_objects prompt not found.");
}
}
// Build prompt template - replace ALL occurrences of placeholders
let promptTemplate = prompt.template
.replaceAll("{guide}", investigationMetadata)
.replaceAll("{history}", history || "-")
.replaceAll("{fieldName}", fieldName)
.replaceAll("{fieldValue}", fieldValue);
// Only replace contradiction placeholders if there's actually a contradiction
if (contradictionInfo && contradictionInfo.isContradicting) {
promptTemplate = promptTemplate
.replaceAll("{contradictionReason}", contradictionInfo.contradictionReason || "-")
.replaceAll("{targetFieldName}", contradictionInfo.targetFieldName || "-")
.replaceAll("{isContradicting}", "true");
}
else {
// No contradiction - replace with empty/neutral values
promptTemplate = promptTemplate
.replaceAll("{contradictionReason}", "")
.replaceAll("{targetFieldName}", "")
.replaceAll("{isContradicting}", "false");
}
// If the prompt has {investigation} placeholder, replace ALL occurrences
if (promptTemplate.includes("{investigation}")) {
promptTemplate = promptTemplate.replaceAll("{investigation}", investigation);
}
let retry = 0;
let response = null;
while (retry < 3) {
response = (await aiProcessor.fetchLLMResponse(promptTemplate, AssistantInvestigationFormat));
if (!response) {
retry += 1;
continue;
}
const requiredTextFields = [
"title",
"curriculum",
"unitNumberAndTitle",
"grade",
"lessonNumberAndTitle",
"objectives",
"ngss",
"analyticalFacts",
"goals",
"day",
];
const hasMissingFields = requiredTextFields.some((field) => {
const value = response[field];
return value === undefined || value === null;
});
if (hasMissingFields) {
retry += 1;
}
else {
break;
}
}
if (retry === 3 || !response) {
throw new Error(`Failed to regenerate text fields based on ${fieldName}. Response is: ${JSON.stringify(response)}.`);
}
logger.info(`Response for regenerating text fields is: ${JSON.stringify(response)}`);
return response;
}
catch (error) {
logger.error({
message: `Failed to regenerate text fields based on ${fieldName}.`,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
}
/**
* Regenerates objects based on regenerated text fields.
* This is Step 2 of the 3-step regeneration process.
* @category Services
* @param {AIProcessor} aiProcessor - AI processor instance.
* @param {string} fieldName - The name of the field to base regeneration on.
* @param {string} fieldValue - The value of the field to base regeneration on.
* @param {string} investigation - The regenerated investigation from Step 1 (YAML format).
* @param {string} investigationMetadata - The metadata/guide in YAML format.
* @param {string} [history] - Chat history in YAML format (optional, defaults to "-").
* @param {object} [contradictionInfo] - Optional contradiction information.
* @param {string | null} [contradictionInfo.contradictionReason] - Reason for contradiction.
* @param {string | null} [contradictionInfo.targetFieldName] - Target field name for contradiction.
* @param {boolean} [contradictionInfo.isContradicting] - Whether the field is contradicting.
* @returns {Promise<IAssistantInvestigationFormat | null>} - The regenerated objects only.
* @throws {Error} If the prompt is not found or regeneration fails.
*/
export async function regenerateOtherFieldsObjects(aiProcessor, fieldName, fieldValue, investigation, investigationMetadata, history = "-", contradictionInfo) {
const logger = getLogger();
try {
logger.info(`Regenerating objects based on ${fieldName}`);
// Try to use dedicated prompt, fallback to existing prompt
let prompt = await Prompt.findOne({
name: "regenerate_other_fields_objects",
});
if (!prompt) {
prompt = await Prompt.findOne({
name: "generate_investigation_objects",
});
if (!prompt) {
throw new Error("generate_investigation_objects prompt not found.");
}
}
// Build prompt template - replace ALL occurrences of placeholders
let promptTemplate = prompt.template
.replaceAll("{guide}", investigationMetadata)
.replaceAll("{history}", history || "-")
.replaceAll("{fieldName}", fieldName)
.replaceAll("{fieldValue}", fieldValue);
// Only replace contradiction placeholders if there's actually a contradiction
if (contradictionInfo && contradictionInfo.isContradicting) {
promptTemplate = promptTemplate
.replaceAll("{contradictionReason}", contradictionInfo.contradictionReason || "-")
.replaceAll("{targetFieldName}", contradictionInfo.targetFieldName || "-")
.replaceAll("{isContradicting}", "true");
}
else {
// No contradiction - replace with empty/neutral values
promptTemplate = promptTemplate
.replaceAll("{contradictionReason}", "")
.replaceAll("{targetFieldName}", "")
.replaceAll("{isContradicting}", "false");
}
// If the prompt has {investigation} placeholder, replace ALL occurrences
if (promptTemplate.includes("{investigation}")) {
promptTemplate = promptTemplate.replaceAll("{investigation}", investigation);
}
try {
const parsedInvestigation = yamlModule.load(investigation);
const existingObjectsCount = parsedInvestigation?.objects?.length || 0;
if (existingObjectsCount > 0) {
const objectCountInstruction = `\n\n### CRITICAL REQUIREMENT:\nYou MUST return EXACTLY ${existingObjectsCount} objects in your response. The investigation context shows ${existingObjectsCount} existing objects, and you MUST regenerate and return ALL ${existingObjectsCount} objects. Do NOT return fewer objects.\n`;
promptTemplate = promptTemplate + objectCountInstruction;
logger.info({
fieldName,
existingObjectsCount,
}, "Added explicit instruction to return all objects");
}
}
catch (parseError) {
logger.warn({ fieldName, error: parseError }, "Failed to parse investigation YAML to count objects, continuing without object count instruction");
}
let retry = 0;
let response = null;
while (retry < 3) {
response = (await aiProcessor.fetchLLMResponse(promptTemplate, AssistantInvestigationFormat));
// Validate response has objects
if (response.objects === undefined || response.objects === null) {
retry += 1;
}
else {
break;
}
}
if (retry === 3 || !response) {
throw new Error(`Failed to regenerate objects based on ${fieldName}. Response is: ${JSON.stringify(response)}.`);
}
logger.info(`Response for regenerating objects is: ${JSON.stringify(response)}`);
return response;
}
catch (error) {
logger.error({
message: `Failed to regenerate objects based on ${fieldName}.`,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
}
/**
* Regenerates steps based on regenerated text fields and objects.
* This is Step 3 of the 3-step regeneration process.
* @category Services
* @param {AIProcessor} aiProcessor - AI processor instance.
* @param {string} fieldName - The name of the field to base regeneration on.
* @param {string} fieldValue - The value of the field to base regeneration on.
* @param {string} investigation - The regenerated investigation from Step 1 + Step 2 (YAML format).
* @param {string} investigationMetadata - The metadata/guide in YAML format.
* @param {string} [history] - Chat history in YAML format (optional, defaults to "-").
* @param {object} [contradictionInfo] - Optional contradiction information.
* @param {string | null} [contradictionInfo.contradictionReason] - Reason for contradiction.
* @param {string | null} [contradictionInfo.targetFieldName] - Target field name for contradiction.
* @param {boolean} [contradictionInfo.isContradicting] - Whether the field is contradicting.
* @returns {Promise<IAssistantInvestigationFormat | null>} - The regenerated steps only.
* @throws {Error} If the prompt is not found or regeneration fails.
*/
export async function regenerateOtherFieldsSteps(aiProcessor, fieldName, fieldValue, investigation, investigationMetadata, history = "-", contradictionInfo) {
const logger = getLogger();
try {
logger.info(`Regenerating steps based on ${fieldName}`);
// Try to use dedicated prompt, fallback to existing prompt
let prompt = await Prompt.findOne({
name: "regenerate_other_fields_steps",
});
if (!prompt) {
prompt = await Prompt.findOne({
name: "generate_investigation_steps",
});
if (!prompt) {
throw new Error("generate_investigation_steps prompt not found.");
}
}
// Build prompt template - replace ALL occurrences of placeholders
let promptTemplate = prompt.template
.replaceAll("{guide}", investigationMetadata)
.replaceAll("{history}", history || "-")
.replaceAll("{fieldName}", fieldName)
.replaceAll("{fieldValue}", fieldValue);
// Only replace contradiction placeholders if there's actually a contradiction
if (contradictionInfo && contradictionInfo.isContradicting) {
promptTemplate = promptTemplate
.replaceAll("{contradictionReason}", contradictionInfo.contradictionReason || "-")
.replaceAll("{targetFieldName}", contradictionInfo.targetFieldName || "-")
.replaceAll("{isContradicting}", "true");
}
else {
// No contradiction - replace with empty/neutral values
promptTemplate = promptTemplate
.replaceAll("{contradictionReason}", "")
.replaceAll("{targetFieldName}", "")
.replaceAll("{isContradicting}", "false");
}
// If the prompt has {investigation} placeholder, replace ALL occurrences
if (promptTemplate.includes("{investigation}")) {
promptTemplate = promptTemplate.replaceAll("{investigation}", investigation);
}
try {
const parsedInvestigation = yamlModule.load(investigation);
const existingStepsCount = parsedInvestigation?.steps?.length || 0;
if (existingStepsCount > 0) {
const stepCountInstruction = `\n\n### CRITICAL REQUIREMENT:\nYou MUST return EXACTLY ${existingStepsCount} steps in your response. The investigation context shows ${existingStepsCount} existing steps, and you MUST regenerate and return ALL ${existingStepsCount} steps. Do NOT return fewer steps.\n`;
promptTemplate = promptTemplate + stepCountInstruction;
logger.info({
fieldName,
existingStepsCount,
}, "Added explicit instruction to return all steps");
}
}
catch (parseError) {
logger.warn({ fieldName, error: parseError }, "Failed to parse investigation YAML to count steps, continuing without step count instruction");
}
let retry = 0;
let response = null;
while (retry < 3) {
response = (await aiProcessor.fetchLLMResponse(promptTemplate, AssistantInvestigationFormat));
// Validate response has steps
if (response.steps === undefined || response.steps === null) {
retry += 1;
}
else {
break;
}
}
if (retry === 3 || !response) {
throw new Error(`Failed to regenerate steps based on ${fieldName}. Response is: ${JSON.stringify(response)}.`);
}
logger.info(`Response for regenerating steps is: ${JSON.stringify(response)}`);
return response;
}
catch (error) {
logger.error({
message: `Failed to regenerate steps based on ${fieldName}.`,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
}
/**
* Regenerates other fields in an investigation based on a specific field.
* This operation uses a 3-step process: text fields → objects → steps.
* @category Services
* @param {AIProcessor} aiProcessor - AI processor instance.
* @param {string} investigationId - The ID of the investigation to update.
* @param {string} fieldName - The name of the field to base regeneration on (e.g., "title", "curriculum", "objectives").
* @param {Types.ObjectId | null} updatedBy - The user making the change (optional).
* @param {string} [history] - Chat history in YAML format (optional, defaults to "-").
* @returns {Promise<IInvestigation>} - The updated investigation.
* @throws {Error} If the investigation is not found or the field is invalid.
*/
export async function regenerateOtherFields(aiProcessor, investigationId, fieldName, updatedBy = null, history = "-") {
const logger = getLogger();
try {
logger.info({ investigationId, fieldName }, "Regenerating other fields based on field");
// Get the investigation (version-aware - respects currentVersionIndex)
const investigation = await getInvestigationById(investigationId);
if (!investigation) {
throw new Error("Investigation not found");
}
// Validate field name and get field value
const validBlockFields = [
"title",
"curriculum",
"unitNumberAndTitle",
"grade",
"lessonNumberAndTitle",
"objectives",
"ngss",
"analyticalFacts",
"goals",
"day",
];
const validStepFields = ["title", "descriptionEn", "desiredOutcome", "alternativeOutcome"];
const validObjectFields = ["name"];
let fieldValue;
let contradictionReason = null;
let targetFieldName = null;
let isContradicting = false;
// Check if it's a step field (format: steps.0.title, steps.1.descriptionEn, etc.)
const stepFieldMatch = fieldName.match(/^steps\.(\d+)\.(.+)$/);
if (stepFieldMatch) {
const stepIndexStr = stepFieldMatch[1];
const stepFieldName = stepFieldMatch[2];
if (!stepIndexStr || !stepFieldName) {
throw new Error(`Invalid step field format: ${fieldName}`);
}
const stepIndex = parseInt(stepIndexStr, 10);
if (!validStepFields.includes(stepFieldName)) {
throw new Error(`Invalid step field name: ${stepFieldName}. Valid step fields are: ${validStepFields.join(", ")}`);
}
const steps = investigation.steps || [];
if (stepIndex < 0 || stepIndex >= steps.length) {
throw new Error(`Step index ${stepIndex} is out of range. Investigation has ${steps.length} steps.`);
}
const step = steps[stepIndex];
if (!step) {
throw new Error(`Step at index ${stepIndex} not found`);
}
// Get the step field (step fields are stored as IEditableField in the model)
const stepModel = investigation.steps?.[stepIndex];
const stepField = stepModel?.[stepFieldName];
if (!stepField || !stepField.value) {
throw new Error(`Step field ${fieldName} has no value to base regeneration on`);
}
fieldValue = stepField.value;
contradictionReason = stepField.contradictionReason || null;
targetFieldName = stepField.targetFieldName || null;
isContradicting = stepField.isContradicting || false;
}
else if (fieldName === "name" || /^objects\.\d+\.name$/.test(fieldName)) {
// Object name field (supports legacy "name" or indexed "objects.{index}.name")
const objects = investigation.objects || [];
if (objects.length === 0) {
throw new Error("No objects found in investigation to base regeneration on");
}
if (fieldName === "name") {
// Legacy behavior: use first object
const firstObject = objects[0];
if (!firstObject || !firstObject.name || !firstObject.name.value) {
throw new Error("First object has no name to base regeneration on");
}
const objectNameField = firstObject.name;
fieldValue = objectNameField.value;
contradictionReason = objectNameField.contradictionReason || null;
targetFieldName = objectNameField.targetFieldName || null;
isContradicting = objectNameField.isContradicting || false;
}
else {
const match = fieldName.match(/^objects\.(\d+)\.(name)$/);
if (!match) {
throw new Error(`Invalid object field format: ${fieldName}. Expected objects.{index}.name`);
}
const objectIndex = parseInt(match[1], 10);
const objectFieldName = match[2];
if (!validObjectFields.includes(objectFieldName)) {
throw new Error(`Invalid object field name: ${objectFieldName}. Valid object fields are: ${validObjectFields.join(", ")}`);
}
if (objectIndex < 0 || objectIndex >= objects.length) {
throw new Error(`Object index ${objectIndex} is out of range. Investigation has ${objects.length} objects.`);
}
const object = objects[objectIndex];
const objectField = object?.[objectFieldName];
if (!objectField ||
typeof objectField !== "object" ||
!("value" in objectField) ||
!objectField.value) {
throw new Error(`Object field ${fieldName} has no value to base regeneration on`);
}
const editableField = objectField;
fieldValue = editableField.value;
contradictionReason = editableField.contradictionReason || null;
targetFieldName = editableField.targetFieldName || null;
isContradicting = editableField.isContradicting || false;
}
}
else {
// Block field
if (!validBlockFields.includes(fieldName)) {
throw new Error(`Invalid field name: ${fieldName}. Valid block fields are: ${validBlockFields.join(", ")}, valid step fields are: steps.{index}.{field}, valid object fields are: objects.{index}.name`);
}
// Get the field value and contradiction information
const field = investigation[fieldName];
if (!field || !field.value) {
throw new Error(`Field ${fieldName} has no value to base regeneration on`);
}
fieldValue = field.value;
contradictionReason = field.contradictionReason || null;
targetFieldName = field.targetFieldName || null;
isContradicting = field.isContradicting || false;
}
// Get metadata for the investigation
const unit = investigation.unitNumberAndTitle?.value?.split(":")[0]?.split(" ")[1];
const lesson = investigation.lessonNumberAndTitle?.value?.split(":")[0]?.split(" ")[1] || "0";
const lessonNumber = parseInt(lesson, 10);
const metadata = await Metadata.findOne({
unit: unit,
lessonNumber: Number.isNaN(lessonNumber) ? undefined : lessonNumber,
});
let investigationMetadataYaml = " ";
if (metadata) {
const yaml = yamlModule;
const investigationMetadataObject = metadata.toObject({
versionKey: false,
transform: (doc, ret) => {
delete ret._id;
return ret;
},
});
investigationMetadataYaml = yaml.dump(investigationMetadataObject);
}
// Convert current investigation to simple format for context
const currentInvestigation = convertInvestigationFromModel(investigation);
const yaml = yamlModule;
// Prepare contradiction info - only pass if field is actually contradicting
const contradictionInfo = isContradicting
? {
contradictionReason,
targetFieldName,
isContradicting,
}
: undefined;
const textFieldsOnly = {
title: currentInvestigation.title,
curriculum: currentInvestigation.curriculum,
unitNumberAndTitle: currentInvestigation.unitNumberAndTitle,
grade: currentInvestigation.grade,
lessonNumberAndTitle: currentInvestigation.lessonNumberAndTitle,
objectives: currentInvestigation.objectives,
ngss: currentInvestigation.ngss,
analyticalFacts: currentInvestigation.analyticalFacts,
goals: currentInvestigation.goals,
day: currentInvestigation.day,
};
logger.info({ investigationId, fieldName }, "Step 1: Regenerating text fields");
let regeneratedInvestigation = (await regenerateOtherFieldsWithoutStepsAndObjects(aiProcessor, fieldName, fieldValue, yaml.dump(textFieldsOnly), investigationMetadataYaml, history || "-", contradictionInfo));
const investigationWithExistingObjects = {
...regeneratedInvestigation,
objects: currentInvestigation.objects || [],
};
logger.info({
investigationId,
fieldName,
existingObjectsCount: investigationWithExistingObjects.objects?.length || 0,
}, "Step 2: Regenerating objects (with existing objects in context)");
const regeneratedObjects = await regenerateOtherFieldsObjects(aiProcessor, fieldName, fieldValue, yaml.dump(investigationWithExistingObjects), investigationMetadataYaml, history || "-", contradictionInfo);
regeneratedInvestigation.objects = regeneratedObjects?.objects;
const investigationWithExistingSteps = {
...regeneratedInvestigation,
steps: currentInvestigation.steps || [],
};
logger.info({
investigationId,
fieldName,
existingStepsCount: investigationWithExistingSteps.steps?.length || 0,
}, "Step 3: Regenerating steps (with existing steps in context)");
const regeneratedSteps = await regenerateOtherFieldsSteps(aiProcessor, fieldName, fieldValue, yaml.dump(investigationWithExistingSteps), investigationMetadataYaml, history || "-", contradictionInfo);
regeneratedInvestigation.steps = regeneratedSteps?.steps;
logger.info({ investigationId, fieldName }, "All 3 steps completed. Building update DTO");
// Build update DTO - exclude the field we're basing regeneration on
const updateDto = {};
// Extract values from regenerated investigation
const responseData = regeneratedInvestigation;
logger.info({
investigationId,
fieldName,
responseDataKeys: Object.keys(responseData),
objectivesValue: responseData.objectives,
}, "Building update DTO from regenerated investigation");
const textFields = [
"title",
"curriculum",
"grade",
"unitNumberAndTitle",
"lessonNumberAndTitle",
"objectives",
"ngss",
"analyticalFacts",
"goals",
"day",
];
for (const field of textFields) {
// Explicitly skip if this field matches the fieldName used for regeneration
if (fieldName === field) {
logger.debug({ investigationId, fieldName, field }, "Skipping field in update DTO - matches regeneration source field");
continue;
}
const value = responseData[field];
if (value !== undefined && value !== null) {
updateDto[field] = value;
}
}
logger.info({
investigationId,
fieldName,
updateDtoKeys: Object.keys(updateDto),
hasObjectives: "objectives" in updateDto,
}, "Update DTO built");
let hasChanges = false;
const changedFields = [];
for (const field of textFields) {
const updateValue = updateDto[field];
if (updateValue !== undefined) {
const currentValue = investigation[field];
if (updateValue !== currentValue?.value) {
hasChanges = true;
changedFields.push(field);
}
}
}
if (updateDto.steps && Array.isArray(updateDto.steps) && updateDto.steps.length > 0) {
const existingSteps = investigation.steps || [];
// Check if fieldName is a step field (e.g., "steps.0.title")
const stepFieldMatch = fieldName.match(/^steps\.(\d+)\.(\w+)$/);
let stepIndexToSkip = null;
let stepFieldToSkip = null;
if (stepFieldMatch) {
stepIndexToSkip = parseInt(stepFieldMatch[1], 10);
stepFieldToSkip = stepFieldMatch[2];
}
logger.info({ stepFieldMatch, stepIndexToSkip, stepFieldToSkip });
// Check each step by index (steps format: [{ title?, descriptionEn?, ... }])
for (const [index, step] of updateDto.steps.entries()) {
const existingStep = existingSteps[index];
// Check each field in the step dynamically
for (const fieldKey in step) {
// Skip updating the step field that was used as regeneration source
if (stepIndexToSkip !== null &&
index === stepIndexToSkip &&
stepFieldToSkip === fieldKey) {
logger.debug({ investigationId, fieldName, stepIndex: index, stepField: fieldKey }, "Skipping step field update - matches regeneration source field");
continue;
}
const updateValue = step[fieldKey];
// Skip if field value is undefined or null
if (updateValue === undefined || updateValue === null) {
continue;
}
// Compare with existing value
const currentValue = existingStep?.[fieldKey]?.value;
if (updateValue !== currentValue) {
hasChanges = true;
const fieldPath = `steps[${index}].${fieldKey}`;
if (!changedFields.includes(fieldPath)) {
changedFields.push(fieldPath);
}
}
}
// If step is new (doesn't exist at this index), mark as changed
if (!existingStep) {
hasChanges = true;
changedFields.push(`steps[${index}]`);
}
}
}
if (updateDto.objects && Array.isArray(updateDto.objects) && updateDto.objects.length > 0) {
const existingObjects = investigation.objects || [];
// Check if fieldName is an object field (e.g., "objects.0.name" or legacy "name")
const match = fieldName.match(/^objects\.(\d+)\.name$/);
const objectIndexToSkip = fieldName === "name" ? 0 : match ? Number(match[1]) : null;
if (updateDto.objects.length !== existingObjects.length) {
hasChanges = true;
changedFields.push("objects");
}
else {
for (let i = 0; i < updateDto.objects.length; i++) {
// Skip updating the object that was used as regeneration source
if (objectIndexToSkip !== null && i === objectIndexToSkip) {
logger.debug({ investigationId, fieldName, objectIndex: i }, "Skipping object update - matches regeneration source field");
continue;
}
const regeneratedObject = updateDto.objects[i];
const existingObject = existingObjects[i];
if (regeneratedObject &&
existingObject &&
regeneratedObject.name !== existingObject.name?.value) {
hasChanges = true;
if (!changedFields.includes(`objects[${i}].name`)) {
changedFields.push(`objects[${i}].name`);
}
}
}
}
}
logger.info({
investigationId,
fieldName,
hasChanges,
changedFields,
totalChangedFields: changedFields.length,
}, "Comparison with current investigation completed");
if (responseData.steps && Array.isArray(responseData.steps) && responseData.steps.length > 0) {
const existingStepsCount = investigation.steps?.length || 0;
const regeneratedStepsCount = responseData.steps.length;
logger.info({
investigationId,
fieldName,
existingStepsCount,
regeneratedStepsCount,
allStepsRegenerated: regeneratedStepsCount >= existingStepsCount,
}, "Processing regenerated steps");
// Check if fieldName is a step field (e.g., "steps.0.title")
const stepFieldMatch = fieldName.match(/^steps\.(\d+)\.(\w+)$/);
let stepIndexToSkip = null;
let stepFieldToSkip = null;
if (stepFieldMatch) {
stepIndexToSkip = parseInt(stepFieldMatch[1], 10);
stepFieldToSkip = stepFieldMatch[2];
}
const mergedSteps = [];
const maxSteps = Math.max(regeneratedStepsCount, existingStepsCount);
for (let index = 0; index < maxSteps; index++) {
if (index < regeneratedStepsCount && responseData.steps[index]) {
const regeneratedStep = responseData.steps[index];
if (regeneratedStep) {
// If this step field matches the regeneration source, exclude that field
if (stepIndexToSkip !== null && index === stepIndexToSkip && stepFieldToSkip) {
const filteredStep = { ...regeneratedStep };
// Remove the field that matches the regeneration source
if (stepFieldToSkip in filteredStep) {
delete filteredStep[stepFieldToSkip];
logger.debug({ investigationId, fieldName, stepIndex: index, stepField: stepFieldToSkip }, "Excluding step field from merged steps - matches regeneration source field");
}
mergedSteps.push(filteredStep);
}
else {
mergedSteps.push(regeneratedStep);
}
}
}
else if (index < existingStepsCount) {
const existingStep = investigation.steps?.[index];
if (existingStep) {
mergedSteps.push({
title: existingStep.title?.value || "",
descriptionEn: existingStep.descriptionEn?.value || "",
desiredOutcome: existingStep.desiredOutcome?.value || "",
alternativeOutcome: existingStep.alternativeOutcome?.value || "",
skippable: existingStep.skippable?.value ?? null,
skippable_after_marking: existingStep.skippable_after_marking?.value ?? null,
});
}
}
}
if (regeneratedStepsCount < existingStepsCount) {
logger.warn({
investigationId,
fieldName,
existingStepsCount,
regeneratedStepsCount,
mergedStepsCount: mergedSteps.length,
}, "AI returned fewer steps than existing. Merged regenerated steps with existing steps.");
}
if (mergedSteps.length > 0) {
updateDto.steps = mergedSteps;
}
}
if (responseData.objects &&
Array.isArray(responseData.objects) &&
responseData.objects.length > 0) {
// Check if fieldName is an object field (e.g., "objects.0.name" or legacy "name")
const match = fieldName.match(/^objects\.(\d+)\.name$/);
const objectIndexToSkip = fieldName === "name" ? 0 : match ? Number(match[1]) : null;
const objectUpdates = [];
for (const [index, obj] of responseData.objects.entries()) {
if (!obj || typeof obj !== "object")
continue;
// Skip updating the object that was used as regeneration source
if (objectIndexToSkip !== null && index === objectIndexToSkip) {
const updateObj = investigation.objects?.[index];
logger.info({ updateObj }, "Update object ape jan");
if (updateObj) {
objectUpdates.push({
name: updateObj.name?.value
? { ...updateObj.name, value: updateObj.name.value }
: undefined,
objectId: updateObj.objectId?.value
? { ...updateObj.objectId, value: updateObj.objectId.value }
: undefined,
position: updateObj.position?.x?.value &&
updateObj.position?.y?.value &&
updateObj.position?.z?.value
? {
x: updateObj.position.x.value,
y: updateObj.position.y.value,
z: updateObj.position.z.value,
}
: undefined,
rotation: updateObj.rotation?.x?.value &&
updateObj.rotation?.y?.value &&
updateObj.rotation?.z?.value
? {
x: updateObj.rotation.x.value,
y: updateObj.rotation.y.value,
z: updateObj.rotation.z.value,
}
: undefined,
size: updateObj.size?.value ? updateObj.size.value : 1,
});
}
}
else {
const updateObj = {
name: obj.name ? { value: String(obj.name) } : undefined,
objectId: obj.objectId || obj.name
? { value: String(obj.objectId || obj.name || "") }
: undefined,
position: obj.position && typeof obj.position === "object"
? {
x: Number(obj.position.x) || 0,
y: Number(obj.position.y) || 0,
z: Number(obj.position.z) || 0,
}
: undefined,
rotation: obj.rotation && typeof obj.rotation === "object"
? {
x: Number(obj.rotation.x) || 0,
y: Number(obj.rotation.y) || 0,
z: Number(obj.rotation.z) || 0,
}
: undefined,
size: obj.size ? Number(obj.size) : 1,
};
objectUpdates.push(updateObj);
}
}
if (objectUpdates.length > 0) {
updateDto.objects = objectUpdates;
}
}
const oldInvestigation = JSON.parse(JSON.stringify(investigation));
const updateResult = await updateInvestigation(investigationId, updateDto, {
needsBuildUpdate: true,
updatedBy,
isAiGenerated: true, // Mark as AI-generated
skipVersioning: true, // We'll handle versioning via updateInvestigationByVersion
});
if (!updateResult.success || !updateResult.updatedInvestigation) {
throw new Error("Failed to update investigation with regenerated fields");
}
const updatedInvestigationDocument = await Investigation.findById(investigationId);
if (updatedInvestigationDocument) {
await updateInvestigationByVersion(oldInvestigation, updatedInvestigationDocument, updatedBy);
}
const updatedInvestigation = await getInvestigationById(investigationId);
updatedInvestigation._regenerationHasChanges = hasChanges;
updatedInvestigation._regenerationChangedFields = changedFields;
logger.info({ investigationId, fieldName, hasChanges, changedFieldsCount: changedFields.length }, "Successfully regenerated other fields based on field");
return updatedInvestigation;
}
catch (error) {
logger.error({
error,
investigationId,
fieldName,
message: error instanceof Error ? error.message : "Unknown error",
}, "Failed to regenerate other fields");
throw error;
}
}
Source