Back to Articles
#java#ai#woocommerce#automation#llm#architecture

How I Converted a WooCommerce Catalog from U.S. Customary Units to Metric with LLM Function Calling

How a hybrid architecture combining an LLM for semantic HTML parsing and Java for deterministic arithmetic automated the conversion of hundreds of e-commerce products—with state persistence and surgical compensating rollbacks.

13 min read
2,818 words

A friend running an e-commerce business recently reached out to vent about a massive, soul-crushing bottleneck. Their store, built on WooCommerce, featured an extensive catalog of mechanical equipment, workshop tools, and industrial components.

The issue? The catalog had been imported from US suppliers and legacy product feeds. Every single product title, specification table, and descriptive paragraph was saturated with U.S. customary units: inches (", , in, inches), feet (', ft), pounds (lbs), and ounces (oz). Dimensions like 21.65"D x 61.02"W x 34.84"H were everywhere.

As they expanded their business into a market where the metric system is the mandatory standard, customers were complaining, and search visibility was suffering.

They were staring down the grim prospect of spending weeks manually editing hundreds of products across the WordPress administrative panel—punching numbers into a desk calculator, copy-pasting values, and introducing inevitable human errors.

As a software engineer who loves rolling up my sleeves and taking on a good technical challenge over the weekend, I saw this and immediately thought: There has to be a better way. I told them to step away from the admin dashboard and let me build an automated system to convert the entire catalog for them.

What started as a fun weekend challenge quickly turned into a fascinating study in combining modern LLM tool calling with reliable systems engineering. Here is why naive solutions failed, how a hybrid architecture pairing an LLM with deterministic Java function calling solved it, and how I built state recovery and a rollback mechanism to protect live production data.


The Traps: Why Naive Approaches Fail

1. The Regular Expression Trap

The immediate instinct for any developer is to write a regex search-and-replace script:

(\d+(?:\.\d+)?)\s*(?:\"|inch|inches)

In plain text, this works passably well. But WooCommerce product descriptions are not plain text—they are raw HTML.

In HTML, the double-quote character (") is heavily overloaded. It surrounds attributes, inline CSS, image sources, and class names:

<img src="https://store.example/wp-content/uploads/drill-press.jpg" class="product-gallery-img" style="width: 100%;" alt="24\" Heavy Duty Drill Press">
<p class="specs">Overall dimensions: 21.65"D x 61.02"W x 34.84"H</p>

A regex attempting to match double quotes without a full HTML tokenizer risks corrupting HTML attributes, mangling class names, or destroying image tags.

Furthermore, suppliers represent measurements inconsistently:

  • Standard double quotes (24"), curly smart quotes (24” or 24“), and Unicode double primes (24″).
  • Foot symbols (6'), smart apostrophes (6’), and single primes (6′).
  • Fractional notation (1 1/2") alongside decimals (1.5").
  • Mixed abbreviations (lbs, lb, pounds, oz, ounce).

2. The “Just Prompt ChatGPT” Trap

The second instinct in the era of generative AI is to dump the product text into an LLM with a prompt like: “Convert all U.S. customary units to metric.”

LLMs excel at natural language understanding. An LLM easily recognizes that 21.65"D means depth in inches, whereas <div class="spec"> is an HTML attribute that must remain untouched.

However, relying entirely on an LLM introduces two fatal issues:

  1. Arithmetic Hallucinations: LLMs are statistical token predictors, not calculators. When asked to multiply 21.65 * 2.54 or 61.02 * 2.54 and round to whole numbers, models frequently hallucinate:

    Prompt:  "Convert 21.65 inches to cm"
    LLM:     "53 cm"  (Actual: 21.65 * 2.54 = 54.991 -> 55 cm)

    In technical equipment catalogs, an error of two or three centimeters can ruin a customer’s installation.

  2. Unsolicited Rewriting: LLMs struggle with strict preservation. Even when instructed not to rewrite text, models frequently alter formatting: stripping WooCommerce shortcodes, cleaning up whitespace, fixing perceived grammatical quirks, or omitting HTML wrappers.


The Hybrid Solution: LLM for Context, Java for Math

The optimal architecture divides responsibilities according to each system’s fundamental strength:

  • The LLM acts as the contextual parser and semantic editor. It reads the raw HTML, identifies measurements within unstructured text, extracts the numeric value and unit, and places the converted result back into the exact original sentence structure without touching any HTML attributes.
  • Java acts as the deterministic calculator, orchestration engine, and database interface. It executes precise floating-point arithmetic and manages the WooCommerce REST API.

The bridge between these two worlds is LLM Function Calling (Tool Calling) via the OpenRouter API.

flowchart LR
    Woo["<b>WooCommerce API</b><br/>Fetch Product"]
    Filter{"<b>Pre-flight</b><br/>Regex Scan"}
    Skip(["<b>Skip</b><br/>$0 Cost"]):::skipStyle
    LLM["<b>LLM Parse</b><br/>OpenRouter API"]
    Tool["<b>Java Tool</b><br/>Math: 21.65 × 2.54 = 55 cm"]
    Audit["<b>Audit Log</b><br/>changes_log.jsonl"]
    Update["<b>WooCommerce API</b><br/>PUT Update"]

    Woo --> Filter
    Filter -->|No imperial| Skip
    Filter -->|Imperial found| LLM
    LLM <-->|Tool Call / Return| Tool
    LLM -->|Updated HTML| Audit
    Audit --> Update

    classDef skipStyle stroke-dasharray: 4 4,stroke-width: 2px

Dissecting the Implementation

The entire pipeline was built into a standalone Java tool called WooMetricToolAgent. Here is how I structured the core components.

1. Defining the Function Schema

First, I defined the tool contract that the LLM is instructed to use whenever it encounters an imperial measurement:

{
  "type": "function",
  "function": {
    "name": "calculate_metric",
    "description": "Converts U.S. customary units to metric. Use this WHENEVER you see a measurement.",
    "parameters": {
      "type": "object",
      "properties": {
        "value": { "type": "number", "description": "The numeric value (e.g. 43)" },
        "unit": { "type": "string", "enum": ["inch", "feet", "lb", "oz"] }
      },
      "required": ["value", "unit"]
    }
  }
}

By constraining unit to an enum of valid values (inch, feet, lb, oz) and value to a number, the LLM handles unit normalization automatically. Whether the text says inches, ", , or in., the model extracts the canonical unit string and numeric float.


2. Deterministic Arithmetic in Java

When the model triggers calculate_metric, the Java runtime intercepts the request and calculates the conversion:

private static int performMathInJava(double value, String unit) {
    double result = 0;
    switch (unit.toLowerCase()) {
        case "inch":
        case "inches":
            result = value * 2.54;
            break;
        case "feet":
        case "ft":
            result = value * 30.48;
            break;
        case "lb":
        case "lbs":
            result = value * 0.453592;
            break;
        case "oz":
            result = value * 28.3495;
            break;
    }
    return (int) Math.round(result);
}

By offloading the multiplication and rounding to Java, the architecture removes LLM-generated arithmetic from the conversion step. As long as the model correctly extracts the numeric quantity and unit (including normalizing mixed fractions like 1 1/2" to 1.5), the calculation itself is completely deterministic and reproducible.


3. Contextual Prompting & Title vs. Description Formatting

In e-commerce catalogs, different fields often follow distinct styling requirements:

  • Product Titles: Under strict SI/NIST guidelines, the symbol for centimeter is always lowercase (cm). However, the store’s existing visual design and theme conventions called for uppercase CM in product titles (e.g., Heavy Duty Steel Workbench - 155 CM W x 88 CM H) to match the rest of their catalog typography.
  • Product Descriptions: Body text followed standard lowercase SI typography: 55 cm depth, weighing 42 kg.

I parameterized the system prompt based on whether the input text is a title or a description body:

private static String smartConvertWithAgent(String inputText, boolean isTitle) {
    if (inputText == null || inputText.isEmpty()) return "";

    // Pre-flight heuristic filter: Skip LLM call if no imperial markers exist
    if (!inputText.matches("(?is).*(inch|inches|\"|”|“|″|lbs|lb|pounds|feet|ft|’|‘|′|oz|ounce).*")) {
        return inputText;
    }

    String cmFormat = isTitle ? "CM" : "cm";
    
    JsonObject systemMsg = new JsonObject();
    systemMsg.addProperty("role", "system");
    systemMsg.addProperty("content", 
        "You are a content converter. Replace U.S. customary units with metric. "
        + "ALWAYS use the 'calculate_metric' tool for every number you find. "
        + "Ensure that imperial symbols like double quotes (\", ”, “, ″), 'lbs', 'inches', "
        + "feet symbols (', ’, ‘, ′), etc., are COMPLETELY REMOVED and replaced by their metric equivalents (e.g., '" + cmFormat + "', 'kg'). "
        + "For a string like '21.65\"D x 61.02\"W x 34.84\"H', it should become '55 " + cmFormat + " D x 155 " + cmFormat + " W x 88 " + cmFormat + " H'. "
        + "CRITICAL: Preserve all original formatting, whitespace, and line endings (e.g., \\r\\n) EXACTLY. "
        + "DO NOT change any text except for the unit conversion. "
        + (isTitle ? "CRITICAL: For centimeters, ALWAYS use uppercase 'CM' in the output. " : "")
        + "If the input is HTML, preserve tags exactly. Return ONLY the final converted string."
    );
    
    // ...
}

Notice the pre-flight regex check:

if (!inputText.matches("(?is).*(inch|inches|\"|”|“|″|lbs|lb|pounds|feet|ft|’|‘|′|oz|ounce).*")) return inputText;

The Heuristic Tradeoff: Plain Text vs. Rich HTML

In plain text (such as product titles), this heuristic was an immediate win: titles containing no measurements or quote characters bypassed the LLM entirely, saving substantial latency and API token costs.

However, rich HTML descriptions exposed an interesting engineering tradeoff. Because HTML markup is full of double quotes (<div class="product">, <img src="..." alt="...">), checking for any unescaped " character meant almost every HTML description with standard tags triggered the LLM—even if the actual text contained no U.S. customary measurements.

While this broad heuristic was safe (it ensured no actual measurement was missed), a production-grade optimization would anchor the search to digits or strip HTML tags before evaluating:

(?i).*(?:\b\d+(?:\.\d+)?\s*(?:inch|inches|lbs?|pounds?|ft|feet|oz|ounces?|[″"′'])\b).*

Accounting for differences between plain text and rich markup is an essential lesson when building LLM-assisted pipelines.


4. The Multi-Turn Agent Loop

Because a single product description may contain half a dozen different measurements (e.g., length, width, height, packaging weight, cable length), the agent must handle multiple tool calls in a conversational loop:

boolean unitConverted = false;
int maxTurns = 15;

for (int turn = 0; turn < maxTurns; turn++) {
    JsonObject responseJson = callOpenRouter(messages);
    if (responseJson == null || !responseJson.has("choices")) return inputText;

    JsonObject choice = responseJson.getAsJsonArray("choices").get(0).getAsJsonObject();
    JsonObject message = choice.getAsJsonObject("message");
    String finishReason = choice.get("finish_reason").getAsString();

    messages.add(message);

    // CASE A: Model wants to calculate a measurement
    if ("tool_calls".equals(finishReason) || message.has("tool_calls")) {
        JsonArray toolCalls = message.getAsJsonArray("tool_calls");
        unitConverted = true;

        for (JsonElement toolCallElem : toolCalls) {
            JsonObject toolCall = toolCallElem.getAsJsonObject();
            String callId = toolCall.get("id").getAsString();
            String argsStr = toolCall.getAsJsonObject("function").get("arguments").getAsString();

            JsonObject args = gson.fromJson(argsStr, JsonObject.class);
            double value = args.get("value").getAsDouble();
            String unit = args.get("unit").getAsString();

            // Run Java calculation
            int calculatedResult = performMathInJava(value, unit);

            // Feed the exact math back to the model as a tool response
            JsonObject toolResponse = new JsonObject();
            toolResponse.addProperty("role", "tool");
            toolResponse.addProperty("tool_call_id", callId);
            toolResponse.addProperty("name", "calculate_metric");
            toolResponse.addProperty("content", String.valueOf(calculatedResult));
            messages.add(toolResponse);
        }
    }
    // CASE B: Final Answer generated
    else {
        return unitConverted ? message.get("content").getAsString() : inputText;
    }
}

If the model detects three measurements in a string, modern function-calling models can emit parallel tool calls in a single turn. Java processes each call, returns the computed integers, and on the subsequent turn, the model yields the final, perfectly formatted HTML string.


Operational Resiliency: State and Crash Recovery

When orchestrating batch mutations over external network APIs, unexpected failures are guaranteed: HTTP 429 rate limits, dropped TCP connections, or sudden process terminations.

If a script crashes after updating 142 out of 500 products, running it again without state tracking would cause:

  1. Wasted API credits re-evaluating already converted products.
  2. The risk of double-converting text if units were ambiguous.

To ensure strict idempotency, I implemented state persistence in the OS user directory:

private static Path getStateFile(String filename) {
    String appData = System.getenv("APPDATA");
    Path stateDir = (appData != null) 
        ? Paths.get(appData, "WooMetricToolAgent") 
        : Paths.get(System.getProperty("user.home"), ".WooMetricToolAgent");

    try {
        if (!Files.exists(stateDir)) {
            Files.createDirectories(stateDir);
        }
    } catch (IOException e) {
        return Paths.get(filename); // Fallback to current working directory
    }
    return stateDir.resolve(filename);
}

Before processing a product:

if (processedIds.contains(id)) {
    System.out.println("Product [" + id + "]: Already processed. Skipping.");
    return;
}

Once a product successfully completes its update:

markAsProcessed(id);

This simple file-backed cache turned the batch job into a resumable process. If the network dropped or rate limits kicked in, I could simply re-run the program, and it immediately resumed where it left off.


The Safety Net: The Panic Button (WooUndoTool)

Here is an iron rule of systems engineering: Never run batch write operations against a production database without a reliable, compensating rollback mechanism.

Before calling the WooCommerce PUT endpoint, WooMetricToolAgent appends the exact previous state of the product to a JSON Lines log file (changes_log.jsonl):

private static void logChange(int id, String oldName, String newName, 
                              String oldDesc, String newDesc, 
                              String oldShort, String newShort) {
    JsonObject logEntry = new JsonObject();
    logEntry.addProperty("id", id);
    logEntry.addProperty("timestamp", System.currentTimeMillis());
    logEntry.addProperty("old_name", oldName);
    logEntry.addProperty("new_name", newName);
    logEntry.addProperty("old_description", oldDesc);
    logEntry.addProperty("new_description", newDesc);
    logEntry.addProperty("old_short_description", oldShort);
    logEntry.addProperty("new_short_description", newShort);

    Path file = getStateFile("changes_log.jsonl");
    try (FileWriter fw = new FileWriter(file.toFile(), true);
         BufferedWriter bw = new BufferedWriter(fw);
         PrintWriter out = new PrintWriter(bw)) {
        out.println(gson.toJson(logEntry));
    } catch (IOException e) {
        System.err.println("Could not log change for product " + id + ": " + e.getMessage());
    }
}

Why JSONL Instead of a Database?

When deciding how to persist change logs and state, the conventional reflex might be to spin up SQLite or an embedded database. But this project was meant to be finished over the weekend, and introducing a database would have added unnecessary complexity without real benefit:

  • Zero setup overhead: No JDBC drivers, schema definitions, or connection management.
  • Instant visibility: A JSON Lines (.jsonl) file can be inspected and verified in seconds using standard CLI tools like grep, tail -f, or jq.
  • No specialized tooling: There is no need to fire up a database GUI client (like DBeaver or TablePlus) or write SQL queries just to check what the agent changed. You can open the file in VS Code or run rg from the terminal.
  • Append-only simplicity: JSON Lines provided a lightweight append-only audit trail that was straightforward to inspect and simple to recover from without managing database locks or connection pooling.

Two-Tiered Recovery: Surgical Reverts vs. Full Backups

To complement this audit trail, I established a two-tiered recovery strategy.

First, before executing a single API write request, I took complete backups of the entire website filesystem and the database. Having a clean snapshot ensured that if anything went fundamentally wrong during execution, I could restore the entire store back to its original baseline.

However, restoring an entire database backup is a blunt, disruptive hammer on an active e-commerce store—it risks wiping out incoming customer orders, active cart sessions, or inventory updates that happened while the script was running.

That is why I built WooUndoTool.java for surgical reverts. If an edge case in a specific product listing was flagged during inspection, there was no need to touch the database backups or take the site offline. I could simply target that exact change:

public class WooUndoTool {
    public static void main(String[] args) {
        int lineNumber = Integer.parseInt(args[0]);
        undoChangeByLine(lineNumber);
    }
    
    private static void undoChangeByLine(int lineNumber) throws IOException {
        // 1. Read line from changes_log.jsonl
        // 2. Extract original name, description, and short_description
        // 3. Issue WooCommerce PUT request to revert the product
        // 4. Remove productId from processed_ids.txt
    }
}

By removing the product ID from processed_ids.txt, WooUndoTool not only restored the previous database state but also left the product eligible for re-processing once prompt adjustments were made.


Real-World Results

Here is an excerpt from the live execution log:

Product [1482]: Industrial Workstation Table 60"W x 30"D ... UPDATED.
Product [1483]: Hydraulic Hose Assembly 1/2" ID x 50 ft ... UPDATED.
Product [1484]: Metric Hex Bolt M8-1.25 x 30mm ... Skipped.
Product [1485]: Cast Iron Vise 8" Jaw Width 45 lbs ... UPDATED.

Before vs. After Example

Input Title:

Heavy Duty Steel Workbench 72"W x 36"D x 34"H - 450 lbs Capacity

Output Title:

Heavy Duty Steel Workbench 183 CM W x 91 CM D x 86 CM H - 204 kg Capacity

Input HTML Body:

<p>Equipped with a sturdy 1.5" composite wood top and 2" tubular steel legs.</p>
<ul>
  <li>Overall dimensions: 72"W x 36"D x 34"H</li>
  <li>Shipping weight: approx. 120 lbs</li>
  <li>Adjustable foot leveling pads extend up to 1"</li>
</ul>

Output HTML Body:

<p>Equipped with a sturdy 4 cm composite wood top and 5 cm tubular steel legs.</p>
<ul>
  <li>Overall dimensions: 183 cm W x 91 cm D x 86 cm H</li>
  <li>Shipping weight: approx. 54 kg</li>
  <li>Adjustable foot leveling pads extend up to 3 cm</li>
</ul>

Across our audited sample batches, surrounding HTML tags (<p>, <ul>, <li>), attributes, and line breaks remained intact, with the catalog migration completing in under an hour.


Key Takeaways for Building AI Automation

  1. Don’t Ask LLMs to Do Math: Large language models excel at syntax, grammar, and semantic context. Whenever quantitative precision is required, delegate the computation to a deterministic tool via function calling.
  2. Use Heuristic Pre-Filtering: Never invoke an LLM without checking if the work is actually necessary. Even a simple regex filter can eliminate massive overhead for plain-text fields like titles, though rich HTML markup requires anchoring patterns to numbers to avoid false positives on tag attributes.
  3. Idempotency is Essential for Batch Automation: When modifying external systems over HTTP, design the process to be stoppable and resumable at any point using persistent state files.
  4. Full Backups + Surgical Rollbacks: Complete snapshots protect you against system-level failures, but granular undo tools allow you to fix individual product edge cases on a live store without restoring entire databases or wiping out active customer orders.