WooCommerce AI analytics should ask, not count

Ask Not Count hook beside an isometric switchboard wired to a small analytics engine dispensing report slips.

Ask the AI box in your store admin the same revenue question twice and you can get two different numbers back. Neither one matches the figure under WooCommerce > Analytics. There’s no error and no log entry to chase, because nothing failed. This is how WooCommerce AI analytics usually breaks when it’s built the easy way. The model is doing the counting, and a model optimizes for looking correct.

That failure is what Ingo Nowitzky documents in Hybrid AI: Combining Deterministic Analytics with LLM Reasoning on Towards Data Science. He built an agent to advise manufacturing plants from an Excel export with more than 800 columns and over 160 free-text fields, and found most of its analysis was wrong. Even with code interpreter enabled, and across ChatGPT, Gemini Enterprise, Microsoft Copilot and the other systems he tested, the agent skipped rows, applied incorrect filters, and returned identical results for different inputs. After seeing enough examples it began fabricating numbers that sat comfortably in plausible ranges.

His fix, keeping probabilistic reasoning for interpretation and deterministic code for the data work, is what I’d do on a store, with one addition: the deterministic half is infrastructure we already own. The model picks the question and your PHP answers it. Most of these features go wrong at that boundary, where someone wired the model to the orders table or dropped a CSV export into a chat window and trusted the summary. That’s how you end up with the quiet drift I wrote about when a vibe coded dashboard stopped matching the database.

A WooCommerce order is harder for a model than an 800-column spreadsheet

His dataset was hostile on its own: 800-plus columns, dozens of near-identical maturity scores, concept and execution variants of everything. A WooCommerce order is still harder, for concrete reasons. Order data doesn’t live in tidy columns. On older stores it’s a wp_posts row with metadata scattered as key/value pairs across wp_postmeta, and with HPOS it lives in dedicated order tables, so the field a model wants to read often has no column to read. Refunds are separate objects you subtract yourself, since they aren’t negative lines baked into the total. Status is a moving target while an order works through processing toward completion, so “revenue last quarter” depends on which statuses you mean. And every date exists twice, local and GMT, so choosing the wrong one silently shifts which orders land inside the period.

His failure list transfers one for one. A skipped row becomes an ignored refund; a wrong filter becomes the wrong order status. Identical results for different inputs is the model recognizing the shape of a question and answering from memory. He also reports systems silently mixing parts of the dataset, which in store terms is blending this quarter’s orders with an old export somebody left in the same chat. And because the model learns what plausible output looks like, its confidence in a wrong number grows with use.

The other side has a real point, and it’s in the same article. His second analysis type, text_summary, does no calculation at all. It collects the non-empty assessor statements and hands them to the parent agent to interpret, and that is the work models are good at. In a store that means clustering refund reasons, summarizing order notes, drafting the reply to a customer. I’d hand all of that over without hesitation. The line I draw is narrow: the model never originates a number. Numbers come from code the model can call but cannot write.

What I’d build

The article splits the pipeline into a planner, which translates natural language into a small JSON specification, and an engine, which executes that spec with pandas and nothing else. You can copy that split with parts WordPress already ships, and skip the pandas. Register one REST route per analytics question, declare every argument as JSON schema, and make the model’s only output the request parameters. WordPress validates arguments against the schema before your callback runs, per the REST API handbook, so a hallucinated parameter becomes a rejected request.

One piece of his design you can delete is the Mapping_File, a lookup table that assigns semantic meaning to each of the 800 columns so the engine selects columns by meaning instead of by name. WooCommerce already has that layer. $order->get_total(), get_status() and get_date_created() are semantic getters that hide whether a value came from a post field, a meta row or an HPOS column. If your analytics code goes through the CRUD getters, the model never has to know a column name, and a storage migration doesn’t break your endpoint.

Trimmed to the parts that matter, the route looks like this.

add_action( 'rest_api_init', function () {
    register_rest_route( 'bbioon/v1', '/store-metrics', array(
        'methods'             => 'GET',
        'permission_callback' => function () {
            return current_user_can( 'view_woocommerce_reports' );
        },
        'callback'            => 'bbioon_store_metrics',
        'args'                => array(
            'metric' => array(
                'required' => true,
                'type'     => 'string',
                'enum'     => array( 'aov', 'order_count', 'net_sales' ),
            ),
            'status' => array(
                'type'    => 'array',
                'items'   => array(
                    'enum' => array( 'wc-processing', 'wc-completed' ),
                ),
                'default' => array( 'wc-processing', 'wc-completed' ),
            ),
            'after' => array(
                'required' => true,
                'type'     => 'string',
                'format'   => 'date',
            ),
        ),
    ) );
} );

function bbioon_store_metrics( WP_REST_Request $request ) {
    $orders = wc_get_orders( array(
        'status'       => $request['status'],
        'limit'        => -1,
        'date_created' => $request['after'] . '...' . gmdate( 'Y-m-d' ),
    ) );

    $net = 0.0;
    foreach ( $orders as $order ) {
        // Refunds are separate objects, so subtract them here.
        $net += $order->get_total() - $order->get_total_refunded();
    }

    $count = count( $orders );
    return rest_ensure_response( array(
        'metric'      => $request['metric'],
        'order_count' => $count,
        'net_sales'   => round( $net, wc_get_price_decimals() ),
        'aov'         => $count > 0 ? round( $net / $count, 2 ) : null,
    ) );
}

The enum is what makes this work. If the model invents a metric or a status, WordPress replies with rest_invalid_param and a 400 before the callback runs, the same move as the article’s planner, which returns a status error instead of guessing when a request is unclear. The probabilistic half now has a contract, and the framework enforces it, not a prompt. A fixed argument set also buys you caching, since you can key a transient on the args; free-text input never gives you that. The query side uses wc_get_orders(), which the WooCommerce docs recommend because it keeps working as order storage changes underneath.

The cost is that users can only ask what the route supports. You start with two or three metrics and extend the list by adding an enum value and a branch in the callback; the model doesn’t get to improvise. For store reporting I’d pay that cost every time. For genuine exploration, point the model at a read replica rather than production, and show the user which query produced the answer, along the lines of the transparency patterns I wrote about for WordPress interfaces.

If you’re adding an AI layer to a store and the numbers have to survive being checked against Analytics, this split is the kind of work I take on: a few deterministic endpoints and a model that only fills in arguments. Happy to look at what you have and say where the boundary should sit.

List what your site exposes and look for any AI-facing route that accepts free text where it should accept an enum. Run curl -s https://example.com/wp-json/ | jq '.routes | keys', or open /wp-json/ in a browser and scan the route names. If a route lets the model write the query, that’s where the wrong number will come from.

author avatar
Ahmad Wael
I'm a WordPress and WooCommerce developer with 15+ years of experience building custom e-commerce solutions and plugins. I specialize in PHP development, following WordPress coding standards to deliver clean, maintainable code. Currently, I'm exploring AI and e-commerce by building multi-agent systems and SaaS products that integrate technologies like Google Gemini API with WordPress platforms, approaching every project with a commitment to performance, security, and exceptional user experience.

Leave a Comment