Source

services/investigation/ai-regenerate.js

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