src/lib/rules/rule-engine.ts

Total Symbols
5
Lines of Code
364
Avg Complexity
11.2
Avg Coverage
100.0%

File Relationships

graph LR resolveFieldValue["resolveFieldValue"] findMatchingRule["findMatchingRule"] generateDateForField["generateDateForField"] callAiWithTimeout["callAiWithTimeout"] getEffectiveFieldType["getEffectiveFieldType"] resolveFieldValue -->|calls| findMatchingRule resolveFieldValue -->|calls| generateDateForField resolveFieldValue -->|calls| callAiWithTimeout resolveFieldValue -->|calls| getEffectiveFieldType click resolveFieldValue "../symbols/b0ea06e6c355d586.html" click findMatchingRule "../symbols/9456e727716b3217.html" click generateDateForField "../symbols/a214011e5ced5989.html" click callAiWithTimeout "../symbols/4ef7f4ef5ff6b7b3.html" click getEffectiveFieldType "../symbols/7eda31b9cfeb392f.html"

Architecture violations

View all

  • [warning] max-cyclomatic-complexity: 'resolveFieldValue' has cyclomatic complexity 40 (max 10)
  • [warning] max-lines: 'resolveFieldValue' has 218 lines (max 80)

Symbols by Kind

function 5

All Symbols

Name Kind Visibility Status Lines Signature
generateDateForField function - 65-73 generateDateForField(fieldType: FieldType, field: FormField): : string
callAiWithTimeout function - 76-102 callAiWithTimeout( fn: (field: FormField) => Promise<string>, field: FormField, context: string, timeoutMs = DEFAULT_AI_TIMEOUT_MS, ): : Promise<string>
resolveFieldValue function exported- 111-328 resolveFieldValue( field: FormField, url: string, aiGenerateFn?: (field: FormField) => Promise<string>, forceAIFirst = false, aiTimeoutMs = DEFAULT_AI_TIMEOUT_MS, ): : Promise<GenerationResult>
getEffectiveFieldType function - 330-340 getEffectiveFieldType(field: FormField): : FieldType
findMatchingRule function - 342-363 findMatchingRule( rules: FieldRule[], field: FormField, ): : FieldRule | undefined

Full Source

/**
 * Rule engine — determines which value to use for a given field
 */

import type {
  FieldRule,
  FormField,
  GenerationResult,
  FieldType,
} from "@/types";
import { FIELD_TYPE_DEFINITIONS } from "@/types";
import { getRulesForUrl } from "@/lib/storage/storage";
import { generate } from "@/lib/generators";
import {
  adaptGeneratedValue,
  generateWithConstraints,
} from "@/lib/generators/adaptive";
import { detectDateFormat, reformatDate } from "@/lib/generators/date";
import { createLogger } from "@/lib/logger";

const log = createLogger("RuleEngine");

const DEFAULT_AI_TIMEOUT_MS = 5000;

/**
 * Generator keys where AI genuinely adds value (free-text / open-ended).
 * Every other type has a deterministic generator — calling AI wastes time.
 */
const AI_USEFUL_GENERATORS = new Set([
  "text",
  "description",
  "notes",
  "search-text",
  "fallback-text",
]);

/**
 * Field types that have deterministic, high-quality generators.
 * Derived from FIELD_TYPE_DEFINITIONS: any type whose generator is NOT
 * in the AI_USEFUL_GENERATORS set is considered generator-only.
 */
const GENERATOR_ONLY_TYPES = new Set<FieldType>(
  FIELD_TYPE_DEFINITIONS.filter(
    (d) => d.generator && !AI_USEFUL_GENERATORS.has(d.generator),
  ).map((d) => d.type),
);

/**
 * Field types that produce date-like values (ISO strings).
 * For these, we detect the display format expected by the field and reformat
 * the generated ISO string accordingly before returning it.
 */
const DATE_FIELD_TYPES = new Set<FieldType>([
  "date",
  "birth-date",
  "start-date",
  "end-date",
  "due-date",
]);

/**
 * Generates a date value for a field, formatted according to the field's
 * detected expected format (ISO, BR, or US).
 */
function generateDateForField(fieldType: FieldType, field: FormField): string {
  const isoDate = generate(fieldType);
  const format = detectDateFormat({
    inputType: field.inputType,
    placeholder: field.placeholder,
    pattern: field.pattern,
  });
  return reformatDate(isoDate, format);
}

