Be precise about what this lesson is, and isn’t, before writing any code: there is no WooCommerce “recommendation API.” What this lesson builds is a custom, self-hosted hybrid recommendation engine, combining two different, real signals, behavioral data from actual orders and structural data from product attributes, into one blended ranking. Course 7 built the behavioral half of this on its own, real co-purchase counts from order line items. This lesson keeps that signal and adds the second one, so a product with thin purchase history isn’t left with no recommendations at all, and phrases the result the same grounded way every recommendation-adjacent ability in this course does.
Course 7’s
Project: AI Product Recommendations
lesson, this one extends its my_plugin_compute_co_purchases() function directly rather
than rewriting it.
Step 1: The behavioral signal, unchanged from Course 7
Reuse my_plugin_compute_co_purchases( $product_id, $limit ) exactly as that lesson
built it: real order-level co-occurrence counts from wc_get_orders(), cached for six
hours. Nothing about that function changes here, it remains one of the two inputs to
this lesson’s blend.
Step 2: A structural signal from category, tags, and price range
This signal needs no order history at all, which matters most for newer or low-volume products the behavioral signal can’t say anything useful about yet:
// File: my-plugin.php
function my_plugin_compute_structural_similarity( $product_id, $limit = 200 ) {
$cache_key = 'my_plugin_structural_sim_' . $product_id;
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$product = wc_get_product( $product_id );
if ( ! $product ) {
return array();
}
$categories = wc_get_product_term_ids( $product_id, 'product_cat' );
$tags = wc_get_product_term_ids( $product_id, 'product_tag' );
$price = (float) $product->get_price();
$price_low = $price * 0.7;
$price_high = $price * 1.3;
$candidates = wc_get_products( array(
'category' => $categories,
'limit' => $limit,
'exclude' => array( $product_id ),
'status' => 'publish',
) );
$scores = array();
foreach ( $candidates as $candidate ) {
$score = 0;
$candidate_cats = wc_get_product_term_ids( $candidate->get_id(), 'product_cat' );
$score += count( array_intersect( $categories, $candidate_cats ) ) * 2;
$candidate_tags = wc_get_product_term_ids( $candidate->get_id(), 'product_tag' );
$score += count( array_intersect( $tags, $candidate_tags ) );
$candidate_price = (float) $candidate->get_price();
if ( $candidate_price >= $price_low && $candidate_price <= $price_high ) {
$score += 1;
}
if ( $score > 0 ) {
$scores[ $candidate->get_id() ] = $score;
}
}
arsort( $scores );
$top = array_slice( $scores, 0, 10, true );
set_transient( $cache_key, $top, 12 * HOUR_IN_SECONDS );
return $top;
}
This score is deliberately simple and explainable: shared category counts double, shared tags count once, and a price within 30% either way adds one point. It’s a heuristic, not a machine-learned model, and it’s honest about that, the same way Course 7’s co-purchase counter was honest about being order-level co-occurrence rather than a weighted collaborative-filtering system.
Step 3: Blending both signals
// File: my-plugin.php
function my_plugin_compute_hybrid_recommendations( $product_id ) {
$behavioral = my_plugin_compute_co_purchases( $product_id );
$structural = my_plugin_compute_structural_similarity( $product_id );
$behavioral_weight = apply_filters( 'my_plugin_hybrid_behavioral_weight', 0.6 );
$structural_weight = 1 - $behavioral_weight;
$max_behavioral = ! empty( $behavioral ) ? max( $behavioral ) : 1;
$max_structural = ! empty( $structural ) ? max( $structural ) : 1;
$blended = array();
foreach ( array_unique( array_merge( array_keys( $behavioral ), array_keys( $structural ) ) ) as $id ) {
$b_score = ( $behavioral[ $id ] ?? 0 ) / $max_behavioral;
$s_score = ( $structural[ $id ] ?? 0 ) / $max_structural;
$blended[ $id ] = ( $b_score * $behavioral_weight ) + ( $s_score * $structural_weight );
}
arsort( $blended );
return array_slice( $blended, 0, 5, true );
}
Normalizing each signal to a 0 to 1 range before weighting matters here, co-purchase counts and structural points live on completely different scales, and blending raw counts would let whichever signal happens to produce bigger numbers dominate regardless of the weight you set.
apply_filters( 'my_plugin_hybrid_behavioral_weight', 0.6 ) lets a specific store lean
harder on behavioral data once it has enough order volume to trust it, or lean on
structural similarity for a newly launched catalog with little purchase history yet,
without touching this function’s code.
Step 4: Registering the ability and phrasing the result
// File: my-plugin.php
wp_register_ability( 'my-plugin/hybrid-recommend-products', array(
'label' => __( 'Hybrid product recommendations', 'my-plugin' ),
'description' => __( 'Recommends products using a blend of real co-purchase behavior and category/tag/price similarity, not a WooCommerce built-in feature.', 'my-plugin' ),
'input_schema' => array(
'type' => 'object',
'properties' => array( 'product_id' => array( 'type' => 'integer' ) ),
'required' => array( 'product_id' ),
),
'permission_callback' => '__return_true',
'execute_callback' => function ( $input ) {
$blended = my_plugin_compute_hybrid_recommendations( $input['product_id'] );
if ( empty( $blended ) ) {
return array( 'candidates' => array(), 'blurb' => '' );
}
$candidates = array();
foreach ( $blended as $id => $score ) {
$p = wc_get_product( $id );
if ( $p ) {
$candidates[] = array( 'id' => $id, 'name' => $p->get_name(), 'score' => round( $score, 3 ) );
}
}
$prompt = sprintf(
"Write a one-sentence product recommendation using ONLY these products, do " .
"not add any product not listed: %s",
wp_json_encode( $candidates )
);
return array(
'candidates' => $candidates,
'blurb' => trim( wp_ai_client_prompt( $prompt )->generate_text() ),
);
},
'meta' => array(
'annotations' => array( 'readonly' => true ),
'mcp' => array( 'public' => true ),
),
) );
Same anti-hallucination discipline as Course 7: the model phrases a sentence around candidates your own PHP already ranked, it never chooses or introduces a product.
Test it
wp-env run cli wp eval '
$result = wp_get_ability( "my-plugin/hybrid-recommend-products" )->execute( array( "product_id" => 42 ) );
print_r( $result["candidates"] );
echo $result["blurb"] . "\n";
'
Test against a product with rich order history and a recently added product with little or none. The second case is the actual proof this lesson earned its place: a behavioral-only system returns nothing useful for it, the hybrid blend still surfaces reasonable structural candidates.
my_plugin_compute_co_purchases() and my_plugin_compute_structural_similarity() each
carry their own transient cache with a different, appropriately chosen expiry, order
data shifts slower than a six-hour window really requires, catalog structure changes
even less often. Don’t share one cache key between them, invalidating one on purpose (a
new order comes in) shouldn’t force a recompute of the other.
Recap
This hybrid engine blends two independently computed, independently cached signals, real
co-purchase counts from order history and a category/tag/price structural score, into
one weighted ranking, normalized so neither signal’s raw scale dominates the other by
accident. It’s a genuinely custom pattern built entirely from your own store’s data and
wp_ai_client_prompt(), not a WooCommerce feature that exists to be configured, and it
covers the gap Course 7’s single-signal version left open: products with little or no
purchase history still get a reasonable recommendation.
Resources & further reading
- Project: AI Product Recommendations, this site
- wc_get_products() function reference, woocommerce.github.io
- Related Products in WooCommerce, woocommerce.com