/** Wraps an AI call with a hard timeout so it never blocks indefinitely. */
async function callAiWithTimeout(
  fn: (field: FormField) => Promise<string>,
  field: FormField,
  context: string,
  timeoutMs = DEFAULT_AI_TIMEOUT_MS,
): Promise<string> {
  const label = field.label ?? field.id ?? field.selector;
  log.info(
    `🤖 AI gerando valor para: "${label}" (${context}, timeout ${timeoutMs}ms)...`,
  );
  const start = Date.now();

  const result = await Promise.race([
    fn(field),
    new Promise<string>((_, reject) =>
      setTimeout(
        () => reject(new Error(`AI timeout (${timeoutMs}ms)`)),
        timeoutMs,
      ),
    ),
  ]);

  log.info(
    `✅ AI concluiu em ${Date.now() - start}ms: "${result.slice(0, 60)}"`,
  );
  return result;
}

/**
 * Resolves the value for a single field, using this priority:
 * 1. Field-specific rule (always wins — even over forceAIFirst)
 * 2. (optional) AI first — when forceAIFirst is true and no rule exists
 * 3. Default generator based on detected field type
 * 4. AI as last resort (only when default generator returns empty, e.g. select fields)
 */
export async function resolveFieldValue(
  field: FormField,
  url: string,
  aiGenerateFn?: (field: FormField) => Promise<string>,
  forceAIFirst = false,
  aiTimeoutMs = DEFAULT_AI_TIMEOUT_MS,
): Promise<GenerationResult> {
  const selector = field.selector;

  const fieldDesc = `selector="${selector}" label="${field.label ?? ""}" type="${field.fieldType}"`;
  log.debug(
    `Resolvendo campo: ${fieldDesc}${forceAIFirst ? " [forceAIFirst=true]" : ""}`,
  );

  // 1. Check field rules first — rules always take priority over AI/TF
  const rules = await getRulesForUrl(url);
  const matchingRule = findMatchingRule(rules, field);

  if (matchingRule) {
    if (matchingRule.fixedValue) {
      return {
        fieldSelector: selector,
        value: matchingRule.fixedValue,
        source: "rule",
      };
    }

    // Handle select fields with explicit option index
    if (
      field.element instanceof HTMLSelectElement &&
      matchingRule.selectOptionIndex !== undefined
    ) {
      const options = Array.from(field.element.options).filter((o) => o.value);
      if (matchingRule.selectOptionIndex === 0) {
        // auto — pick random non-empty option
        if (options.length > 0) {
          const random = options[Math.floor(Math.random() * options.length)];
          return {
            fieldSelector: selector,
            value: random.value,
            source: "rule",
          };
        }
      } else {
        // pick by 1-based index
        const opt = field.element.options[matchingRule.selectOptionIndex - 1];
        if (opt) {
          return { fieldSelector: selector, value: opt.value, source: "rule" };
        }
      }
    }

    // If the rule specifies a generator type
    if (
      matchingRule.generator !== "auto" &&
      matchingRule.generator !== "ai" &&
      matchingRule.generator !== "tensorflow"
    ) {
      const ruleGenerator = matchingRule.generator as FieldType;
      if (DATE_FIELD_TYPES.has(ruleGenerator)) {
        const value = generateDateForField(ruleGenerator, field);
        return { fieldSelector: selector, value, source: "generator" };
      }
      const value = generateWithConstraints(
        () => generate(ruleGenerator, matchingRule.generatorParams),
        {
          element: field.element,
          requireValidity: false,
        },
      );
      return { fieldSelector: selector, value, source: "generator" };
    }

    // If the rule says to use AI
    if (matchingRule.generator === "ai" && aiGenerateFn) {
      try {
        const aiValue = await callAiWithTimeout(
          aiGenerateFn,
          field,
          "rule:ai",
          aiTimeoutMs,
        );
        const value = adaptGeneratedValue(aiValue, {
          element: field.element,
          requireValidity: false,
        });
        return { fieldSelector: selector, value, source: "ai" };
      } catch (err) {
        log.warn(`AI (rule) falhou:`, err);
      }
    }

    // generator === "auto": respect the fieldType the user explicitly set in the rule.
    // This is the fix for when the user changes the field type in the editor but keeps
    // "auto" as the generator — the rule's fieldType must drive generation, not the
    // TF-detected field.fieldType.
    if (
      matchingRule.generator === "auto" &&
      matchingRule.fieldType !== "unknown"
    ) {
      const ruleType = matchingRule.fieldType;
      if (DATE_FIELD_TYPES.has(ruleType)) {
        const value = generateDateForField(ruleType, field);
        return { fieldSelector: selector, value, source: "generator" };
      }
      const value = generateWithConstraints(
        () => generate(ruleType, matchingRule.generatorParams),
        { element: field.element, requireValidity: false },
      );
      if (value) {
        return { fieldSelector: selector, value, source: "generator" };
      }
    }
    // generator === "tensorflow": fall through — TF already classified the field,
    // field.fieldType reflects that result. forceAIFirst is intentionally skipped.
  }

  // 2. AI first — only when no matching rule exists and forceAIFirst is enabled
  if (
    forceAIFirst &&
    !matchingRule &&
    aiGenerateFn &&
    !GENERATOR_ONLY_TYPES.has(field.fieldType)
  ) {
    try {
      const aiValue = await callAiWithTimeout(
        aiGenerateFn,
        field,
        "forceAIFirst",
        aiTimeoutMs,
      );
      const value = adaptGeneratedValue(aiValue, {
        element: field.element,
        requireValidity: true,
      });
      if (value) {
        return { fieldSelector: selector, value, source: "ai" };
      }
    } catch (err) {
      log.warn(`AI (forceAIFirst) falhou:`, err);
      // Fall through to default generator
    }
  }

  // 3. Default generator based on detected field type
  const effectiveType = getEffectiveFieldType(field);
  if (DATE_FIELD_TYPES.has(effectiveType)) {
    const value = generateDateForField(effectiveType, field);
    log.debug(`Gerador de data (${effectiveType}, detectado): "${value}"`);
    return { fieldSelector: selector, value, source: "generator" };
  }
  const value = generateWithConstraints(() => generate(effectiveType), {
    element: field.element,
    requireValidity: true,
  });
  if (value) {
    log.debug(`Gerador padrão (${effectiveType}): "${value}"`);
    return { fieldSelector: selector, value, source: "generator" };
  }

  // 5.5 For <select> elements: pick a random valid option directly.
  // AI cannot know which options are available in the DOM, so we must handle this ourselves.
  if (field.element instanceof HTMLSelectElement) {
    const validOptions = Array.from(field.element.options).filter(
      (opt) => opt.value,
    );
    if (validOptions.length > 0) {
      const random =
        validOptions[Math.floor(Math.random() * validOptions.length)];
      log.debug(
        `Select aleatório: "${random.value}" (${validOptions.length} opções disponíveis)`,
      );
      return {
        fieldSelector: selector,
        value: random.value,
        source: "generator",
      };
    }
    // No valid options — return empty, no point calling AI
    log.warn(`Select sem opções válidas para: ${fieldDesc}`);
    return { fieldSelector: selector, value: "", source: "generator" };
  }

  // 5.6 For checkbox/radio: the value is fixed (true/false), AI adds no value here.
  if (
    field.element instanceof HTMLInputElement &&
    (field.element.type === "checkbox" || field.element.type === "radio")
  ) {
    return { fieldSelector: selector, value: "true", source: "generator" };
  }

  // 6. AI as last resort — only for free-text fields where the generator returned empty
  if (aiGenerateFn && !GENERATOR_ONLY_TYPES.has(field.fieldType)) {
    log.info(
      `Gerador padrão vazio — tentando AI como último recurso para: ${fieldDesc}`,
    );
    try {
      const aiValue = await callAiWithTimeout(
        aiGenerateFn,
        field,
        "último recurso",
        aiTimeoutMs,
      );
      const adaptedAiValue = adaptGeneratedValue(aiValue, {
        element: field.element,
        requireValidity: true,
      });
      if (adaptedAiValue) {
        return { fieldSelector: selector, value: adaptedAiValue, source: "ai" };
      }
      log.warn(`AI (último recurso) retornou vazio para: ${fieldDesc}`);
    } catch (err) {
      log.warn(`AI (último recurso) falhou para: ${fieldDesc}`, err);
    }
  }

  return { fieldSelector: selector, value: value || "", source: "generator" };
}

function getEffectiveFieldType(field: FormField): FieldType {
  if (
    (field.fieldType === "unknown" || field.fieldType === "select") &&
    field.contextualType &&
    field.contextualType !== "unknown" &&
    field.contextualType !== "select"
  ) {
    return field.contextualType;
  }
  return field.fieldType;
}

function findMatchingRule(
  rules: FieldRule[],
  field: FormField,
): FieldRule | undefined {
  // Sort by priority (descending) so higher priority rules take precedence
  const sorted = [...rules].sort((a, b) => b.priority - a.priority);

  return sorted.find((rule) => {
    // Match by CSS selector
    if (rule.fieldSelector && field.element.matches(rule.fieldSelector)) {
      return true;
    }
    // Match by field name
    if (
      rule.fieldName &&
      (field.name === rule.fieldName || field.id === rule.fieldName)
    ) {
      return true;
    }
    return false;
  });
}