diff --git a/.github/workflows/build_panel_assets.yml b/.github/workflows/build_panel_assets.yml index 6255b5307e..7b0653d6f4 100644 --- a/.github/workflows/build_panel_assets.yml +++ b/.github/workflows/build_panel_assets.yml @@ -19,6 +19,10 @@ on: - "packages/panel/resources/**" - "packages/panel/vite.config.ts" - "packages/panel/package.json" + - "packages/search-relevance/resources/js/**" + - "packages/search-relevance/resources/client/src/**" + - "packages/search-relevance/vite.config.js" + - "packages/search-relevance/package.json" - ".github/workflows/build_panel_assets.yml" permissions: @@ -58,8 +62,16 @@ jobs: run: npx vite build working-directory: packages/panel + # The search relevance add-on ships its compiled bundle the same way; + # its build/ directory is tracked, so the split carries it too. + - name: Build search relevance add-on bundle + run: npm run build --workspace @lunarphp/search-relevance-addon + + - name: Build search relevance storefront client + run: npm run build --workspace @lunarphp/search-relevance + - name: Commit built assets uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore(panel): rebuild compiled assets" - file_pattern: packages/panel/public/build + file_pattern: packages/panel/public/build packages/search-relevance/build packages/search-relevance/resources/client/dist diff --git a/.github/workflows/publish_npm.yml b/.github/workflows/publish_npm.yml index ad1e41551a..92b6320249 100644 --- a/.github/workflows/publish_npm.yml +++ b/.github/workflows/publish_npm.yml @@ -1,7 +1,7 @@ name: publish-npm -# Publishes the add-on-facing npm packages (@lunarphp/panel and -# @lunarphp/panel-vite-plugin) alongside the Composer monorepo split. +# Publishes the add-on-facing npm packages (@lunarphp/panel, +# @lunarphp/panel-vite-plugin and @lunarphp/search-relevance) alongside the Composer monorepo split. # Skips any version already on the registry, so tags that don't bump the # npm package versions pass without churn. # @@ -33,6 +33,8 @@ jobs: directory: packages/panel/resources/panel-package - package: "@lunarphp/panel-vite-plugin" directory: packages/panel/resources/package + - package: "@lunarphp/search-relevance" + directory: packages/search-relevance/resources/client steps: - name: Checkout code uses: actions/checkout@v6 diff --git a/.github/workflows/split_packages.yml b/.github/workflows/split_packages.yml index fdc8592a84..376e629a45 100644 --- a/.github/workflows/split_packages.yml +++ b/.github/workflows/split_packages.yml @@ -20,6 +20,7 @@ jobs: - "panel-addon-example" - "paypal" - "search" + - "search-relevance" - "stripe" - "table-rate-shipping" - "upgrade" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f19566bd7..1cf8096415 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.4"]' || '["8.4","8.5"]') }} laravel: ["12.*", "13.*"] dependency-version: [prefer-stable] - testsuite: [core, admin, panel, filament, shipping, stripe, paypal, search, upgrade] + testsuite: [core, admin, panel, filament, shipping, stripe, paypal, search, search-relevance, upgrade] include: - laravel: 12.* testbench: 10.* @@ -196,6 +196,15 @@ jobs: - name: Build example add-on bundle run: npm run build --workspace @lunarphp/panel-addon-example + - name: Build search relevance panel add-on bundle + run: npm run build --workspace @lunarphp/search-relevance-addon + + - name: Test and build the search relevance storefront client + run: | + npm test --workspace @lunarphp/search-relevance + npm run type-check --workspace @lunarphp/search-relevance + npm run build --workspace @lunarphp/search-relevance + # Fails when a publishable npm package's content no longer matches the # tarball published under its current version — the reminder to bump. # Relies on the panel build above for fresh @lunarphp/panel types. diff --git a/TODO.md b/TODO.md index 8a81b47be2..35a9323731 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,7 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting. ## Done +- Search relevance — request/results pipelines in `search`, and the `search-relevance` add-on: logging, scoring, learned reranking, panel section (spec 0084) - Attribute field-type configuration schema — field types declare their settings once in core; the panel and Filament bridge both render from the descriptors (spec 0054) - Filament admin & bridge hardening — standalone bridge, dead hooks and config, locale nav groups, guard and asset id (spec 0076) - First staff account creation in core — `lunar:create-admin` moves out of the Filament admin; panel install offers it (spec 0075) diff --git a/composer.json b/composer.json index 891af14e05..8235247197 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "files": [ "packages/admin/src/helpers.php", "packages/core/src/helpers.php", - "packages/search/src/helpers.php" + "packages/search/src/helpers.php", + "packages/search-relevance/src/helpers.php" ], "psr-4": { "Lunar\\Core\\": "packages/core/src", @@ -74,6 +75,8 @@ "Lunar\\Panel\\Database\\Factories\\": "packages/panel/database/factories", "Lunar\\Paypal\\": "packages/paypal/src/", "Lunar\\Search\\": "packages/search/src/", + "Lunar\\SearchRelevance\\": "packages/search-relevance/src/", + "Lunar\\SearchRelevance\\Database\\Factories\\": "packages/search-relevance/database/factories", "Lunar\\Shipping\\": "packages/table-rate-shipping/src", "Lunar\\Shipping\\Database\\Factories\\": "packages/table-rate-shipping/database/factories", "Lunar\\Stripe\\": "packages/stripe/src/", @@ -94,6 +97,7 @@ "Lunar\\Shipping\\Tests\\": "packages/table-rate-shipping/tests", "Lunar\\Tests\\Stripe\\": "tests/stripe", "Lunar\\Tests\\Search\\": "tests/search", + "Lunar\\Tests\\SearchRelevance\\": "tests/search-relevance", "Lunar\\Tests\\Upgrade\\": "tests/upgrade", "LunarPanelExample\\": "packages/panel-addon-example/src/" } @@ -103,6 +107,7 @@ "name": [ "Table Rate Shipping", "Search", + "Search Relevance", "Meilisearch", "Paypal Payments", "Stripe Payments", @@ -116,6 +121,7 @@ "Lunar\\Paypal\\PaypalServiceProvider", "Lunar\\Meilisearch\\MeilisearchServiceProvider", "Lunar\\Search\\SearchServiceProvider", + "Lunar\\SearchRelevance\\SearchRelevanceServiceProvider", "Lunar\\Filament\\LunarFilamentServiceProvider", "Lunar\\Admin\\LunarPanelProvider", "Lunar\\Shipping\\ShippingServiceProvider", @@ -136,6 +142,7 @@ "lunarphp/panel-addon-example": "self.version", "lunarphp/paypal": "self.version", "lunarphp/search": "self.version", + "lunarphp/search-relevance": "self.version", "lunarphp/stripe": "self.version", "lunarphp/table-rate-shipping": "self.version", "lunarphp/upgrade": "self.version" diff --git a/package.json b/package.json index 4fe6256e60..c6ddd00145 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "workspaces": [ "packages/panel/resources/package", "packages/panel/resources/panel-package", - "packages/panel-addon-example" + "packages/panel-addon-example", + "packages/search-relevance", + "packages/search-relevance/resources/client" ], "scripts": { "check-npm-drift": "node scripts/check-npm-drift.mjs" diff --git a/packages/core/src/Search/ProductIndexer.php b/packages/core/src/Search/ProductIndexer.php index ffc6032617..9c7df05335 100644 --- a/packages/core/src/Search/ProductIndexer.php +++ b/packages/core/src/Search/ProductIndexer.php @@ -61,6 +61,20 @@ public function toSearchableArray(Model $model): array $data['skus'] = $model->variants->pluck('sku')->toArray(); + // Uppercased with separators stripped, so a partial code typed without + // the printed hyphens (HAGMB) still prefix-matches HAG-MB-32A. + $data['skus_normalised'] = $model->variants->pluck('sku') + ->filter() + ->map(fn (string $sku) => strtoupper(preg_replace('/[^A-Za-z0-9]+/', '', $sku))) + ->filter() + ->values() + ->toArray(); + return $data; } + + public function getExactMatchFields(): array + { + return ['skus', 'skus_normalised']; + } } diff --git a/packages/core/src/Search/ScoutIndexer.php b/packages/core/src/Search/ScoutIndexer.php index a2baabe87b..3897c31d4c 100644 --- a/packages/core/src/Search/ScoutIndexer.php +++ b/packages/core/src/Search/ScoutIndexer.php @@ -54,6 +54,19 @@ public function getFilterableFields(): array ]; } + /** + * Fields that must match exactly, with typo tolerance disabled by the + * engine setup (`lunar:meilisearch:setup` applies these). Part-number + * style codes match random tokens under default typo tolerance. + * search-relevance's part-number retrieval searches these fields alone, + * so list every field a shopper types a code into (SKU, supplier part + * number, barcode). + */ + public function getExactMatchFields(): array + { + return []; + } + public function toSearchableArray(Model $model): array { if (! $model->attribute_data) { diff --git a/packages/meilisearch/src/Console/MeilisearchSetup.php b/packages/meilisearch/src/Console/MeilisearchSetup.php index 91001e335c..c48e1dc097 100644 --- a/packages/meilisearch/src/Console/MeilisearchSetup.php +++ b/packages/meilisearch/src/Console/MeilisearchSetup.php @@ -69,6 +69,17 @@ public function handle(EngineManager $engine): void ); $this->engine->waitForTask($task['taskUid']); + $indexer = $model->indexer(); + $exactFields = method_exists($indexer, 'getExactMatchFields') ? $indexer->getExactMatchFields() : []; + + if ($exactFields) { + $this->info("Disable typo tolerance on exact-match fields for {$searchable}"); + $task = $index->updateTypoTolerance([ + 'disableOnAttributes' => $exactFields, + ]); + $this->engine->waitForTask($task['taskUid']); + } + $this->newLine(); } } diff --git a/packages/search-relevance/.github/workflows/close-pull-request.yml b/packages/search-relevance/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000..db848086f5 --- /dev/null +++ b/packages/search-relevance/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on the Lunar Search Relevance Repo which is a read-only sub split of `lunarphp/lunar`. Please submit your PR on the https://github.com/lunarphp/lunar repository.

Thanks!" diff --git a/packages/search-relevance/.gitignore b/packages/search-relevance/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/packages/search-relevance/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/search-relevance/build/assets/addon-_4607Chj.js b/packages/search-relevance/build/assets/addon-_4607Chj.js new file mode 100644 index 0000000000..7df5197f77 --- /dev/null +++ b/packages/search-relevance/build/assets/addon-_4607Chj.js @@ -0,0 +1 @@ +(function(e,h,x,n){"use strict";const b={class:"flex items-start gap-4 mb-4"},N={class:"flex-1 min-w-0"},w={class:"m-0 mb-1 text-sm font-semibold tracking-[-0.01em] text-ink-900"},V={key:0,class:"text-xs text-ink-500 leading-normal max-w-[640px]"},E={key:0,class:"flex gap-1.5 shrink-0"},C={class:"min-w-0"},$={key:0,class:"mt-2 text-[11px] text-ink-500"},g=e.defineComponent({__name:"TableBlock",props:{title:{},description:{default:""},bordered:{type:Boolean,default:!1}},setup(r){return(a,t)=>(e.openBlock(),e.createElementBlock("section",{class:e.normalizeClass(["min-w-0",r.bordered?"py-6 border-b border-line first:pt-1 last:border-b-0 last:pb-7":""])},[e.createElementVNode("div",b,[e.createElementVNode("div",N,[e.createElementVNode("h2",w,e.toDisplayString(r.title),1),r.description?(e.openBlock(),e.createElementBlock("div",V,e.toDisplayString(r.description),1)):e.createCommentVNode("",!0)]),a.$slots.actions?(e.openBlock(),e.createElementBlock("div",E,[e.renderSlot(a.$slots,"actions")])):e.createCommentVNode("",!0)]),e.createElementVNode("div",C,[e.renderSlot(a.$slots,"default")]),a.$slots.footer?(e.openBlock(),e.createElementBlock("div",$,[e.renderSlot(a.$slots,"footer")])):e.createCommentVNode("",!0)],2))}}),S={"data-screen-label":"Search relevance",class:"contents"},B=["aria-label"],D=["aria-pressed","onClick"],q={class:"px-4 sm:px-5 lg:px-7 max-w-[1400px] w-full mx-auto pt-5 pb-7"},T={class:"grid grid-cols-2 lg:grid-cols-5 gap-2.5"},P={class:"mt-5 grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] items-start"},z={class:"min-w-0"},L={class:"mt-6 grid gap-5 lg:grid-cols-2"},K={class:"text-[12px] text-ink-500 mb-3"},I={class:"grid grid-cols-2 gap-2.5"},F={class:"mt-3 text-[12px] text-ink-500"},Z={class:"text-ink-900 font-medium"},j={key:1,class:"text-[12.5px] text-ink-700"},H=e.defineComponent({__name:"Index",props:{range:{},ranges:{},kpis:{},uplift:{},top_queries:{},zero_result_queries:{},no_click_queries:{},urls:{}},setup(r){const a=r,{t}=x.useI18n(),c=e.computed(()=>[{label:t("search-relevance::panel.nav_group")},{label:t("search-relevance::panel.title"),current:!0}]),p=_=>{h.router.get(a.urls.index,{range:_},{preserveState:!0,preserveScroll:!0,replace:!0})},o=_=>`${_}%`,i=[{key:"query",label:t("search-relevance::panel.column_query"),width:"minmax(0,1.6fr)"},{key:"searches",label:t("search-relevance::panel.column_searches"),width:"110px",align:"right"},{key:"clicks",label:t("search-relevance::panel.column_clicks"),width:"100px",align:"right"},{key:"conversions",label:t("search-relevance::panel.column_conversions"),width:"110px",align:"right"},{key:"conversion_rate",label:t("search-relevance::panel.column_conversion_rate"),width:"100px",align:"right"}],m=[{key:"query",label:t("search-relevance::panel.column_query"),width:"minmax(160px,1fr)"},{key:"searches",label:t("search-relevance::panel.column_searches"),width:"110px",align:"right"}],l=_=>_.url,k=e.computed(()=>a.uplift.searches===0||a.uplift.mrr_ranked===a.uplift.mrr_shown?"neutral":a.uplift.mrr_ranked>a.uplift.mrr_shown?"sage":"danger");return(_,u)=>(e.openBlock(),e.createElementBlock("div",S,[e.createVNode(e.unref(n.Breadcrumbs),{items:c.value},null,8,["items"]),e.createVNode(e.unref(n.PageHeader),{title:e.unref(t)("search-relevance::panel.title"),description:e.unref(t)("search-relevance::panel.description"),icon:"chart"},{actions:e.withCtx(()=>[e.createElementVNode("div",{class:"inline-flex border border-line-strong rounded-md p-0.5 bg-surface-2 gap-0.5 shadow-sm",role:"group","aria-label":e.unref(t)("search-relevance::panel.range")},[(e.openBlock(!0),e.createElementBlock(e.Fragment,null,e.renderList(r.ranges,s=>(e.openBlock(),e.createElementBlock("button",{key:s.value,type:"button",class:e.normalizeClass(["h-[26px] px-2.5 rounded-sm text-[12px] font-medium transition-[background-color,color,box-shadow] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sage/35",r.range===s.value?"bg-surface text-ink-900 shadow-sm":"text-ink-500 hover:text-ink-900"]),"aria-pressed":r.range===s.value,onClick:f=>p(s.value)},e.toDisplayString(s.label),11,D))),128))],8,B)]),_:1},8,["title","description"]),e.createElementVNode("div",q,[e.createVNode(e.unref(n.PageZone),{region:"main",position:"before"}),e.createElementVNode("div",T,[e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.kpi_searches"),value:r.kpis.searches,icon:"search",tone:"sage"},null,8,["label","value"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.kpi_click_through_rate"),value:o(r.kpis.click_through_rate),icon:"eye"},null,8,["label","value"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.kpi_conversion_rate"),value:o(r.kpis.conversion_rate),icon:"cart"},null,8,["label","value"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.kpi_zero_result_rate"),value:o(r.kpis.zero_result_rate),icon:"alert",tone:r.kpis.zero_result_rate>0?"warn":"neutral"},null,8,["label","value","tone"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.kpi_mean_click_position"),value:r.kpis.mean_click_position??"-",hint:r.kpis.mean_click_position===null?e.unref(t)("search-relevance::panel.kpi_no_clicks"):"",icon:"sliders"},null,8,["label","value","hint"])]),e.createElementVNode("div",P,[e.createElementVNode("div",z,[e.createVNode(g,{title:e.unref(t)("search-relevance::panel.top_queries_title")},{default:e.withCtx(()=>[e.createVNode(e.unref(n.DataTable),{columns:i,rows:r.top_queries,"row-key":"query","row-to":l,"empty-text":e.unref(t)("search-relevance::panel.empty_queries")},{"cell-conversion_rate":e.withCtx(({value:s})=>[e.createTextVNode(e.toDisplayString(o(s)),1)]),_:1},8,["rows","empty-text"])]),_:1},8,["title"]),e.createElementVNode("div",L,[e.createVNode(g,{title:e.unref(t)("search-relevance::panel.zero_result_title"),description:e.unref(t)("search-relevance::panel.zero_result_description")},{default:e.withCtx(()=>[e.createVNode(e.unref(n.DataTable),{columns:m,rows:r.zero_result_queries,"row-key":"query","row-to":l,"empty-text":e.unref(t)("search-relevance::panel.zero_result_empty")},null,8,["rows","empty-text"])]),_:1},8,["title","description"]),e.createVNode(g,{title:e.unref(t)("search-relevance::panel.no_click_title"),description:e.unref(t)("search-relevance::panel.no_click_description")},{default:e.withCtx(()=>[e.createVNode(e.unref(n.DataTable),{columns:m,rows:r.no_click_queries,"row-key":"query","row-to":l,"empty-text":e.unref(t)("search-relevance::panel.no_click_empty")},null,8,["rows","empty-text"])]),_:1},8,["title","description"])])]),e.createVNode(e.unref(n.SideCard),{title:e.unref(t)("search-relevance::panel.uplift_title")},{default:e.withCtx(()=>[e.createElementVNode("p",K,e.toDisplayString(e.unref(t)("search-relevance::panel.uplift_description")),1),r.uplift.searches>0?(e.openBlock(),e.createElementBlock(e.Fragment,{key:0},[e.createElementVNode("div",I,[e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.uplift_mrr_shown"),value:r.uplift.mrr_shown},null,8,["label","value"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.uplift_mrr_ranked"),value:r.uplift.mrr_ranked,tone:k.value},null,8,["label","value","tone"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.uplift_improved"),value:o(r.uplift.improved_share),tone:"sage"},null,8,["label","value"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(t)("search-relevance::panel.uplift_worsened"),value:o(r.uplift.worsened_share),tone:r.uplift.worsened_share>0?"warn":"neutral"},null,8,["label","value","tone"])]),e.createElementVNode("div",F,[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.uplift_searches"))+": ",1),e.createElementVNode("span",Z,e.toDisplayString(r.uplift.searches),1)])],64)):(e.openBlock(),e.createElementBlock("p",j,e.toDisplayString(e.unref(t)("search-relevance::panel.uplift_empty")),1))]),_:1},8,["title"])]),e.createVNode(e.unref(n.PageZone),{region:"main",position:"after"})])]))}}),M={class:"flex items-center gap-2 min-w-0"},Q={class:"flex-1 h-1.5 rounded-full bg-surface-2 border border-line overflow-hidden","aria-hidden":"true"},R={class:"text-[12px] text-ink-700 [font-variant-numeric:tabular-nums] w-10 text-right"},y=e.defineComponent({__name:"RelativeBar",props:{value:{}},setup(r){const a=r,t=e.computed(()=>`${Math.round(Math.max(0,Math.min(1,a.value??0))*100)}%`);return(c,p)=>(e.openBlock(),e.createElementBlock("div",M,[e.createElementVNode("div",Q,[e.createElementVNode("div",{class:"h-full bg-sage rounded-full",style:e.normalizeStyle({width:t.value})},null,4)]),e.createElementVNode("span",R,e.toDisplayString(r.value===null?"-":r.value.toFixed(2)),1)]))}}),U={"data-screen-label":"Search relevance query",class:"contents"},W={class:"px-4 sm:px-5 lg:px-7 max-w-[1400px] w-full mx-auto pt-5 pb-7"},A={class:"grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] items-start"},O={class:"min-w-0"},G={class:"min-w-0"},J={class:"truncate"},X={class:"text-[11px] text-ink-500 truncate"},Y={class:"text-[12px] text-ink-500 mb-2"},v={key:0,class:"flex flex-col gap-2"},ee={key:1,class:"text-[12.5px] text-ink-700"},te={class:"text-ink-700"},re=e.defineComponent({__name:"Query",props:{query:{},model_type:{},learned:{},variants:{},excluded:{},reset_at:{},urls:{}},setup(r){const a=r,{t}=x.useI18n(),c=e.computed(()=>[{key:"exclude",label:t("search-relevance::panel.override_exclude"),icon:"x",method:"post",primary:!1,confirmation:t("search-relevance::panel.override_exclude_confirm")}]),p=e.ref(!1),o=()=>{h.router.post(a.urls.reset,{},{preserveScroll:!0})},i=s=>{h.router.delete(s.url,{preserveScroll:!0})},m=e.computed(()=>[{label:t("search-relevance::panel.nav_group")},{label:t("search-relevance::panel.title"),href:a.urls.index},{label:t("search-relevance::panel.query_title"),current:!0}]),l=[{key:"name",label:t("search-relevance::panel.column_product"),width:"minmax(200px,1fr)"},{key:"relative",label:t("search-relevance::panel.column_relative"),width:"140px"},{key:"clicks",label:t("search-relevance::panel.column_clicks"),width:"64px",align:"right"},{key:"baskets",label:t("search-relevance::panel.column_baskets"),width:"72px",align:"right"},{key:"purchases",label:t("search-relevance::panel.column_purchases"),width:"84px",align:"right"},{key:"sessions",label:t("search-relevance::panel.column_sessions"),width:"76px",align:"right"},{key:"typical_position",label:t("search-relevance::panel.column_typical_position"),width:"84px",align:"right"}],k=[{key:"raw_query",label:t("search-relevance::panel.column_raw_query"),width:"minmax(0,1fr)"},{key:"searches",label:t("search-relevance::panel.column_searches"),width:"90px",align:"right"}],_=s=>s.url??null,u=s=>{if(!s)return"-";const f=new Date(s.replace(" ","T"));return Number.isNaN(f.getTime())?s:f.toLocaleDateString(void 0,{day:"numeric",month:"short",year:"numeric"})};return(s,f)=>(e.openBlock(),e.createElementBlock("div",U,[e.createVNode(e.unref(n.Breadcrumbs),{items:m.value},null,8,["items"]),e.createVNode(e.unref(n.PageHeader),{title:r.query,description:e.unref(t)("search-relevance::panel.query_description"),icon:"search"},{actions:e.withCtx(()=>[e.createVNode(e.unref(n.Button),{icon:"refresh",onClick:f[0]||(f[0]=d=>p.value=!0)},{default:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.override_reset")),1)]),_:1})]),_:1},8,["title","description"]),e.createElementVNode("div",W,[e.createVNode(e.unref(n.PageZone),{region:"main",position:"before"}),e.createElementVNode("div",A,[e.createElementVNode("div",O,[e.createVNode(g,{title:e.unref(t)("search-relevance::panel.query_learned_title")},e.createSlots({default:e.withCtx(()=>[e.createVNode(e.unref(n.DataTable),{columns:l,rows:r.learned,"row-key":"product_id","row-to":_,"row-actions":c.value,"empty-text":e.unref(t)("search-relevance::panel.query_learned_empty")},{"cell-name":e.withCtx(({row:d})=>[e.createElementVNode("div",G,[e.createElementVNode("div",J,e.toDisplayString(d.name),1),e.createElementVNode("div",X,e.toDisplayString(e.unref(t)("search-relevance::panel.column_last_event"))+": "+e.toDisplayString(u(d.last_event_at)),1)])]),"cell-relative":e.withCtx(({row:d})=>[e.createVNode(e.unref(n.Tooltip),{text:`${e.unref(t)("search-relevance::panel.relative_tooltip")} ${e.unref(t)("search-relevance::panel.column_score")}: ${d.score}`},{default:e.withCtx(()=>[e.createVNode(y,{value:d.relative},null,8,["value"])]),_:2},1032,["text"])]),"cell-typical_position":e.withCtx(({value:d})=>[e.createVNode(e.unref(n.Tooltip),{text:e.unref(t)("search-relevance::panel.typical_position_tooltip")},{default:e.withCtx(()=>[e.createElementVNode("span",null,e.toDisplayString(d===null?"-":d),1)]),_:2},1032,["text"])]),_:1},8,["rows","row-actions","empty-text"])]),_:2},[r.reset_at?{name:"footer",fn:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.reset_at",{date:u(r.reset_at)})),1)]),key:"0"}:void 0]),1032,["title"]),e.createVNode(e.unref(n.SideCard),{title:e.unref(t)("search-relevance::panel.excluded_title"),class:"mt-6"},{default:e.withCtx(()=>[e.createElementVNode("p",Y,e.toDisplayString(e.unref(t)("search-relevance::panel.excluded_description")),1),r.excluded.length?(e.openBlock(),e.createElementBlock("ul",v,[(e.openBlock(!0),e.createElementBlock(e.Fragment,null,e.renderList(r.excluded,d=>(e.openBlock(),e.createElementBlock("li",{key:d.product_id,class:"flex items-center justify-between gap-3 text-[12.5px] text-ink-900"},[e.createElementVNode("span",null,e.toDisplayString(d.name),1),e.createVNode(e.unref(n.Button),{size:"sm",onClick:Ie=>i(d)},{default:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.override_include")),1)]),_:1},8,["onClick"])]))),128))])):(e.openBlock(),e.createElementBlock("p",ee,e.toDisplayString(e.unref(t)("search-relevance::panel.excluded_empty")),1))]),_:1},8,["title"])]),e.createVNode(g,{title:e.unref(t)("search-relevance::panel.query_variants_title")},{footer:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.query_model"))+": ",1),e.createElementVNode("span",te,e.toDisplayString(r.model_type),1)]),default:e.withCtx(()=>[e.createVNode(e.unref(n.DataTable),{columns:k,rows:r.variants,"row-key":"raw_query","empty-text":e.unref(t)("search-relevance::panel.query_variants_empty")},null,8,["rows","empty-text"])]),_:1},8,["title"])]),e.createVNode(e.unref(n.PageZone),{region:"main",position:"after"})]),e.createVNode(e.unref(n.ConfirmDialog),{open:p.value,"onUpdate:open":f[1]||(f[1]=d=>p.value=d),title:e.unref(t)("search-relevance::panel.override_reset_title"),description:e.unref(t)("search-relevance::panel.override_reset_description"),tone:"danger",onConfirm:o},null,8,["open","title","description"])]))}}),ne={"data-screen-label":"Search relevance product",class:"contents"},ae={class:"px-4 sm:px-5 lg:px-7 max-w-[1400px] w-full mx-auto pt-5 pb-7"},le=e.defineComponent({__name:"Product",props:{product:{},queries:{},urls:{}},setup(r){const a=r,{t}=x.useI18n(),c=e.computed(()=>[{label:t("search-relevance::panel.nav_group")},{label:t("search-relevance::panel.title"),href:a.urls.index},{label:a.product.name,current:!0}]),p=[{key:"query",label:t("search-relevance::panel.column_query"),width:"minmax(160px,1fr)"},{key:"relative",label:t("search-relevance::panel.column_relative"),width:"160px"},{key:"clicks",label:t("search-relevance::panel.column_clicks"),width:"80px",align:"right"},{key:"baskets",label:t("search-relevance::panel.column_baskets"),width:"80px",align:"right"},{key:"purchases",label:t("search-relevance::panel.column_purchases"),width:"90px",align:"right"}],o=i=>i.url;return(i,m)=>(e.openBlock(),e.createElementBlock("div",ne,[e.createVNode(e.unref(n.Breadcrumbs),{items:c.value},null,8,["items"]),e.createVNode(e.unref(n.PageHeader),{title:r.product.name,description:e.unref(t)("search-relevance::panel.product_description"),icon:"box"},{actions:e.withCtx(()=>[e.createVNode(e.unref(n.Button),{icon:"edit",onClick:m[0]||(m[0]=l=>e.unref(h.router).visit(r.product.edit_url))},{default:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.product_edit")),1)]),_:1})]),_:1},8,["title","description"]),e.createElementVNode("div",ae,[e.createVNode(e.unref(n.PageZone),{region:"main",position:"before"}),e.createVNode(g,{title:e.unref(t)("search-relevance::panel.product_title")},{default:e.withCtx(()=>[e.createVNode(e.unref(n.DataTable),{columns:p,rows:r.queries,"row-key":"query","row-to":o,"empty-text":e.unref(t)("search-relevance::panel.product_empty")},{"cell-relative":e.withCtx(({value:l})=>[e.createVNode(y,{value:l},null,8,["value"])]),_:1},8,["rows","empty-text"])]),_:1},8,["title"]),e.createVNode(e.unref(n.PageZone),{region:"main",position:"after"})])]))}}),ce={"data-screen-label":"Search relevance settings",class:"flex flex-col gap-6"},se={class:"text-[13px] font-semibold text-ink-900 mb-2"},oe={class:"flex items-center gap-3"},ie={class:"text-[12.5px] text-ink-700"},de={class:"mt-2 text-[11px] text-ink-500"},pe={class:"text-[13px] font-semibold text-ink-900 mb-2"},me={class:"grid grid-cols-3 gap-2.5 max-w-xl"},he={class:"text-[11px] text-ink-500"},_e={class:"text-[15px] font-semibold text-ink-900 [font-variant-numeric:tabular-nums]"},fe={class:"text-[13px] font-semibold text-ink-900 mb-2"},xe={class:"flex flex-col gap-1.5 max-w-xl text-[12.5px]"},ge={class:"flex items-center justify-between gap-3 rounded-md border border-line px-3 py-2"},ke={class:"text-ink-500"},ye={class:"text-ink-900 font-medium"},ue={class:"px-3 text-[11px] text-ink-500"},be={class:"text-[13px] font-semibold text-ink-900 mb-1"},Ne={class:"text-[11px] text-ink-500 mb-2"},we={class:"flex flex-col gap-1.5 max-w-xl"},Ve=["title"],Ee={class:"text-[11.5px] text-ink-700"},Ce=e.defineComponent({layout:(r,a)=>a,__name:"Index",props:{mode:{},mode_env:{},weights:{},schedule:{},last_run:{},versions:{}},setup(r){const a=r,{t}=x.useI18n(),c=e.computed(()=>t(`search-relevance::panel.settings_mode_help_${a.mode}`)),p=e.computed(()=>a.mode==="on"?"sage":a.mode==="shadow"?"warn":"archived"),o=i=>{if(!i)return t("search-relevance::panel.settings_last_run_never");const m=new Date(i.replace(" ","T"));return Number.isNaN(m.getTime())?i:m.toLocaleString()};return(i,m)=>(e.openBlock(),e.createBlock(e.unref(n.SettingsShell),{title:e.unref(t)("search-relevance::panel.settings_title"),description:e.unref(t)("search-relevance::panel.settings_description")},{default:e.withCtx(()=>[e.createElementVNode("div",ce,[e.createElementVNode("div",null,[e.createElementVNode("h3",se,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_mode")),1),e.createElementVNode("div",oe,[e.createVNode(e.unref(n.StatusBadge),{tone:p.value,size:"sm",dot:""},{default:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)(`search-relevance::panel.settings_mode_${r.mode}`)),1)]),_:1},8,["tone"]),e.createElementVNode("span",ie,e.toDisplayString(c.value),1)]),e.createElementVNode("p",de,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_mode_config",{env:r.mode_env})),1)]),e.createElementVNode("div",null,[e.createElementVNode("h3",pe,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_weights")),1),e.createElementVNode("dl",me,[(e.openBlock(!0),e.createElementBlock(e.Fragment,null,e.renderList(r.weights,(l,k)=>(e.openBlock(),e.createElementBlock("div",{key:k,class:"rounded-md border border-line bg-surface-2 px-3 py-2"},[e.createElementVNode("dt",he,e.toDisplayString(e.unref(t)(`search-relevance::panel.settings_weight_${k}`)),1),e.createElementVNode("dd",_e,e.toDisplayString(l),1)]))),128))])]),e.createElementVNode("div",null,[e.createElementVNode("h3",fe,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_scoring")),1),e.createElementVNode("dl",xe,[e.createElementVNode("div",ge,[e.createElementVNode("dt",ke,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_last_run")),1),e.createElementVNode("dd",ye,e.toDisplayString(o(r.last_run)),1)]),e.createElementVNode("div",ue,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_schedule",{time:r.schedule})),1)])]),e.createElementVNode("div",null,[e.createElementVNode("h3",be,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_versions")),1),e.createElementVNode("p",Ne,e.toDisplayString(e.unref(t)("search-relevance::panel.settings_versions_help")),1),e.createElementVNode("ul",we,[(e.openBlock(!0),e.createElementBlock(e.Fragment,null,e.renderList(r.versions,l=>(e.openBlock(),e.createElementBlock("li",{key:l.model,class:"flex items-center justify-between gap-3 rounded-md border border-line px-3 py-2"},[e.createElementVNode("span",{class:"text-[12.5px] text-ink-900 font-medium",title:l.model},e.toDisplayString(l.label),9,Ve),e.createElementVNode("code",Ee,e.toDisplayString(l.version),1)]))),128))])])])]),_:1},8,["title","description"]))}}),$e={class:"flex flex-col gap-3"},Se={class:"grid grid-cols-2 gap-2.5"},Be=e.defineComponent({__name:"SearchConversionWidget",props:{data:{},range:{}},setup(r){const{t:a}=x.useI18n();return(t,c)=>(e.openBlock(),e.createElementBlock("div",$e,[e.createElementVNode("div",Se,[e.createVNode(e.unref(n.KpiCard),{label:e.unref(a)("search-relevance::panel.widget_searches"),value:r.data.searches,icon:"search",delta:r.data.searches_delta},null,8,["label","value","delta"]),e.createVNode(e.unref(n.KpiCard),{label:e.unref(a)("search-relevance::panel.widget_conversion_rate"),value:`${r.data.conversion_rate}%`,icon:"cart",tone:"sage",delta:r.data.conversion_delta},null,8,["label","value","delta"])]),e.createVNode(e.unref(h.Link),{href:r.data.url,class:"text-[12px] text-ink-700 underline underline-offset-2 hover:text-ink-900 self-start"},{default:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(a)("search-relevance::panel.widget_view")),1)]),_:1},8,["href"])]))}}),De={class:"flex flex-col gap-2"},qe={class:"flex items-center justify-between gap-3 text-[12.5px]"},Te={class:"text-ink-900 font-medium truncate"},Pe={class:"text-ink-500 shrink-0 [font-variant-numeric:tabular-nums]"},ze={class:"mt-3 flex items-center justify-between gap-3 text-[11px]"},Le={class:"text-ink-500"},Ke=e.defineComponent({__name:"ProductSearchPerformance",props:{product:{}},setup(r){var o;const a=r,{t}=x.useI18n(),c=e.ref(null),p=((o=h.usePage().props.panel)==null?void 0:o.path)??"panel";return e.onMounted(async()=>{if(a.product)try{const i=await n.http.get(`/${p}/search-relevance/products/${a.product.id}/summary`);i.queries.length&&(c.value=i)}catch{c.value=null}}),(i,m)=>c.value?(e.openBlock(),e.createBlock(e.unref(n.SideCard),{key:0,title:e.unref(t)("search-relevance::panel.product_card_title")},{default:e.withCtx(()=>[e.createElementVNode("ul",De,[(e.openBlock(!0),e.createElementBlock(e.Fragment,null,e.renderList(c.value.queries,l=>(e.openBlock(),e.createElementBlock("li",{key:l.query},[e.createVNode(e.unref(h.Link),{href:l.url,class:"block rounded-sm -mx-1 px-1 py-0.5 hover:bg-surface-2"},{default:e.withCtx(()=>[e.createElementVNode("div",qe,[e.createElementVNode("span",Te,e.toDisplayString(l.query),1),e.createElementVNode("span",Pe,e.toDisplayString(l.purchases)+" / "+e.toDisplayString(l.clicks),1)]),l.relative!==null?(e.openBlock(),e.createBlock(y,{key:0,value:l.relative,class:"mt-1"},null,8,["value"])):e.createCommentVNode("",!0)]),_:2},1032,["href"])]))),128))]),e.createElementVNode("div",ze,[e.createElementVNode("span",Le,e.toDisplayString(c.value.total>c.value.queries.length?e.unref(t)("search-relevance::panel.product_card_more",{count:c.value.total-c.value.queries.length}):""),1),e.createVNode(e.unref(h.Link),{href:c.value.url,class:"text-ink-700 underline underline-offset-2 hover:text-ink-900"},{default:e.withCtx(()=>[e.createTextVNode(e.toDisplayString(e.unref(t)("search-relevance::panel.product_view_report")),1)]),_:1},8,["href"])])]),_:1},8,["title"])):e.createCommentVNode("",!0)}});window.LunarPanel.registerPages({"search-relevance::Index":H,"search-relevance::Query":re,"search-relevance::Product":le,"search-relevance::Settings/Index":Ce}),window.LunarPanel.registerComponents("search-relevance",{SearchConversionWidget:Be,ProductSearchPerformance:Ke})})(Vue,InertiaVue3,VueI18n,LunarPanelUI); diff --git a/packages/search-relevance/build/manifest.json b/packages/search-relevance/build/manifest.json new file mode 100644 index 0000000000..33da44108c --- /dev/null +++ b/packages/search-relevance/build/manifest.json @@ -0,0 +1,8 @@ +{ + "resources/js/addon.ts": { + "file": "assets/addon-_4607Chj.js", + "name": "addon", + "src": "resources/js/addon.ts", + "isEntry": true + } +} \ No newline at end of file diff --git a/packages/search-relevance/composer.json b/packages/search-relevance/composer.json new file mode 100644 index 0000000000..912bbd71d7 --- /dev/null +++ b/packages/search-relevance/composer.json @@ -0,0 +1,53 @@ +{ + "name": "lunarphp/search-relevance", + "type": "library", + "description": "Learned search ranking for LunarPHP: logs searches and shopper events, scores them nightly and reranks results, engine-agnostic.", + "keywords": [ + "lunarphp", + "laravel", + "ecommerce", + "e-commerce", + "headless", + "store", + "shop", + "search", + "relevance", + "ranking" + ], + "license": "MIT", + "authors": [ + { + "name": "Lunar", + "homepage": "https://lunarphp.io/" + } + ], + "require": { + "php": "^8.4", + "lunarphp/core": "self.version", + "lunarphp/search": "self.version" + }, + "suggest": { + "lunarphp/panel": "Adds the Search relevance section, dashboard widget and settings screen to the admin panel." + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Lunar\\SearchRelevance\\": "src/", + "Lunar\\SearchRelevance\\Database\\Factories\\": "database/factories" + } + }, + "extra": { + "lunar": { + "name": "Search Relevance" + }, + "laravel": { + "providers": [ + "Lunar\\SearchRelevance\\SearchRelevanceServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/packages/search-relevance/config/search-relevance.php b/packages/search-relevance/config/search-relevance.php new file mode 100644 index 0000000000..bcbc008adc --- /dev/null +++ b/packages/search-relevance/config/search-relevance.php @@ -0,0 +1,107 @@ + env('LUNAR_SEARCH_RELEVANCE_MODE', 'shadow'), + + // Searchable models whose results are ranked and logged. + 'models' => [Product::class], + + // Sort fields that mean the engine's own relevance order. A search sorted + // by one of these is ranked like an unsorted one; any other sort is the + // shopper's choice and is left alone. Lunar's storefront sends + // `relevance:asc` by default. + 'relevance_sorts' => ['relevance', '_text_match'], + + // Candidate window fetched from the engine and reordered. + 'window' => 250, + + // Reorder only within buckets of this many hits, so a weak keyword match + // can never leap above a strong one. + 'bucket_size' => 10, + + // Seconds the ranked window is cached for. + 'cache_ttl' => 300, + + // Learned products the engine did not return are inserted at the head of + // the second bucket, at most `max` of them, each at least `min_relative`. + 'learned_union' => ['max' => 5, 'min_relative' => 0.1], + + // How many shown product ids a logged search records. + 'impressions_logged' => 50, + + // How long a click attributes a later basket or purchase. + 'attribution_ttl_minutes' => 30, + + // What identifies a shopper: the Lunar cart (survives login) or the + // Laravel session id. + 'session_key' => 'cart', + + 'normaliser' => DefaultQueryNormaliser::class, + + // Bump when normalisation rules change so learned scores relearn cleanly. + 'normaliser_version' => 1, + + 'ranker' => BucketedRanker::class, + + // Signal class => weight. Combined scores are clamped to 0..1. + 'signals' => [ + QueryAffinitySignal::class => 1.0, + ], + + 'scoring' => [ + 'weights' => ['click' => 1, 'basket' => 3, 'purchase' => 5], + // Position correction: an event at position p counts p^eta times, + // capped at max_position_weight, so the ranking learns preference + // rather than position bias. 0 disables it. + 'position_eta' => 0.7, + 'max_position_weight' => 5, + 'half_life_days' => 30, + 'window_days' => 180, + 'min_sessions' => 3, + 'max_products_per_query' => 50, + // Daily run time for lunar:search-relevance:score. + 'schedule' => '02:00', + ], + + // Raw queries and events older than this are pruned weekly. + 'retention_days' => 400, + + 'guards' => [ + // Events endpoint rate limit, "attempts,minutes", per shopper and per IP. + 'events_rate_limit' => '60,1', + // Sessions searching faster than this are ignored by scoring. + 'max_searches_per_minute' => 30, + // Events are only accepted this long after the search they belong to. + 'event_window_minutes' => 120, + // Only sessions that hold a cart or belong to a known customer count + // towards learning, so a bot minting fresh sessions gains nothing. + 'trusted_sessions_only' => true, + // Searches from these user agents (case-insensitive substrings) are + // neither logged nor ranked. + 'ignored_user_agents' => [ + 'bot', 'crawl', 'spider', 'slurp', 'curl', 'wget', 'python-requests', + 'headlesschrome', 'phantomjs', 'lighthouse', 'facebookexternalhit', + ], + ], +]; diff --git a/packages/search-relevance/database/factories/SearchEventFactory.php b/packages/search-relevance/database/factories/SearchEventFactory.php new file mode 100644 index 0000000000..7da310f868 --- /dev/null +++ b/packages/search-relevance/database/factories/SearchEventFactory.php @@ -0,0 +1,35 @@ + SearchQuery::factory(), + 'product_id' => 1, + 'position' => 1, + 'type' => 'click', + 'source' => 'organic', + 'session_id' => fn (array $attributes) => SearchQuery::find($attributes['search_id'])?->session_id ?? 'session:unknown', + 'created_at' => now(), + ]; + } + + public function basket(): static + { + return $this->state(['type' => 'basket']); + } + + public function purchase(): static + { + return $this->state(['type' => 'purchase']); + } +} diff --git a/packages/search-relevance/database/factories/SearchQueryFactory.php b/packages/search-relevance/database/factories/SearchQueryFactory.php new file mode 100644 index 0000000000..de7a327ad6 --- /dev/null +++ b/packages/search-relevance/database/factories/SearchQueryFactory.php @@ -0,0 +1,35 @@ + (string) Str::ulid(), + 'model_type' => Product::class, + 'raw_query' => 'cable ties', + 'normalised_query' => 'cable tie', + 'filters_hash' => md5('[]'), + 'session_id' => 'session:'.Str::random(20), + 'customer_id' => null, + 'version' => 'n1:database:keyword', + 'mode' => 'shadow', + 'result_count' => count($shown), + 'shown' => $shown, + 'ranked' => null, + 'features' => null, + 'created_at' => now(), + ]; + } +} diff --git a/packages/search-relevance/database/factories/SearchQueryScoreFactory.php b/packages/search-relevance/database/factories/SearchQueryScoreFactory.php new file mode 100644 index 0000000000..9b529601cd --- /dev/null +++ b/packages/search-relevance/database/factories/SearchQueryScoreFactory.php @@ -0,0 +1,26 @@ + Product::class, + 'normalised_query' => 'cable tie', + 'product_id' => 1, + 'score' => 10.0, + 'relative' => 1.0, + 'sessions' => 3, + 'version' => 'n1:database:keyword', + 'updated_at' => now(), + ]; + } +} diff --git a/packages/search-relevance/database/migrations/2026_09_14_000001_create_search_queries_table.php b/packages/search-relevance/database/migrations/2026_09_14_000001_create_search_queries_table.php new file mode 100644 index 0000000000..30038c27ce --- /dev/null +++ b/packages/search-relevance/database/migrations/2026_09_14_000001_create_search_queries_table.php @@ -0,0 +1,33 @@ +prefix.'search_queries', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->string('model_type'); + $table->text('raw_query'); + $table->string('normalised_query', 255)->index(); + $table->char('filters_hash', 32); + $table->string('session_id', 64)->index(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->string('version', 32)->index(); + $table->string('mode', 8); + $table->integer('result_count'); + $table->json('shown'); + $table->json('ranked')->nullable(); + $table->json('features')->nullable(); + $table->timestamp('created_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'search_queries'); + } +}; diff --git a/packages/search-relevance/database/migrations/2026_09_14_000002_create_search_events_table.php b/packages/search-relevance/database/migrations/2026_09_14_000002_create_search_events_table.php new file mode 100644 index 0000000000..f57b730e81 --- /dev/null +++ b/packages/search-relevance/database/migrations/2026_09_14_000002_create_search_events_table.php @@ -0,0 +1,30 @@ +prefix.'search_events', function (Blueprint $table) { + $table->id(); + $table->foreignUlid('search_id')->constrained($this->prefix.'search_queries')->cascadeOnDelete(); + $table->unsignedBigInteger('product_id'); + $table->smallInteger('position'); + $table->string('type', 16); + $table->string('source', 16); + $table->string('session_id', 64); + $table->timestamp('created_at'); + $table->index(['created_at', 'search_id']); + // One event of each type per product per search: replays add nothing. + $table->unique(['search_id', 'product_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'search_events'); + } +}; diff --git a/packages/search-relevance/database/migrations/2026_09_14_000003_create_search_query_scores_table.php b/packages/search-relevance/database/migrations/2026_09_14_000003_create_search_query_scores_table.php new file mode 100644 index 0000000000..63bdf9dde4 --- /dev/null +++ b/packages/search-relevance/database/migrations/2026_09_14_000003_create_search_query_scores_table.php @@ -0,0 +1,29 @@ +prefix.'search_query_scores', function (Blueprint $table) { + $table->string('model_type'); + $table->string('normalised_query', 255); + $table->unsignedBigInteger('product_id'); + $table->double('score'); + $table->double('relative'); + $table->integer('sessions'); + $table->string('version', 32); + $table->timestamp('updated_at'); + $table->primary(['model_type', 'normalised_query', 'product_id']); + $table->index(['version', 'normalised_query']); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'search_query_scores'); + } +}; diff --git a/packages/search-relevance/database/migrations/2026_09_14_000005_create_search_learning_overrides_table.php b/packages/search-relevance/database/migrations/2026_09_14_000005_create_search_learning_overrides_table.php new file mode 100644 index 0000000000..245f019a97 --- /dev/null +++ b/packages/search-relevance/database/migrations/2026_09_14_000005_create_search_learning_overrides_table.php @@ -0,0 +1,29 @@ +prefix.'search_learning_overrides', function (Blueprint $table) { + $table->id(); + $table->string('model_type'); + $table->string('normalised_query'); + // Null for a query-wide `reset`; the product for an `exclude`. + $table->unsignedBigInteger('product_id')->nullable(); + $table->string('type', 16); + $table->timestamp('created_at'); + + // Named: the generated name exceeds MySQL's 64-character limit. + $table->index(['model_type', 'normalised_query', 'type'], $this->prefix.'learning_overrides_lookup'); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'search_learning_overrides'); + } +}; diff --git a/packages/search-relevance/database/migrations/2026_09_14_000010_add_search_manage_relevance_permission.php b/packages/search-relevance/database/migrations/2026_09_14_000010_add_search_manage_relevance_permission.php new file mode 100644 index 0000000000..5124fc2c2e --- /dev/null +++ b/packages/search-relevance/database/migrations/2026_09_14_000010_add_search_manage_relevance_permission.php @@ -0,0 +1,34 @@ + 'search:manage-relevance', + 'guard_name' => app(Manifest::class)->getAuthGuard(), + ]); + } + + public function down() + { + $tableNames = config('permission.table_names'); + + if (! isset($tableNames['permissions']) || ! Schema::hasTable($tableNames['permissions'])) { + return; + } + + Permission::query()->where('name', 'search:manage-relevance')->delete(); + } +}; diff --git a/packages/search-relevance/package.json b/packages/search-relevance/package.json new file mode 100644 index 0000000000..ee1d278925 --- /dev/null +++ b/packages/search-relevance/package.json @@ -0,0 +1,17 @@ +{ + "name": "@lunarphp/search-relevance-addon", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@inertiajs/vue3": "^2.0.0", + "@lunarphp/panel": "^0.1.0", + "@lunarphp/panel-vite-plugin": "^0.1.0", + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^6.0.0", + "vue": "^3.5.13" + } +} diff --git a/packages/search-relevance/resources/client/README.md b/packages/search-relevance/resources/client/README.md new file mode 100644 index 0000000000..0ead68a023 --- /dev/null +++ b/packages/search-relevance/resources/client/README.md @@ -0,0 +1,17 @@ +# @lunarphp/search-relevance + +Storefront click tracking for the `lunarphp/search-relevance` Composer package. Reads the `meta` the results pipeline stamps on `SearchResults` and each hit and posts clicks to the events endpoint so basket and purchase attribution can follow. + +```ts +import { useSearchTracking } from '@lunarphp/search-relevance/vue'; + +const { track, attrs } = useSearchTracking(() => props.results); +``` + +```vue +... +``` + +Framework-agnostic entry: `attachSearchTracking()`, `trackHit()`, `sendSearchEvent()`, `trackingAttributes()` from `@lunarphp/search-relevance`. + +See the Lunar docs for the full guide. diff --git a/packages/search-relevance/resources/client/dist/index.d.ts b/packages/search-relevance/resources/client/dist/index.d.ts new file mode 100644 index 0000000000..5f59df4d71 --- /dev/null +++ b/packages/search-relevance/resources/client/dist/index.d.ts @@ -0,0 +1,70 @@ +/** + * Storefront click tracking for lunarphp/search-relevance. + * + * Reads the `meta` the results pipeline stamps on SearchResults and each hit, + * and posts a click to the events endpoint in a way that survives the + * navigation the click usually starts. + */ +export type HitSource = 'organic' | 'learned' | 'explore'; +export interface TrackedResults { + meta?: { + search_id?: string | null; + [key: string]: unknown; + }; +} +export interface TrackedHit { + document: { + id?: string | number; + [key: string]: unknown; + }; + meta?: { + position?: number; + source?: HitSource; + [key: string]: unknown; + }; +} +export interface SearchEventPayload { + search_id: string; + product_id: string | number; + position: number; + source?: HitSource; + /** Explicit shopper id for clients without a cart session cookie. */ + session_id?: string; +} +export interface TrackingOptions { + /** Defaults to `/lunar/search/events` (route `lunar.search-relevance.events`). */ + endpoint?: string; + /** CSRF token; read from `` when omitted. */ + token?: string | null; + /** Sent as `session_id` with every event. */ + sessionId?: string | null; +} +export declare const DEFAULT_ENDPOINT = "/lunar/search/events"; +export declare const ATTRIBUTES: { + readonly searchId: "data-lunar-search-id"; + readonly productId: "data-lunar-product-id"; + readonly position: "data-lunar-position"; + readonly source: "data-lunar-source"; +}; +/** + * Post one event. Uses `navigator.sendBeacon` so a click that immediately + * navigates away still delivers, falling back to a keepalive fetch. + */ +export declare function sendSearchEvent(payload: SearchEventPayload, options?: TrackingOptions): void; +/** The payload for a hit, or null when the search was not logged. */ +export declare function eventFor(results: TrackedResults, hit: TrackedHit): SearchEventPayload | null; +/** Record a click on a hit. No-op when the search was not logged. */ +export declare function trackHit(results: TrackedResults, hit: TrackedHit, options?: TrackingOptions): void; +/** + * The `data-lunar-*` attributes for a rendered hit, for storefronts that + * prefer markup plus `attachSearchTracking()` over calling trackHit(). + */ +export declare function trackingAttributes(results: TrackedResults, hit: TrackedHit): Record; +export declare function payloadFromElement(element: Element): SearchEventPayload | null; +/** + * Delegated click tracking: any click inside an element carrying the + * tracking attributes sends an event. Returns a function that detaches it. + */ +export declare function attachSearchTracking(options?: TrackingOptions, root?: Document | Element): () => void; +/** Shape published on window by the IIFE build. */ +export declare const attach: typeof attachSearchTracking; diff --git a/packages/search-relevance/resources/client/dist/index.js b/packages/search-relevance/resources/client/dist/index.js new file mode 100644 index 0000000000..1edafccff7 --- /dev/null +++ b/packages/search-relevance/resources/client/dist/index.js @@ -0,0 +1,67 @@ +const p = "/lunar/search/events", c = { + searchId: "data-lunar-search-id", + productId: "data-lunar-product-id", + position: "data-lunar-position", + source: "data-lunar-source" +}, l = () => { + var t; + return typeof document > "u" ? null : ((t = document.querySelector('meta[name="csrf-token"]')) == null ? void 0 : t.content) ?? null; +}; +function a(t, e = {}) { + const n = e.endpoint ?? p, r = e.token === void 0 ? l() : e.token, o = new FormData(); + o.append("search_id", String(t.search_id)), o.append("product_id", String(t.product_id)), o.append("position", String(t.position)), o.append("source", t.source ?? "organic"); + const i = t.session_id ?? e.sessionId; + i && o.append("session_id", i), r && o.append("_token", r), !(typeof navigator < "u" && typeof navigator.sendBeacon == "function" && navigator.sendBeacon(n, o)) && fetch(n, { method: "POST", body: o, keepalive: !0, credentials: "same-origin" }).catch(() => { + }); +} +function u(t, e) { + var o, i, d, s; + const n = (o = t.meta) == null ? void 0 : o.search_id, r = (i = e.document) == null ? void 0 : i.id; + return !n || r === void 0 || r === null ? null : { + search_id: n, + product_id: r, + position: Number(((d = e.meta) == null ? void 0 : d.position) ?? 0), + source: ((s = e.meta) == null ? void 0 : s.source) ?? "organic" + }; +} +function h(t, e, n = {}) { + const r = u(t, e); + r && a(r, n); +} +function m(t, e) { + const n = u(t, e); + return n ? { + [c.searchId]: n.search_id, + [c.productId]: String(n.product_id), + [c.position]: String(n.position), + [c.source]: n.source ?? "organic" + } : {}; +} +function f(t) { + const e = t.getAttribute(c.searchId), n = t.getAttribute(c.productId); + return !e || n === null ? null : { + search_id: e, + product_id: n, + position: Number(t.getAttribute(c.position) ?? 0), + source: t.getAttribute(c.source) ?? "organic" + }; +} +function g(t = {}, e = document) { + const n = (r) => { + const o = r.target instanceof Element ? r.target.closest(`[${c.searchId}]`) : null, i = o ? f(o) : null; + i && a(i, t); + }; + return e.addEventListener("click", n, !0), () => e.removeEventListener("click", n, !0); +} +const I = g; +export { + c as ATTRIBUTES, + p as DEFAULT_ENDPOINT, + I as attach, + g as attachSearchTracking, + u as eventFor, + f as payloadFromElement, + a as sendSearchEvent, + h as trackHit, + m as trackingAttributes +}; diff --git a/packages/search-relevance/resources/client/dist/tracking.iife.js b/packages/search-relevance/resources/client/dist/tracking.iife.js new file mode 100644 index 0000000000..3f6ce2dd79 --- /dev/null +++ b/packages/search-relevance/resources/client/dist/tracking.iife.js @@ -0,0 +1,102 @@ +var LunarSearchRelevance = (function(exports) { + "use strict"; + const DEFAULT_ENDPOINT = "/lunar/search/events"; + const ATTRIBUTES = { + searchId: "data-lunar-search-id", + productId: "data-lunar-product-id", + position: "data-lunar-position", + source: "data-lunar-source" + }; + const csrfToken = () => { + var _a; + return typeof document === "undefined" ? null : ((_a = document.querySelector('meta[name="csrf-token"]')) == null ? void 0 : _a.content) ?? null; + }; + function sendSearchEvent(payload, options = {}) { + const endpoint = options.endpoint ?? DEFAULT_ENDPOINT; + const token = options.token === void 0 ? csrfToken() : options.token; + const data = new FormData(); + data.append("search_id", String(payload.search_id)); + data.append("product_id", String(payload.product_id)); + data.append("position", String(payload.position)); + data.append("source", payload.source ?? "organic"); + const sessionId = payload.session_id ?? options.sessionId; + if (sessionId) { + data.append("session_id", sessionId); + } + if (token) { + data.append("_token", token); + } + if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" && navigator.sendBeacon(endpoint, data)) { + return; + } + void fetch(endpoint, { method: "POST", body: data, keepalive: true, credentials: "same-origin" }).catch(() => void 0); + } + function eventFor(results, hit) { + var _a, _b, _c, _d; + const searchId = (_a = results.meta) == null ? void 0 : _a.search_id; + const productId = (_b = hit.document) == null ? void 0 : _b.id; + if (!searchId || productId === void 0 || productId === null) { + return null; + } + return { + search_id: searchId, + product_id: productId, + position: Number(((_c = hit.meta) == null ? void 0 : _c.position) ?? 0), + source: ((_d = hit.meta) == null ? void 0 : _d.source) ?? "organic" + }; + } + function trackHit(results, hit, options = {}) { + const payload = eventFor(results, hit); + if (payload) { + sendSearchEvent(payload, options); + } + } + function trackingAttributes(results, hit) { + const payload = eventFor(results, hit); + if (!payload) { + return {}; + } + return { + [ATTRIBUTES.searchId]: payload.search_id, + [ATTRIBUTES.productId]: String(payload.product_id), + [ATTRIBUTES.position]: String(payload.position), + [ATTRIBUTES.source]: payload.source ?? "organic" + }; + } + function payloadFromElement(element) { + const searchId = element.getAttribute(ATTRIBUTES.searchId); + const productId = element.getAttribute(ATTRIBUTES.productId); + if (!searchId || productId === null) { + return null; + } + return { + search_id: searchId, + product_id: productId, + position: Number(element.getAttribute(ATTRIBUTES.position) ?? 0), + source: element.getAttribute(ATTRIBUTES.source) ?? "organic" + }; + } + function attachSearchTracking(options = {}, root = document) { + const handler = (event) => { + const target = event.target instanceof Element ? event.target.closest(`[${ATTRIBUTES.searchId}]`) : null; + const payload = target ? payloadFromElement(target) : null; + if (payload) { + sendSearchEvent(payload, options); + } + }; + root.addEventListener("click", handler, true); + return () => root.removeEventListener("click", handler, true); + } + const attach = attachSearchTracking; + exports.ATTRIBUTES = ATTRIBUTES; + exports.DEFAULT_ENDPOINT = DEFAULT_ENDPOINT; + exports.attach = attach; + exports.attachSearchTracking = attachSearchTracking; + exports.eventFor = eventFor; + exports.payloadFromElement = payloadFromElement; + exports.sendSearchEvent = sendSearchEvent; + exports.trackHit = trackHit; + exports.trackingAttributes = trackingAttributes; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + return exports; +})({}); diff --git a/packages/search-relevance/resources/client/dist/vue.d.ts b/packages/search-relevance/resources/client/dist/vue.d.ts new file mode 100644 index 0000000000..786a95e211 --- /dev/null +++ b/packages/search-relevance/resources/client/dist/vue.d.ts @@ -0,0 +1,28 @@ +import type { Directive, MaybeRefOrGetter } from 'vue'; +import { sendSearchEvent } from './index'; +import type { TrackedHit, TrackedResults, TrackingOptions } from './index'; +export * from './index'; +/** + * Click tracking for an Inertia or Vue storefront: + * + * const { track, attrs } = useSearchTracking(() => props.results); + * + */ +export declare function useSearchTracking(results: MaybeRefOrGetter, options?: TrackingOptions): { + /** Send a click event for a hit. Safe to call when the search was not logged. */ + track: (hit: TrackedHit) => void; + /** The `data-lunar-*` attributes for a hit, for `v-bind`. */ + attrs: (hit: TrackedHit) => Record; + /** The raw sender, for events not tied to a rendered hit. */ + send: (payload: Parameters[0]) => void; +}; +interface HitDirectiveValue { + results: TrackedResults; + hit: TrackedHit; + options?: TrackingOptions; +} +/** + * `v-lunar-search-hit="{ results, hit }"`: stamps the tracking attributes on + * the element and sends a click event when anything inside it is clicked. + */ +export declare const vLunarSearchHit: Directive; diff --git a/packages/search-relevance/resources/client/dist/vue.js b/packages/search-relevance/resources/client/dist/vue.js new file mode 100644 index 0000000000..dacf71e163 --- /dev/null +++ b/packages/search-relevance/resources/client/dist/vue.js @@ -0,0 +1,43 @@ +import { toValue as o } from "vue"; +import { sendSearchEvent as u, trackingAttributes as n, trackHit as i } from "./index.js"; +import { ATTRIBUTES as S, DEFAULT_ENDPOINT as T, attach as k, attachSearchTracking as m, eventFor as E, payloadFromElement as v } from "./index.js"; +function d(t, r = {}) { + const a = () => o(t) ?? {}; + return { + /** Send a click event for a hit. Safe to call when the search was not logged. */ + track: (e) => i(a(), e, r), + /** The `data-lunar-*` attributes for a hit, for `v-bind`. */ + attrs: (e) => n(a(), e), + /** The raw sender, for events not tied to a rendered hit. */ + send: (e) => u(e, r) + }; +} +const f = { + mounted(t, r) { + c(t, r.value), t.addEventListener("click", () => { + const a = t.__lunarSearchHit; + a && i(a.results, a.hit, a.options); + }); + }, + updated(t, r) { + c(t, r.value); + } +}; +function c(t, r) { + t.__lunarSearchHit = r; + for (const [a, e] of Object.entries(n(r.results, r.hit))) + t.setAttribute(a, e); +} +export { + S as ATTRIBUTES, + T as DEFAULT_ENDPOINT, + k as attach, + m as attachSearchTracking, + E as eventFor, + v as payloadFromElement, + u as sendSearchEvent, + i as trackHit, + n as trackingAttributes, + d as useSearchTracking, + f as vLunarSearchHit +}; diff --git a/packages/search-relevance/resources/client/package.json b/packages/search-relevance/resources/client/package.json new file mode 100644 index 0000000000..180ef2aa46 --- /dev/null +++ b/packages/search-relevance/resources/client/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lunarphp/search-relevance", + "version": "0.1.0", + "type": "module", + "description": "Storefront click tracking for lunarphp/search-relevance: a framework-agnostic client plus a Vue composable and directive.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/lunarphp/lunar.git", + "directory": "packages/search-relevance/resources/client" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./vue": { + "types": "./dist/vue.d.ts", + "default": "./dist/vue.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build && vite build --config vite.iife.config.ts && vue-tsc -p tsconfig.json", + "test": "vitest run", + "type-check": "vue-tsc --noEmit" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + }, + "devDependencies": { + "happy-dom": "^15.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^4.1.10", + "vue": "^3.5.13", + "vue-tsc": "^2.2.0" + } +} diff --git a/packages/search-relevance/resources/client/src/index.test.ts b/packages/search-relevance/resources/client/src/index.test.ts new file mode 100644 index 0000000000..4c9ff23c0b --- /dev/null +++ b/packages/search-relevance/resources/client/src/index.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { attachSearchTracking, eventFor, sendSearchEvent, trackingAttributes } from './index'; + +const results = { meta: { search_id: '01ARZ3NDEKTSV4RRFFQ69G5FAV' } }; +const hit = { document: { id: '7' }, meta: { position: 3, source: 'learned' as const } }; + +const formEntries = (data: FormData): Record => Object.fromEntries([...data.entries()].map(([k, v]) => [k, String(v)])); + +describe('sendSearchEvent', () => { + beforeEach(() => { + document.head.innerHTML = ''; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prefers sendBeacon and includes the CSRF token from the page', () => { + const beacon = vi.fn(() => true); + Object.defineProperty(navigator, 'sendBeacon', { value: beacon, configurable: true }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + sendSearchEvent({ search_id: 'abc', product_id: 7, position: 2 }); + + expect(beacon).toHaveBeenCalledOnce(); + const [endpoint, data] = beacon.mock.calls[0] as unknown as [string, FormData]; + expect(endpoint).toBe('/lunar/search/events'); + expect(formEntries(data)).toEqual({ search_id: 'abc', product_id: '7', position: '2', source: 'organic', _token: 'tok' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('falls back to a keepalive fetch and passes the session id', async () => { + Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); + + sendSearchEvent({ search_id: 'abc', product_id: 7, position: 2, source: 'learned' }, { endpoint: '/events', token: null, sessionId: 'cart:9' }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [endpoint, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(endpoint).toBe('/events'); + expect(init.keepalive).toBe(true); + expect(formEntries(init.body as FormData)).toEqual({ search_id: 'abc', product_id: '7', position: '2', source: 'learned', session_id: 'cart:9' }); + }); +}); + +describe('eventFor and trackingAttributes', () => { + it('builds the payload from the results and hit meta', () => { + expect(eventFor(results, hit)).toEqual({ search_id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', product_id: '7', position: 3, source: 'learned' }); + expect(trackingAttributes(results, hit)).toEqual({ + 'data-lunar-search-id': '01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'data-lunar-product-id': '7', + 'data-lunar-position': '3', + 'data-lunar-source': 'learned', + }); + }); + + it('is empty when the search was not logged', () => { + expect(eventFor({ meta: {} }, hit)).toBeNull(); + expect(trackingAttributes({}, hit)).toEqual({}); + }); +}); + +describe('attachSearchTracking', () => { + it('sends an event for clicks inside a tracked element and can detach', () => { + const beacon = vi.fn(() => true); + Object.defineProperty(navigator, 'sendBeacon', { value: beacon, configurable: true }); + document.body.innerHTML = '
Go
No'; + + const detach = attachSearchTracking({ token: null }); + + document.getElementById('link')!.click(); + document.getElementById('other')!.click(); + expect(beacon).toHaveBeenCalledOnce(); + expect(formEntries(beacon.mock.calls[0]![1] as FormData)).toEqual({ search_id: 'abc', product_id: '7', position: '2', source: 'organic' }); + + detach(); + document.getElementById('link')!.click(); + expect(beacon).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/search-relevance/resources/client/src/index.ts b/packages/search-relevance/resources/client/src/index.ts new file mode 100644 index 0000000000..e7a75bde16 --- /dev/null +++ b/packages/search-relevance/resources/client/src/index.ts @@ -0,0 +1,160 @@ +/** + * Storefront click tracking for lunarphp/search-relevance. + * + * Reads the `meta` the results pipeline stamps on SearchResults and each hit, + * and posts a click to the events endpoint in a way that survives the + * navigation the click usually starts. + */ + +export type HitSource = 'organic' | 'learned' | 'explore'; + +export interface TrackedResults { + meta?: { search_id?: string | null; [key: string]: unknown }; +} + +export interface TrackedHit { + document: { id?: string | number; [key: string]: unknown }; + meta?: { position?: number; source?: HitSource; [key: string]: unknown }; +} + +export interface SearchEventPayload { + search_id: string; + product_id: string | number; + position: number; + source?: HitSource; + /** Explicit shopper id for clients without a cart session cookie. */ + session_id?: string; +} + +export interface TrackingOptions { + /** Defaults to `/lunar/search/events` (route `lunar.search-relevance.events`). */ + endpoint?: string; + /** CSRF token; read from `` when omitted. */ + token?: string | null; + /** Sent as `session_id` with every event. */ + sessionId?: string | null; +} + +export const DEFAULT_ENDPOINT = '/lunar/search/events'; + +export const ATTRIBUTES = { + searchId: 'data-lunar-search-id', + productId: 'data-lunar-product-id', + position: 'data-lunar-position', + source: 'data-lunar-source', +} as const; + +const csrfToken = (): string | null => + typeof document === 'undefined' ? null : document.querySelector('meta[name="csrf-token"]')?.content ?? null; + +/** + * Post one event. Uses `navigator.sendBeacon` so a click that immediately + * navigates away still delivers, falling back to a keepalive fetch. + */ +export function sendSearchEvent(payload: SearchEventPayload, options: TrackingOptions = {}): void { + const endpoint = options.endpoint ?? DEFAULT_ENDPOINT; + const token = options.token === undefined ? csrfToken() : options.token; + const data = new FormData(); + + data.append('search_id', String(payload.search_id)); + data.append('product_id', String(payload.product_id)); + data.append('position', String(payload.position)); + data.append('source', payload.source ?? 'organic'); + + const sessionId = payload.session_id ?? options.sessionId; + if (sessionId) { + data.append('session_id', sessionId); + } + if (token) { + data.append('_token', token); + } + + if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' && navigator.sendBeacon(endpoint, data)) { + return; + } + + void fetch(endpoint, { method: 'POST', body: data, keepalive: true, credentials: 'same-origin' }).catch(() => undefined); +} + +/** The payload for a hit, or null when the search was not logged. */ +export function eventFor(results: TrackedResults, hit: TrackedHit): SearchEventPayload | null { + const searchId = results.meta?.search_id; + const productId = hit.document?.id; + + if (!searchId || productId === undefined || productId === null) { + return null; + } + + return { + search_id: searchId, + product_id: productId, + position: Number(hit.meta?.position ?? 0), + source: hit.meta?.source ?? 'organic', + }; +} + +/** Record a click on a hit. No-op when the search was not logged. */ +export function trackHit(results: TrackedResults, hit: TrackedHit, options: TrackingOptions = {}): void { + const payload = eventFor(results, hit); + + if (payload) { + sendSearchEvent(payload, options); + } +} + +/** + * The `data-lunar-*` attributes for a rendered hit, for storefronts that + * prefer markup plus `attachSearchTracking()` over calling trackHit(). + */ +export function trackingAttributes(results: TrackedResults, hit: TrackedHit): Record { + const payload = eventFor(results, hit); + + if (!payload) { + return {}; + } + + return { + [ATTRIBUTES.searchId]: payload.search_id, + [ATTRIBUTES.productId]: String(payload.product_id), + [ATTRIBUTES.position]: String(payload.position), + [ATTRIBUTES.source]: payload.source ?? 'organic', + }; +} + +export function payloadFromElement(element: Element): SearchEventPayload | null { + const searchId = element.getAttribute(ATTRIBUTES.searchId); + const productId = element.getAttribute(ATTRIBUTES.productId); + + if (!searchId || productId === null) { + return null; + } + + return { + search_id: searchId, + product_id: productId, + position: Number(element.getAttribute(ATTRIBUTES.position) ?? 0), + source: (element.getAttribute(ATTRIBUTES.source) as HitSource | null) ?? 'organic', + }; +} + +/** + * Delegated click tracking: any click inside an element carrying the + * tracking attributes sends an event. Returns a function that detaches it. + */ +export function attachSearchTracking(options: TrackingOptions = {}, root: Document | Element = document): () => void { + const handler = (event: Event): void => { + const target = event.target instanceof Element ? event.target.closest(`[${ATTRIBUTES.searchId}]`) : null; + const payload = target ? payloadFromElement(target) : null; + + if (payload) { + sendSearchEvent(payload, options); + } + }; + + root.addEventListener('click', handler, true); + + return () => root.removeEventListener('click', handler, true); +} + +/** Shape published on window by the IIFE build. */ +export const attach = attachSearchTracking; diff --git a/packages/search-relevance/resources/client/src/test-utils.ts b/packages/search-relevance/resources/client/src/test-utils.ts new file mode 100644 index 0000000000..c5120aa045 --- /dev/null +++ b/packages/search-relevance/resources/client/src/test-utils.ts @@ -0,0 +1,10 @@ +import { createApp, type Component } from 'vue'; + +/** Mount a component into a fresh container and return that container. */ +export function mount(component: Component): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + createApp(component).mount(container); + + return container; +} diff --git a/packages/search-relevance/resources/client/src/vue.test.ts b/packages/search-relevance/resources/client/src/vue.test.ts new file mode 100644 index 0000000000..f7a84c9cd9 --- /dev/null +++ b/packages/search-relevance/resources/client/src/vue.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { mount } from './test-utils'; +import { useSearchTracking, vLunarSearchHit } from './vue'; + +const results = { meta: { search_id: '01ARZ3NDEKTSV4RRFFQ69G5FAV' } }; +const hit = { document: { id: '7' }, meta: { position: 3, source: 'organic' as const } }; + +describe('useSearchTracking', () => { + it('tracks a hit against the current results and exposes bindable attributes', () => { + const beacon = vi.fn(() => true); + Object.defineProperty(navigator, 'sendBeacon', { value: beacon, configurable: true }); + const current = ref<{ meta: { search_id?: string } }>({ meta: {} }); + const { track, attrs } = useSearchTracking(current, { token: null }); + + track(hit); + expect(beacon).not.toHaveBeenCalled(); + expect(attrs(hit)).toEqual({}); + + current.value = results; + track(hit); + expect(beacon).toHaveBeenCalledOnce(); + expect(attrs(hit)['data-lunar-search-id']).toBe(results.meta.search_id); + }); +}); + +describe('vLunarSearchHit', () => { + it('stamps the attributes and sends a click event', async () => { + const beacon = vi.fn(() => true); + Object.defineProperty(navigator, 'sendBeacon', { value: beacon, configurable: true }); + + const Component = defineComponent({ + directives: { lunarSearchHit: vLunarSearchHit }, + setup: () => () => h('div', { id: 'hit' }, [h('a', 'Go')]), + }); + const element = mount( + defineComponent({ + setup: () => () => h('div', [ + // Wrapper so the directive binds inside the component tree. + h(Component), + ]), + }), + ); + + const target = element.querySelector('#hit') as HTMLElement; + vLunarSearchHit.mounted!(target, { value: { results, hit, options: { token: null } } } as never, null as never, null as never); + await nextTick(); + + expect(target.getAttribute('data-lunar-position')).toBe('3'); + (target.querySelector('a') as HTMLElement).click(); + expect(beacon).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/search-relevance/resources/client/src/vue.ts b/packages/search-relevance/resources/client/src/vue.ts new file mode 100644 index 0000000000..f1b088544c --- /dev/null +++ b/packages/search-relevance/resources/client/src/vue.ts @@ -0,0 +1,62 @@ +import type { Directive, MaybeRefOrGetter } from 'vue'; +import { toValue } from 'vue'; +import { sendSearchEvent, trackHit, trackingAttributes } from './index'; +import type { TrackedHit, TrackedResults, TrackingOptions } from './index'; + +export * from './index'; + +/** + * Click tracking for an Inertia or Vue storefront: + * + * const { track, attrs } = useSearchTracking(() => props.results); + * + */ +export function useSearchTracking(results: MaybeRefOrGetter, options: TrackingOptions = {}) { + const current = (): TrackedResults => toValue(results) ?? {}; + + return { + /** Send a click event for a hit. Safe to call when the search was not logged. */ + track: (hit: TrackedHit): void => trackHit(current(), hit, options), + /** The `data-lunar-*` attributes for a hit, for `v-bind`. */ + attrs: (hit: TrackedHit): Record => trackingAttributes(current(), hit), + /** The raw sender, for events not tied to a rendered hit. */ + send: (payload: Parameters[0]): void => sendSearchEvent(payload, options), + }; +} + +interface HitDirectiveValue { + results: TrackedResults; + hit: TrackedHit; + options?: TrackingOptions; +} + +/** + * `v-lunar-search-hit="{ results, hit }"`: stamps the tracking attributes on + * the element and sends a click event when anything inside it is clicked. + */ +export const vLunarSearchHit: Directive = { + mounted(element, binding) { + apply(element, binding.value); + element.addEventListener('click', () => { + const value = (element as HitDirectiveElement).__lunarSearchHit; + if (value) { + trackHit(value.results, value.hit, value.options); + } + }); + }, + updated(element, binding) { + apply(element, binding.value); + }, +}; + +interface HitDirectiveElement extends HTMLElement { + __lunarSearchHit?: HitDirectiveValue; +} + +function apply(element: HTMLElement, value: HitDirectiveValue): void { + (element as HitDirectiveElement).__lunarSearchHit = value; + + for (const [name, attributeValue] of Object.entries(trackingAttributes(value.results, value.hit))) { + element.setAttribute(name, attributeValue); + } +} diff --git a/packages/search-relevance/resources/client/tsconfig.json b/packages/search-relevance/resources/client/tsconfig.json new file mode 100644 index 0000000000..fedc7bef86 --- /dev/null +++ b/packages/search-relevance/resources/client/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "skipLibCheck": true, + "declaration": true, + "noEmit": false, + "types": [ + "vitest/globals" + ], + "emitDeclarationOnly": true, + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts", + "src/test-utils.ts" + ] +} diff --git a/packages/search-relevance/resources/client/vite.config.ts b/packages/search-relevance/resources/client/vite.config.ts new file mode 100644 index 0000000000..b065634da8 --- /dev/null +++ b/packages/search-relevance/resources/client/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +// ESM library build: the framework-agnostic entry and the Vue layer, with +// vue left external so the storefront's own copy is used. +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: false, + lib: { + entry: { index: 'src/index.ts', vue: 'src/vue.ts' }, + formats: ['es'], + }, + rollupOptions: { external: ['vue'] }, + }, + test: { + environment: 'happy-dom', + globals: true, + }, +}); diff --git a/packages/search-relevance/resources/client/vite.iife.config.ts b/packages/search-relevance/resources/client/vite.iife.config.ts new file mode 100644 index 0000000000..045867f141 --- /dev/null +++ b/packages/search-relevance/resources/client/vite.iife.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; + +// Self-contained script exposing window.LunarSearchRelevance. The Blade +// tracking component inlines this file so Blade and headless storefronts +// share one implementation. Unminified: it is inlined into page markup. +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: false, + minify: false, + lib: { + entry: 'src/index.ts', + name: 'LunarSearchRelevance', + formats: ['iife'], + fileName: () => 'tracking.iife.js', + }, + }, +}); diff --git a/packages/search-relevance/resources/js/addon.ts b/packages/search-relevance/resources/js/addon.ts new file mode 100644 index 0000000000..de4c5cab8a --- /dev/null +++ b/packages/search-relevance/resources/js/addon.ts @@ -0,0 +1,19 @@ +import IndexPage from './pages/Index.vue'; +import QueryPage from './pages/Query.vue'; +import ProductPage from './pages/Product.vue'; +import SettingsIndexPage from './pages/Settings/Index.vue'; +import SearchConversionWidget from './components/SearchConversionWidget.vue'; +import ProductSearchPerformance from './components/ProductSearchPerformance.vue'; + +// Registered eagerly: pages must exist before Inertia resolves the initial page. +window.LunarPanel.registerPages({ + 'search-relevance::Index': IndexPage, + 'search-relevance::Query': QueryPage, + 'search-relevance::Product': ProductPage, + 'search-relevance::Settings/Index': SettingsIndexPage, +}); + +window.LunarPanel.registerComponents('search-relevance', { + SearchConversionWidget, + ProductSearchPerformance, +}); diff --git a/packages/search-relevance/resources/js/components/ProductSearchPerformance.vue b/packages/search-relevance/resources/js/components/ProductSearchPerformance.vue new file mode 100644 index 0000000000..42e80b8399 --- /dev/null +++ b/packages/search-relevance/resources/js/components/ProductSearchPerformance.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/search-relevance/resources/js/components/RelativeBar.vue b/packages/search-relevance/resources/js/components/RelativeBar.vue new file mode 100644 index 0000000000..63c99d527f --- /dev/null +++ b/packages/search-relevance/resources/js/components/RelativeBar.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/search-relevance/resources/js/components/SearchConversionWidget.vue b/packages/search-relevance/resources/js/components/SearchConversionWidget.vue new file mode 100644 index 0000000000..824a97336c --- /dev/null +++ b/packages/search-relevance/resources/js/components/SearchConversionWidget.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/search-relevance/resources/js/components/TableBlock.vue b/packages/search-relevance/resources/js/components/TableBlock.vue new file mode 100644 index 0000000000..0cb740371a --- /dev/null +++ b/packages/search-relevance/resources/js/components/TableBlock.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/search-relevance/resources/js/pages/Index.vue b/packages/search-relevance/resources/js/pages/Index.vue new file mode 100644 index 0000000000..5e64fefae9 --- /dev/null +++ b/packages/search-relevance/resources/js/pages/Index.vue @@ -0,0 +1,199 @@ + + + diff --git a/packages/search-relevance/resources/js/pages/Product.vue b/packages/search-relevance/resources/js/pages/Product.vue new file mode 100644 index 0000000000..c9d3e2d71b --- /dev/null +++ b/packages/search-relevance/resources/js/pages/Product.vue @@ -0,0 +1,67 @@ + + + diff --git a/packages/search-relevance/resources/js/pages/Query.vue b/packages/search-relevance/resources/js/pages/Query.vue new file mode 100644 index 0000000000..7225b08405 --- /dev/null +++ b/packages/search-relevance/resources/js/pages/Query.vue @@ -0,0 +1,172 @@ + + + diff --git a/packages/search-relevance/resources/js/pages/Settings/Index.vue b/packages/search-relevance/resources/js/pages/Settings/Index.vue new file mode 100644 index 0000000000..6b50f28947 --- /dev/null +++ b/packages/search-relevance/resources/js/pages/Settings/Index.vue @@ -0,0 +1,94 @@ + + + diff --git a/packages/search-relevance/resources/lang/en/panel.php b/packages/search-relevance/resources/lang/en/panel.php new file mode 100644 index 0000000000..d2740568f9 --- /dev/null +++ b/packages/search-relevance/resources/lang/en/panel.php @@ -0,0 +1,113 @@ + 'Search', + 'nav_label' => 'Search relevance', + 'settings_label' => 'Search relevance', + + 'title' => 'Search relevance', + 'description' => 'How shoppers search, what they click, and what the learned ranking has picked up.', + 'range' => 'Range', + + 'kpi_searches' => 'Searches', + 'kpi_click_through_rate' => 'Click-through rate', + 'kpi_conversion_rate' => 'Conversion rate', + 'kpi_zero_result_rate' => 'Zero-result rate', + 'kpi_mean_click_position' => 'Mean click position', + 'kpi_no_clicks' => 'No clicks yet', + + 'uplift_title' => 'Uplift', + 'uplift_description' => 'Replays every search in the range that led to a purchase and compares where the purchased product sat in the shown order against the learned order.', + 'uplift_searches' => 'Purchased searches', + 'uplift_mrr_shown' => 'MRR shown', + 'uplift_mrr_ranked' => 'MRR ranked', + 'uplift_improved' => 'Improved', + 'uplift_worsened' => 'Worsened', + 'uplift_empty' => 'No purchases were attributed to a search in this range yet.', + + 'top_queries_title' => 'Top queries', + 'zero_result_title' => 'Zero-result queries', + 'zero_result_description' => 'Searches that returned nothing. Synonyms, redirects or new products fix these.', + 'no_click_title' => 'Queries with no clicks', + 'no_click_description' => 'Searches that returned results nobody opened. Merchandising opportunities.', + 'column_query' => 'Query', + 'column_searches' => 'Searches', + 'column_clicks' => 'Clicks', + 'column_conversions' => 'Conversions', + 'column_conversion_rate' => 'Conv. rate', + 'empty_queries' => 'No searches were logged in this range.', + 'zero_result_empty' => 'Every search in this range returned results.', + 'no_click_empty' => 'Every search in this range got a click.', + + 'query_title' => 'Query', + 'query_description' => 'Learned products for this query in score order, with the events behind each score.', + 'query_learned_title' => 'Learned products', + 'query_learned_empty' => 'Nothing has been learned for this query yet. Scores appear after the nightly scoring run once enough sessions have engaged.', + 'query_variants_title' => 'Raw variants', + 'query_variants_empty' => 'No raw queries recorded.', + 'query_missing_product' => 'Product #:id (deleted)', + 'query_model' => 'Model', + 'column_product' => 'Product', + 'column_relative' => 'Relative', + 'column_score' => 'Score', + 'column_baskets' => 'Baskets', + 'column_purchases' => 'Purchases', + 'column_sessions' => 'Sessions', + 'column_last_event' => 'Last event', + 'column_typical_position' => 'Engine pos.', + 'column_raw_query' => 'Raw query', + 'override_exclude' => 'Exclude from learning', + 'override_exclude_confirm' => 'Stop learning this product for this query? Its learned score is removed now and future events for it are ignored.', + 'override_excluded' => 'Product excluded from learning for this query.', + 'override_included' => 'Product can be learned for this query again.', + 'override_include' => 'Allow again', + 'excluded_title' => 'Excluded products', + 'excluded_description' => 'Products staff have kept out of the learned list for this query.', + 'excluded_empty' => 'No products are excluded.', + 'override_reset' => 'Reset learning', + 'override_reset_title' => 'Reset learning for this query?', + 'override_reset_description' => 'Everything learned for this query is discarded now, and only events from this point on count. Use this when the learned order looks manipulated or wrong.', + 'override_reset_done' => 'Learning reset for this query.', + 'reset_at' => 'Learning reset on {date}; earlier events are ignored.', + 'relative_tooltip' => 'Score relative to the strongest product for this query (1.0 is the top).', + 'typical_position_tooltip' => 'Average position the product sat at when shoppers engaged with it.', + + 'product_title' => 'Search performance', + 'product_description' => 'Queries this product is found through, with the clicks and purchases they produced.', + 'product_empty' => 'No search activity has been recorded for this product yet.', + 'product_card_title' => 'Search', + 'product_card_more' => '{count} more', + 'product_view_report' => 'View search report', + 'product_edit' => 'Edit product', + + 'settings_title' => 'Search relevance', + 'settings_description' => 'How learned ranking is configured for this store. These values are set in code, like the rest of Lunar\'s configuration.', + 'settings_mode' => 'Mode', + 'settings_mode_off' => 'Off', + 'settings_mode_shadow' => 'Shadow', + 'settings_mode_on' => 'On', + 'settings_mode_help_off' => 'Nothing is logged or ranked.', + 'settings_mode_help_shadow' => 'Searches are logged and the learned order is stored, but shoppers see the engine order.', + 'settings_mode_help_on' => 'Shoppers see the learned order.', + 'settings_mode_config' => 'Set with the {env} environment variable or the lunar.search_relevance.mode config key. Check the uplift card before switching to On.', + 'settings_weights' => 'Event weights', + 'settings_weight_click' => 'Click', + 'settings_weight_basket' => 'Basket', + 'settings_weight_purchase' => 'Purchase', + 'settings_scoring' => 'Scoring', + 'settings_schedule' => 'Runs daily at {time}', + 'settings_last_run' => 'Last run', + 'settings_last_run_never' => 'Not run yet', + 'settings_versions' => 'Retrieval versions', + 'settings_versions_help' => 'Scores are learned per version; changing the normaliser or engine starts a fresh version.', + + 'widget_label' => 'Search conversion', + 'widget_description' => 'Searches and search conversion rate in the selected range.', + 'widget_searches' => 'Searches', + 'widget_conversion_rate' => 'Conversion rate', + 'widget_view' => 'View report', + 'delta_new' => 'new', + + 'search_source' => 'Search queries', + 'search_source_hint' => ':count searches', +]; diff --git a/packages/search-relevance/resources/views/components/tracking.blade.php b/packages/search-relevance/resources/views/components/tracking.blade.php new file mode 100644 index 0000000000..8d67afd950 --- /dev/null +++ b/packages/search-relevance/resources/views/components/tracking.blade.php @@ -0,0 +1,8 @@ +@props(['results']) +@php($searchId = $results->meta['search_id'] ?? null) +@if ($searchId) + +@endif diff --git a/packages/search-relevance/routes/storefront.php b/packages/search-relevance/routes/storefront.php new file mode 100644 index 0000000000..d75e456ce4 --- /dev/null +++ b/packages/search-relevance/routes/storefront.php @@ -0,0 +1,8 @@ +middleware(['web', 'throttle:lunar-search-relevance-events']) + ->name('lunar.search-relevance.events'); diff --git a/packages/search-relevance/src/Console/PruneCommand.php b/packages/search-relevance/src/Console/PruneCommand.php new file mode 100644 index 0000000000..0338dfb3c4 --- /dev/null +++ b/packages/search-relevance/src/Console/PruneCommand.php @@ -0,0 +1,27 @@ +subDays((int) $config->get('lunar.search_relevance.retention_days', 400)); + + $events = SearchEvent::query()->where('created_at', '<', $cutoff)->delete(); + $queries = SearchQuery::query()->where('created_at', '<', $cutoff)->delete(); + + $this->info("Pruned {$queries} searches and {$events} events older than {$cutoff->toDateString()}."); + + return self::SUCCESS; + } +} diff --git a/packages/search-relevance/src/Console/ReplayCommand.php b/packages/search-relevance/src/Console/ReplayCommand.php new file mode 100644 index 0000000000..d57b699821 --- /dev/null +++ b/packages/search-relevance/src/Console/ReplayCommand.php @@ -0,0 +1,29 @@ +option('days')); + $result = $replay->run(now()->subDays($days)); + + $this->table(['Metric', 'Value'], [ + ['Searches with a purchase', $result->searches], + ['MRR (shown order)', number_format($result->mrrShown, 3)], + ['MRR (reranked order)', number_format($result->mrrRanked, 3)], + ['Improved', number_format($result->improvedShare * 100, 1).'%'], + ['Worsened', number_format($result->worsenedShare * 100, 1).'%'], + ]); + + return self::SUCCESS; + } +} diff --git a/packages/search-relevance/src/Console/ScoreCommand.php b/packages/search-relevance/src/Console/ScoreCommand.php new file mode 100644 index 0000000000..a57a6346e1 --- /dev/null +++ b/packages/search-relevance/src/Console/ScoreCommand.php @@ -0,0 +1,35 @@ +get('lunar.search_relevance.models', []); + $versions = collect($models)->map(fn (string $model) => $version->current($model))->unique()->values(); + + $scoring = [ + ...$config->get('lunar.search_relevance.scoring', []), + 'max_searches_per_minute' => $config->get('lunar.search_relevance.guards.max_searches_per_minute', 30), + 'trusted_sessions_only' => (bool) $config->get('lunar.search_relevance.guards.trusted_sessions_only', true), + 'keep_versions' => $versions->all(), + ]; + + foreach ($versions as $current) { + $written = $aggregator->aggregate($current, $scoring); + $this->info("[{$current}] wrote {$written} query/product rows."); + } + + return self::SUCCESS; + } +} diff --git a/packages/search-relevance/src/Contracts/QueryNormaliser.php b/packages/search-relevance/src/Contracts/QueryNormaliser.php new file mode 100644 index 0000000000..797a3d7e19 --- /dev/null +++ b/packages/search-relevance/src/Contracts/QueryNormaliser.php @@ -0,0 +1,11 @@ + $config + */ + public function aggregate(string $version, array $config): int; +} diff --git a/packages/search-relevance/src/Contracts/Signal.php b/packages/search-relevance/src/Contracts/Signal.php new file mode 100644 index 0000000000..395b625d91 --- /dev/null +++ b/packages/search-relevance/src/Contracts/Signal.php @@ -0,0 +1,14 @@ + $productIds + * @return array product_id => 0..1 + */ + public function scores(RankingContext $context, array $productIds): array; +} diff --git a/packages/search-relevance/src/DataObjects/Hit.php b/packages/search-relevance/src/DataObjects/Hit.php new file mode 100644 index 0000000000..9fe673b341 --- /dev/null +++ b/packages/search-relevance/src/DataObjects/Hit.php @@ -0,0 +1,55 @@ + $this->productId, + 'original_position' => $this->originalPosition, + 'score' => $this->score, + 'document' => $this->document, + 'source' => $this->source, + 'boost' => $this->boost, + ]; + } + + public static function fromArray(array $data): self + { + $hit = new self( + (int) $data['product_id'], + (int) $data['original_position'], + (float) $data['score'], + $data['document'], + $data['source'] ?? 'organic', + ); + $hit->boost = $data['boost'] ?? null; + + return $hit; + } + + public function withPosition(int $position): self + { + $hit = new self($this->productId, $position, $this->score, $this->document, $this->source); + $hit->boost = $this->boost; + + return $hit; + } +} diff --git a/packages/search-relevance/src/DataObjects/HitCollection.php b/packages/search-relevance/src/DataObjects/HitCollection.php new file mode 100644 index 0000000000..bd31e96252 --- /dev/null +++ b/packages/search-relevance/src/DataObjects/HitCollection.php @@ -0,0 +1,27 @@ + */ +final class HitCollection extends Collection +{ + /** @return array */ + public function productIds(): array + { + return $this->map(fn (Hit $hit) => $hit->productId)->values()->all(); + } + + /** @return array> */ + public function toArrays(): array + { + return $this->map(fn (Hit $hit) => $hit->toArray())->values()->all(); + } + + /** @param array> $rows */ + public static function fromArrays(array $rows): self + { + return new self(array_map([Hit::class, 'fromArray'], $rows)); + } +} diff --git a/packages/search-relevance/src/DataObjects/RankingContext.php b/packages/search-relevance/src/DataObjects/RankingContext.php new file mode 100644 index 0000000000..f70d9332c6 --- /dev/null +++ b/packages/search-relevance/src/DataObjects/RankingContext.php @@ -0,0 +1,25 @@ +mode !== 'off' + && ! $this->sort + && $this->normalisedQuery !== '' + && $this->normalisedQuery !== '*'; + } +} diff --git a/packages/search-relevance/src/Http/Controllers/SearchEventController.php b/packages/search-relevance/src/Http/Controllers/SearchEventController.php new file mode 100644 index 0000000000..1d1da76138 --- /dev/null +++ b/packages/search-relevance/src/Http/Controllers/SearchEventController.php @@ -0,0 +1,61 @@ +validate([ + 'search_id' => ['required', 'string', 'size:26'], + 'product_id' => ['required', 'integer'], + 'position' => ['required', 'integer', 'min:1'], + 'source' => ['nullable', 'in:organic,learned,explore'], + 'session_id' => ['nullable', 'string', 'max:64'], + ]); + } catch (ValidationException) { + return response()->noContent(); + } + + $productId = (int) $data['product_id']; + $position = (int) $data['position']; + $search = SearchQuery::query()->find($data['search_id']); + + $window = (int) $this->config->get('lunar.search_relevance.guards.event_window_minutes', 120); + + if (! $search || ! RecordEvent::accepts($search, $productId, $position, $window)) { + return response()->noContent(); + } + + $source = $data['source'] ?? 'organic'; + $sessionId = $data['session_id'] ?? $this->logger->sessionId(); + + $this->bus->dispatch(new RecordEvent($data['search_id'], $productId, $position, 'click', $source, $sessionId)); + + $this->attribution->remember($productId, $data['search_id'], $position, $source, $sessionId); + + return response()->noContent(); + } +} diff --git a/packages/search-relevance/src/Jobs/LogSearch.php b/packages/search-relevance/src/Jobs/LogSearch.php new file mode 100644 index 0000000000..934d157b9d --- /dev/null +++ b/packages/search-relevance/src/Jobs/LogSearch.php @@ -0,0 +1,24 @@ + $attributes Includes the ulid generated at request time. */ + public function __construct(public array $attributes) {} + + public function handle(): void + { + SearchQuery::query()->create($this->attributes); + } +} diff --git a/packages/search-relevance/src/Jobs/RecordEvent.php b/packages/search-relevance/src/Jobs/RecordEvent.php new file mode 100644 index 0000000000..10de448918 --- /dev/null +++ b/packages/search-relevance/src/Jobs/RecordEvent.php @@ -0,0 +1,91 @@ +shown ?? []); + + if ($windowMinutes > 0 && $search->created_at && $search->created_at->lt(now()->subMinutes($windowMinutes))) { + return false; + } + + return in_array($productId, $shown, true) && $position >= 1 && $position <= count($shown); + } + + public function handle(Repository $config): void + { + if (! in_array($this->type, self::TYPES, true) || ! in_array($this->source, self::SOURCES, true)) { + return; + } + + $search = SearchQuery::query()->find($this->searchId); + + $window = (int) $config->get('lunar.search_relevance.guards.event_window_minutes', 120); + + if (! $search || ! self::accepts($search, $this->productId, $this->position, $window)) { + return; + } + + $exists = SearchEvent::query() + ->where('search_id', $this->searchId) + ->where('product_id', $this->productId) + ->where('type', $this->type) + ->exists(); + + if ($exists) { + return; + } + + try { + SearchEvent::query()->create([ + 'search_id' => $this->searchId, + 'product_id' => $this->productId, + 'position' => $this->position, + 'type' => $this->type, + 'source' => $this->source, + 'session_id' => mb_substr($this->sessionId, 0, 64), + 'created_at' => now(), + ]); + } catch (UniqueConstraintViolationException) { + // A concurrent worker recorded the same event first. + } + } +} diff --git a/packages/search-relevance/src/Learning/Overrides.php b/packages/search-relevance/src/Learning/Overrides.php new file mode 100644 index 0000000000..28aad019c7 --- /dev/null +++ b/packages/search-relevance/src/Learning/Overrides.php @@ -0,0 +1,89 @@ +firstOrCreate([ + 'model_type' => $modelType, + 'normalised_query' => $query, + 'product_id' => $productId, + 'type' => LearningOverride::EXCLUDE, + ], ['created_at' => now()]); + + SearchQueryScore::query() + ->where('model_type', $modelType) + ->where('normalised_query', $query) + ->where('product_id', $productId) + ->delete(); + + $this->affinity->forget(); + } + + public function include(string $modelType, string $query, int $productId): void + { + LearningOverride::query() + ->where('model_type', $modelType) + ->where('normalised_query', $query) + ->where('product_id', $productId) + ->where('type', LearningOverride::EXCLUDE) + ->delete(); + } + + /** Discard everything learned for the query; only events after now count again. */ + public function reset(string $modelType, string $query): void + { + LearningOverride::query()->create([ + 'model_type' => $modelType, + 'normalised_query' => $query, + 'product_id' => null, + 'type' => LearningOverride::RESET, + 'created_at' => now(), + ]); + + SearchQueryScore::query() + ->where('model_type', $modelType) + ->where('normalised_query', $query) + ->delete(); + + $this->affinity->forget(); + } + + /** @return Collection */ + public function excluded(string $modelType, string $query): Collection + { + return LearningOverride::query() + ->where('model_type', $modelType) + ->where('normalised_query', $query) + ->where('type', LearningOverride::EXCLUDE) + ->orderBy('id') + ->pluck('product_id') + ->map(fn ($id) => (int) $id); + } + + public function resetAt(string $modelType, string $query): ?Carbon + { + $at = LearningOverride::query() + ->where('model_type', $modelType) + ->where('normalised_query', $query) + ->where('type', LearningOverride::RESET) + ->max('created_at'); + + return $at ? Carbon::parse($at) : null; + } +} diff --git a/packages/search-relevance/src/Listeners/AttributeOrderLines.php b/packages/search-relevance/src/Listeners/AttributeOrderLines.php new file mode 100644 index 0000000000..3571ce9b30 --- /dev/null +++ b/packages/search-relevance/src/Listeners/AttributeOrderLines.php @@ -0,0 +1,47 @@ +order; + $order->loadMissing('lines'); + + $variantIds = $order->lines + ->filter(fn (OrderLine $line) => $line->purchasable_type === ProductVariant::morphName() || $line->purchasable_type === ProductVariant::class) + ->pluck('purchasable_id'); + + $productIds = ProductVariant::query()->whereKey($variantIds)->pluck('product_id', 'id'); + + foreach ($order->lines as $line) { + $attribution = $line->meta['search_attribution'] ?? null; + $productId = $productIds->get($line->purchasable_id); + + if (! is_array($attribution) || empty($attribution['search_id']) || $productId === null) { + continue; + } + + $sessionId = $attribution['session_id'] ?? ($order->cart_id ? 'cart:'.$order->cart_id : 'session:none'); + + $this->bus->dispatch(new RecordEvent( + (string) $attribution['search_id'], + (int) $productId, + (int) ($attribution['position'] ?? 0), + 'purchase', + (string) ($attribution['source'] ?? 'organic'), + (string) $sessionId, + )); + } + } +} diff --git a/packages/search-relevance/src/Listeners/ForgetPurchases.php b/packages/search-relevance/src/Listeners/ForgetPurchases.php new file mode 100644 index 0000000000..c595f5943f --- /dev/null +++ b/packages/search-relevance/src/Listeners/ForgetPurchases.php @@ -0,0 +1,43 @@ +order; + $order->loadMissing('lines'); + + $variantIds = $order->lines + ->filter(fn (OrderLine $line) => $line->purchasable_type === ProductVariant::morphName() || $line->purchasable_type === ProductVariant::class) + ->pluck('purchasable_id'); + + $productIds = ProductVariant::query()->whereKey($variantIds)->pluck('product_id', 'id'); + + foreach ($order->lines as $line) { + $attribution = $line->meta['search_attribution'] ?? null; + $productId = $productIds->get($line->purchasable_id); + + if (! is_array($attribution) || empty($attribution['search_id']) || $productId === null) { + continue; + } + + SearchEvent::query() + ->where('search_id', (string) $attribution['search_id']) + ->where('product_id', (int) $productId) + ->where('type', 'purchase') + ->delete(); + } + } +} diff --git a/packages/search-relevance/src/Logging/SearchLogger.php b/packages/search-relevance/src/Logging/SearchLogger.php new file mode 100644 index 0000000000..8aa0d583cd --- /dev/null +++ b/packages/search-relevance/src/Logging/SearchLogger.php @@ -0,0 +1,101 @@ +request) { + return true; + } + + $agent = mb_strtolower((string) $this->request->userAgent()); + + foreach ($this->config->get('lunar.search_relevance.guards.ignored_user_agents', []) as $needle) { + if ($needle !== '' && str_contains($agent, mb_strtolower($needle))) { + return false; + } + } + + return true; + } + + /** + * The shopper identifier: `cart:{id}` when session_key is `cart` and a + * cart exists, otherwise `session:{id}`. + */ + public function sessionId(): string + { + if ($this->config->get('lunar.search_relevance.session_key', 'cart') === 'cart') { + $cart = $this->cartSession->current(calculate: false); + + if ($cart) { + return 'cart:'.$cart->id; + } + } + + return 'session:'.($this->session?->getId() ?: 'none'); + } + + public function customerId(): ?int + { + return $this->storefrontSession->getCustomer()?->id; + } + + /** + * @param array $shown + * @param array|null $ranked + * @param array>|null $features + */ + public function log( + string $searchId, + RankingContext $context, + string $rawQuery, + int $resultCount, + array $shown, + ?array $ranked, + ?array $features = null, + ): void { + $this->bus->dispatch(new LogSearch([ + 'id' => $searchId, + 'model_type' => $context->modelType, + 'raw_query' => $rawQuery, + 'normalised_query' => mb_substr($context->normalisedQuery, 0, 255), + 'filters_hash' => $context->filtersHash, + 'session_id' => mb_substr($context->sessionId, 0, 64), + 'customer_id' => $context->customerId, + 'version' => $context->version, + 'mode' => $context->mode, + 'result_count' => $resultCount, + 'shown' => array_values($shown), + 'ranked' => $ranked === null ? null : array_values($ranked), + 'features' => $features, + 'created_at' => now(), + ])); + } +} diff --git a/packages/search-relevance/src/Models/LearningOverride.php b/packages/search-relevance/src/Models/LearningOverride.php new file mode 100644 index 0000000000..d5d5c3442a --- /dev/null +++ b/packages/search-relevance/src/Models/LearningOverride.php @@ -0,0 +1,35 @@ + 'datetime', + ]; +} diff --git a/packages/search-relevance/src/Models/SearchEvent.php b/packages/search-relevance/src/Models/SearchEvent.php new file mode 100644 index 0000000000..bf02d1dc92 --- /dev/null +++ b/packages/search-relevance/src/Models/SearchEvent.php @@ -0,0 +1,44 @@ + 'integer', + 'position' => 'integer', + 'created_at' => 'datetime', + ]; + + protected static function newFactory(): SearchEventFactory + { + return SearchEventFactory::new(); + } + + public function searchQuery(): BelongsTo + { + return $this->belongsTo(SearchQuery::class, 'search_id'); + } +} diff --git a/packages/search-relevance/src/Models/SearchQuery.php b/packages/search-relevance/src/Models/SearchQuery.php new file mode 100644 index 0000000000..7881bb9a0a --- /dev/null +++ b/packages/search-relevance/src/Models/SearchQuery.php @@ -0,0 +1,53 @@ + $shown + * @property ?array $ranked + * @property ?array> $features + * @property ?Carbon $created_at + */ +class SearchQuery extends Base +{ + use HasFactory; + use HasUlids; + + public $timestamps = false; + + protected $guarded = []; + + protected $casts = [ + 'shown' => 'array', + 'ranked' => 'array', + 'features' => 'array', + 'created_at' => 'datetime', + ]; + + protected static function newFactory(): SearchQueryFactory + { + return SearchQueryFactory::new(); + } + + public function events(): HasMany + { + return $this->hasMany(SearchEvent::class, 'search_id'); + } +} diff --git a/packages/search-relevance/src/Models/SearchQueryScore.php b/packages/search-relevance/src/Models/SearchQueryScore.php new file mode 100644 index 0000000000..4490167db8 --- /dev/null +++ b/packages/search-relevance/src/Models/SearchQueryScore.php @@ -0,0 +1,47 @@ + 'integer', + 'score' => 'float', + 'relative' => 'float', + 'sessions' => 'integer', + 'updated_at' => 'datetime', + ]; + + protected static function newFactory(): SearchQueryScoreFactory + { + return SearchQueryScoreFactory::new(); + } +} diff --git a/packages/search-relevance/src/Normalisers/DefaultQueryNormaliser.php b/packages/search-relevance/src/Normalisers/DefaultQueryNormaliser.php new file mode 100644 index 0000000000..19625dd9da --- /dev/null +++ b/packages/search-relevance/src/Normalisers/DefaultQueryNormaliser.php @@ -0,0 +1,52 @@ +isPartNumber($q)) { + return $q; // never stem or reshape a part number + } + + // Light plural stemming so "cable ties" and "cable tie" pool their data + $q = preg_replace('/\b(\p{L}{2,})ies\b/u', '$1y', $q); + $q = preg_replace('/\b(\p{L}+(?:x|ch|sh))es\b/u', '$1', $q); + $q = preg_replace('/\b(\p{L}{2,}[^s\W])s\b/u', '$1', $q); + + return preg_replace('/\s+/u', ' ', trim($q)); + } + + public function isPartNumber(string $query): bool + { + return static::looksLikePartNumber($query); + } + + /** One token mixing letters and digits (hyphens, dots and slashes allowed), e.g. HAG-MB-32A or hagmb32. */ + public static function looksLikePartNumber(string $query): bool + { + $q = trim($query); + + return $q !== '' + && ! preg_match('/\s/u', $q) + && ! preg_match('/^\d+(\.\d+)?('.static::UNITS.')$/i', $q) // a bare size, not a part number + && preg_match('/^[a-z0-9][a-z0-9.\-\/]*$/i', $q) + && preg_match('/\p{L}/u', $q) + && preg_match('/\d/', $q); + } +} diff --git a/packages/search-relevance/src/Observers/CartLineObserver.php b/packages/search-relevance/src/Observers/CartLineObserver.php new file mode 100644 index 0000000000..90a0391615 --- /dev/null +++ b/packages/search-relevance/src/Observers/CartLineObserver.php @@ -0,0 +1,61 @@ +productId($line); + + if ($productId === null) { + return; + } + + $attribution = $this->attribution->find($productId); + + if (! $attribution) { + return; + } + + $meta = $line->meta?->getArrayCopy() ?? []; + $meta['search_attribution'] = [ + 'search_id' => $attribution['search_id'], + 'position' => $attribution['position'], + 'source' => $attribution['source'], + 'session_id' => $attribution['session_id'], + ]; + $line->meta = $meta; + $line->saveQuietly(); + + $this->bus->dispatch(new RecordEvent( + $attribution['search_id'], + $productId, + (int) $attribution['position'], + 'basket', + $attribution['source'], + $attribution['session_id'], + )); + } + + protected function productId(CartLine $line): ?int + { + if ($line->purchasable_type !== ProductVariant::morphName() && $line->purchasable_type !== ProductVariant::class) { + return null; + } + + return ProductVariant::query()->whereKey($line->purchasable_id)->value('product_id'); + } +} diff --git a/packages/search-relevance/src/Panel/Http/Controllers/OverridesController.php b/packages/search-relevance/src/Panel/Http/Controllers/OverridesController.php new file mode 100644 index 0000000000..b7971cf9b9 --- /dev/null +++ b/packages/search-relevance/src/Panel/Http/Controllers/OverridesController.php @@ -0,0 +1,41 @@ +exclude($this->modelType($request), $query, $productId); + + return back()->with('success', __('search-relevance::panel.override_excluded')); + } + + public function include(Request $request, Overrides $overrides, string $query, int $productId): RedirectResponse + { + $overrides->include($this->modelType($request), $query, $productId); + + return back()->with('success', __('search-relevance::panel.override_included')); + } + + public function reset(Request $request, Overrides $overrides, string $query): RedirectResponse + { + $overrides->reset($this->modelType($request), $query); + + return back()->with('success', __('search-relevance::panel.override_reset_done')); + } + + protected function modelType(Request $request): string + { + $modelTypes = QueryReport::modelTypes(); + $modelType = (string) $request->input('model', $modelTypes->first()); + + return $modelTypes->contains($modelType) ? $modelType : (string) $modelTypes->first(); + } +} diff --git a/packages/search-relevance/src/Panel/Http/Controllers/SearchRelevanceController.php b/packages/search-relevance/src/Panel/Http/Controllers/SearchRelevanceController.php new file mode 100644 index 0000000000..5198fd8f58 --- /dev/null +++ b/packages/search-relevance/src/Panel/Http/Controllers/SearchRelevanceController.php @@ -0,0 +1,102 @@ +query('range')); + $uplift = $replay->run($range->start()); + + return Inertia::render('search-relevance::Index', [ + 'range' => $range->value, + 'ranges' => array_map(fn (DashboardRange $case) => [ + 'value' => $case->value, + 'label' => __("panel::dashboard.range_{$case->value}"), + ], DashboardRange::cases()), + 'kpis' => $kpis->forWindow($range->start(), $range->end()), + 'uplift' => [ + 'searches' => $uplift->searches, + 'mrr_shown' => round($uplift->mrrShown, 3), + 'mrr_ranked' => round($uplift->mrrRanked, 3), + 'improved_share' => round($uplift->improvedShare * 100, 1), + 'worsened_share' => round($uplift->worsenedShare * 100, 1), + ], + 'top_queries' => $topQueries->top($range->start(), $range->end()), + 'zero_result_queries' => $topQueries->zeroResult($range->start(), $range->end()), + 'no_click_queries' => $topQueries->noClick($range->start(), $range->end()), + 'urls' => ['index' => route('panel.search-relevance.index')], + ]); + } + + public function query(Request $request, QueryReport $report, Overrides $overrides, string $query): Response + { + $modelTypes = QueryReport::modelTypes(); + $modelType = (string) $request->query('model', $modelTypes->first()); + $params = ['query' => $query, 'model' => $modelType]; + + $learned = array_map(fn (array $row) => [ + ...$row, + '_actions' => ['exclude' => route('panel.search-relevance.exclude', [...$params, 'productId' => $row['product_id']])], + ], $report->learned($modelType, $query)); + + $excluded = $overrides->excluded($modelType, $query); + $names = Product::query()->whereKey($excluded->all())->get()->keyBy('id'); + + return Inertia::render('search-relevance::Query', [ + 'query' => $query, + 'model_type' => $modelType, + 'learned' => $learned, + 'variants' => $report->variants($query), + 'excluded' => $excluded->map(fn (int $id) => [ + 'product_id' => $id, + 'name' => ($product = $names->get($id)) ? (string) $product->translate('name') : __('search-relevance::panel.query_missing_product', ['id' => $id]), + 'url' => route('panel.search-relevance.include', [...$params, 'productId' => $id]), + ])->values()->all(), + 'reset_at' => $overrides->resetAt($modelType, $query)?->toIso8601String(), + 'urls' => [ + 'index' => route('panel.search-relevance.index'), + 'reset' => route('panel.search-relevance.reset', $params), + ], + ]); + } + + public function product(Product $product, ProductReport $report): Response + { + return Inertia::render('search-relevance::Product', [ + 'product' => [ + 'id' => $product->id, + 'name' => (string) $product->translate('name'), + 'edit_url' => route('panel.products.edit', $product), + ], + 'queries' => $report->queries($product, 100), + 'urls' => ['index' => route('panel.search-relevance.index')], + ]); + } + + /** The top queries for the product edit sidebar card. */ + public function productSummary(Product $product, ProductReport $report): JsonResponse + { + $queries = $report->queries($product, 100); + + return response()->json([ + 'queries' => array_slice($queries, 0, 3), + 'total' => count($queries), + 'url' => route('panel.search-relevance.product', $product), + ]); + } +} diff --git a/packages/search-relevance/src/Panel/Http/Controllers/SettingsController.php b/packages/search-relevance/src/Panel/Http/Controllers/SettingsController.php new file mode 100644 index 0000000000..46c7130a12 --- /dev/null +++ b/packages/search-relevance/src/Panel/Http/Controllers/SettingsController.php @@ -0,0 +1,36 @@ +max('updated_at'); + + return Inertia::render('search-relevance::Settings/Index', [ + 'mode' => (string) $config->get('lunar.search_relevance.mode', 'shadow'), + 'mode_env' => 'LUNAR_SEARCH_RELEVANCE_MODE', + 'weights' => $config->get('lunar.search_relevance.scoring.weights', []), + 'schedule' => (string) $config->get('lunar.search_relevance.scoring.schedule', '02:00'), + 'last_run' => $lastRun ? (string) $lastRun : null, + 'versions' => QueryReport::modelTypes()->map(fn (string $model) => [ + 'model' => $model, + 'label' => class_basename($model), + 'version' => $version->current($model), + ])->values()->all(), + ]); + } +} diff --git a/packages/search-relevance/src/Panel/Reports/ProductReport.php b/packages/search-relevance/src/Panel/Reports/ProductReport.php new file mode 100644 index 0000000000..734baa21bf --- /dev/null +++ b/packages/search-relevance/src/Panel/Reports/ProductReport.php @@ -0,0 +1,60 @@ + + */ + public function queries(Product $product, int $limit = 20): array + { + $events = (new SearchEvent)->getTable(); + $queries = (new SearchQuery)->getTable(); + + $rows = SearchEvent::query() + ->join($queries, "{$queries}.id", '=', "{$events}.search_id") + ->where("{$events}.product_id", $product->getKey()) + ->groupBy("{$queries}.normalised_query") + ->selectRaw("{$queries}.normalised_query as normalised_query") + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as clicks", ['click']) + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as baskets", ['basket']) + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as purchases", ['purchase']) + ->orderByDesc('purchases') + ->orderByDesc('clicks') + ->orderBy('normalised_query') + ->limit($limit) + ->get(); + + $learned = SearchQueryScore::query() + ->where('model_type', $product::class) + ->where('version', $this->version->current($product::class)) + ->where('product_id', $product->getKey()) + ->whereIn('normalised_query', $rows->pluck('normalised_query')->all()) + ->get() + ->keyBy('normalised_query'); + + return $rows->map(function (SearchEvent $row) use ($learned): array { + $query = (string) $row->normalised_query; + $score = $learned->get($query); + + return [ + 'query' => $query, + 'relative' => $score ? round((float) $score->relative, 3) : null, + 'clicks' => (int) $row->clicks, + 'baskets' => (int) $row->baskets, + 'purchases' => (int) $row->purchases, + 'url' => route('panel.search-relevance.query', ['query' => $query]), + ]; + })->all(); + } +} diff --git a/packages/search-relevance/src/Panel/Reports/QueryReport.php b/packages/search-relevance/src/Panel/Reports/QueryReport.php new file mode 100644 index 0000000000..12ce27abe8 --- /dev/null +++ b/packages/search-relevance/src/Panel/Reports/QueryReport.php @@ -0,0 +1,105 @@ + + */ + public function learned(string $modelType, string $query): array + { + $scores = SearchQueryScore::query() + ->where('model_type', $modelType) + ->where('normalised_query', $query) + ->where('version', $this->version->current($modelType)) + ->orderByDesc('relative') + ->orderBy('product_id') + ->get(); + + if ($scores->isEmpty()) { + return []; + } + + $ids = $scores->pluck('product_id')->map(fn ($id) => (int) $id)->all(); + $engagement = $this->engagement($modelType, $query)->whereIn('product_id', $ids)->get()->keyBy('product_id'); + $products = Product::query()->whereIn('id', $ids)->get()->keyBy('id'); + + return $scores->map(function (SearchQueryScore $score) use ($engagement, $products): array { + $productId = (int) $score->product_id; + $row = $engagement->get($productId); + $product = $products->get($productId); + $typical = $row?->typical_position; + + return [ + 'product_id' => $productId, + 'name' => $product ? (string) $product->translate('name') : __('search-relevance::panel.query.missing_product', ['id' => $productId]), + 'relative' => round((float) $score->relative, 3), + 'score' => round((float) $score->score, 3), + 'clicks' => (int) ($row?->clicks ?? 0), + 'baskets' => (int) ($row?->baskets ?? 0), + 'purchases' => (int) ($row?->purchases ?? 0), + 'sessions' => (int) $score->sessions, + 'last_event_at' => $row?->last_event_at ? (string) $row->last_event_at : null, + 'typical_position' => $typical === null ? null : round((float) $typical, 1), + 'url' => $product ? route('panel.search-relevance.product', $product) : null, + 'edit_url' => $product ? route('panel.products.edit', $product) : null, + ]; + })->values()->all(); + } + + /** @return array */ + public function variants(string $query): array + { + return SearchQuery::query() + ->where('normalised_query', $query) + ->groupBy('raw_query') + ->selectRaw('raw_query, count(*) as searches') + ->orderByDesc('searches') + ->orderBy('raw_query') + ->limit(50) + ->get() + ->map(fn (SearchQuery $row): array => ['raw_query' => (string) $row->raw_query, 'searches' => (int) $row->searches]) + ->all(); + } + + /** + * Event counts per product for the query, from every logged search of it. + * + * @return Builder + */ + protected function engagement(string $modelType, string $query) + { + $events = (new SearchEvent)->getTable(); + $queries = (new SearchQuery)->getTable(); + + return SearchEvent::query() + ->join($queries, "{$queries}.id", '=', "{$events}.search_id") + ->where("{$queries}.model_type", $modelType) + ->where("{$queries}.normalised_query", $query) + ->groupBy("{$events}.product_id") + ->selectRaw("{$events}.product_id as product_id") + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as clicks", ['click']) + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as baskets", ['basket']) + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as purchases", ['purchase']) + ->selectRaw("max({$events}.created_at) as last_event_at") + ->selectRaw("avg({$events}.position) as typical_position"); + } + + /** @return Collection */ + public static function modelTypes(): Collection + { + return collect(config('lunar.search_relevance.models', [Product::class])); + } +} diff --git a/packages/search-relevance/src/Panel/Reports/SearchKpis.php b/packages/search-relevance/src/Panel/Reports/SearchKpis.php new file mode 100644 index 0000000000..5f51bc8c51 --- /dev/null +++ b/packages/search-relevance/src/Panel/Reports/SearchKpis.php @@ -0,0 +1,66 @@ +searches($start, $end)->count(); + $zeroResults = $this->searches($start, $end)->where('result_count', 0)->count(); + + $clicked = $this->events($start, $end)->where('type', 'click')->distinct()->count('search_id'); + $purchased = $this->events($start, $end)->where('type', 'purchase')->distinct()->count('search_id'); + $meanPosition = $this->events($start, $end)->where('type', 'click')->avg('position'); + + return [ + 'searches' => $searches, + 'click_through_rate' => $this->rate($clicked, $searches), + 'conversion_rate' => $this->rate($purchased, $searches), + 'zero_result_rate' => $this->rate($zeroResults, $searches), + 'mean_click_position' => $meanPosition === null ? null : round((float) $meanPosition, 1), + ]; + } + + /** @return Builder */ + public function searches(DateTimeInterface $start, DateTimeInterface $end): Builder + { + return SearchQuery::query() + ->where('created_at', '>=', $start) + ->where('created_at', '<', $end); + } + + /** + * Events joined to their search, filtered by the search's own timestamp. + * + * @return Builder + */ + public function events(DateTimeInterface $start, DateTimeInterface $end): Builder + { + $events = (new SearchEvent)->getTable(); + $queries = (new SearchQuery)->getTable(); + + return SearchEvent::query() + ->join($queries, "{$queries}.id", '=', "{$events}.search_id") + ->where("{$queries}.created_at", '>=', $start) + ->where("{$queries}.created_at", '<', $end); + } + + public function rate(int $part, int $whole): float + { + return $whole > 0 ? round($part / $whole * 100, 1) : 0.0; + } +} diff --git a/packages/search-relevance/src/Panel/Reports/TopQueries.php b/packages/search-relevance/src/Panel/Reports/TopQueries.php new file mode 100644 index 0000000000..ac5aea8cef --- /dev/null +++ b/packages/search-relevance/src/Panel/Reports/TopQueries.php @@ -0,0 +1,111 @@ + + */ + public function top(DateTimeInterface $start, DateTimeInterface $end, int $limit = 20): array + { + $counts = $this->grouped($this->kpis->searches($start, $end), $limit); + + if ($counts->isEmpty()) { + return []; + } + + $events = (new SearchEvent)->getTable(); + $queries = (new SearchQuery)->getTable(); + + $engagement = $this->kpis->events($start, $end) + ->whereIn("{$queries}.normalised_query", $counts->keys()->all()) + ->groupBy("{$queries}.normalised_query") + ->selectRaw("{$queries}.normalised_query as normalised_query") + ->selectRaw("sum(case when {$events}.type = ? then 1 else 0 end) as clicks", ['click']) + ->selectRaw("count(distinct case when {$events}.type = ? then {$events}.search_id end) as conversions", ['purchase']) + ->get() + ->keyBy('normalised_query'); + + return $counts->map(function (int $searches, string $query) use ($engagement): array { + $row = $engagement->get($query); + $conversions = (int) ($row?->conversions ?? 0); + + return [ + 'query' => $query, + 'searches' => $searches, + 'clicks' => (int) ($row?->clicks ?? 0), + 'conversions' => $conversions, + 'conversion_rate' => $this->kpis->rate($conversions, $searches), + 'url' => $this->url($query), + ]; + })->values()->all(); + } + + /** @return array */ + public function zeroResult(DateTimeInterface $start, DateTimeInterface $end, int $limit = 20): array + { + return $this->simple($this->kpis->searches($start, $end)->where('result_count', 0), $limit); + } + + /** + * Queries that returned results but were never clicked in the window. + * + * @return array + */ + public function noClick(DateTimeInterface $start, DateTimeInterface $end, int $limit = 20): array + { + $queries = (new SearchQuery)->getTable(); + + $clicked = $this->kpis->events($start, $end) + ->where('type', 'click') + ->select("{$queries}.normalised_query"); + + return $this->simple( + $this->kpis->searches($start, $end)->where('result_count', '>', 0)->whereNotIn('normalised_query', $clicked), + $limit, + ); + } + + /** + * @param Builder $searches + * @return array + */ + protected function simple(Builder $searches, int $limit): array + { + return $this->grouped($searches, $limit) + ->map(fn (int $count, string $query): array => ['query' => $query, 'searches' => $count, 'url' => $this->url($query)]) + ->values() + ->all(); + } + + /** + * @param Builder $searches + * @return Collection normalised query => search count, most searched first + */ + protected function grouped(Builder $searches, int $limit): Collection + { + return $searches + ->groupBy('normalised_query') + ->selectRaw('normalised_query, count(*) as searches') + ->orderByDesc('searches') + ->orderBy('normalised_query') + ->limit($limit) + ->get() + ->mapWithKeys(fn (SearchQuery $row) => [(string) $row->normalised_query => (int) $row->searches]); + } + + public function url(string $query): string + { + return route('panel.search-relevance.query', ['query' => $query]); + } +} diff --git a/packages/search-relevance/src/Panel/Search/QuerySearchSource.php b/packages/search-relevance/src/Panel/Search/QuerySearchSource.php new file mode 100644 index 0000000000..4864fef860 --- /dev/null +++ b/packages/search-relevance/src/Panel/Search/QuerySearchSource.php @@ -0,0 +1,71 @@ + + */ + public function query(): Builder + { + return SearchQuery::query() + ->selectRaw('min(id) as id, normalised_query, count(*) as searches') + ->groupBy('normalised_query') + ->orderByDesc('searches') + ->orderBy('normalised_query'); + } + + public function applyTerm(Builder $query, string $token): void + { + $query->where('normalised_query', 'like', "%{$token}%"); + } + + /** @param SearchQuery $model */ + public function row(Model $model): array + { + $query = (string) $model->normalised_query; + + return [ + 'id' => $query, + 'label' => $query, + 'hint' => __('search-relevance::panel.search_source_hint', ['count' => (int) $model->searches]), + 'url' => route('panel.search-relevance.query', ['query' => $query]), + ]; + } +} diff --git a/packages/search-relevance/src/Panel/SearchRelevanceSection.php b/packages/search-relevance/src/Panel/SearchRelevanceSection.php new file mode 100644 index 0000000000..791a147d90 --- /dev/null +++ b/packages/search-relevance/src/Panel/SearchRelevanceSection.php @@ -0,0 +1,118 @@ +group('search', 'search-relevance::panel.nav_group', position: Position::last()); + $registry->addItem('search', new NavigationItem( + key: 'search-relevance', + label: 'search-relevance::panel.nav_label', + icon: 'chart', + route: 'panel.search-relevance.index', + permission: self::PERMISSION, + )); + } + + public function settingsNavigation(NavigationRegistry $registry): void + { + $registry->group('store', 'panel::nav.store', priority: 20); + $registry->addItem('store', new NavigationItem( + key: 'search-relevance', + label: 'search-relevance::panel.settings_label', + route: 'panel.settings.search-relevance.index', + permission: self::PERMISSION, + )); + } + + public function routes(): ?Closure + { + return function (): void { + Route::middleware('can:'.self::PERMISSION)->group(function (): void { + Route::get('search-relevance', [SearchRelevanceController::class, 'index']) + ->name('panel.search-relevance.index'); + + // A normalised query may contain any character, slashes included. + Route::get('search-relevance/queries/{query}', [SearchRelevanceController::class, 'query']) + ->where('query', '.*') + ->name('panel.search-relevance.query'); + + Route::get('search-relevance/products/{product}', [SearchRelevanceController::class, 'product']) + ->name('panel.search-relevance.product'); + Route::get('search-relevance/products/{product}/summary', [SearchRelevanceController::class, 'productSummary']) + ->name('panel.search-relevance.product.summary'); + + // {productId}, not {product}: the panel binds `product` to the model. + Route::post('search-relevance/queries/{query}/exclusions/{productId}', [OverridesController::class, 'exclude']) + ->where(['query' => '.*', 'productId' => '[0-9]+']) + ->name('panel.search-relevance.exclude'); + Route::delete('search-relevance/queries/{query}/exclusions/{productId}', [OverridesController::class, 'include']) + ->where(['query' => '.*', 'productId' => '[0-9]+']) + ->name('panel.search-relevance.include'); + Route::post('search-relevance/queries/{query}/reset', [OverridesController::class, 'reset']) + ->where('query', '.*') + ->name('panel.search-relevance.reset'); + + Route::prefix('settings/search-relevance') + ->name('panel.settings.search-relevance.') + ->group(function (): void { + Route::get('/', [SettingsController::class, 'index'])->name('index'); + }); + }); + }; + } + + public function slots(SlotRegistry $registry): void + { + // A compact sidebar card; the full report lives on its own page. + $registry->add(new Slot( + zone: 'products.edit:sidebar:after', + component: 'search-relevance::ProductSearchPerformance', + permission: self::PERMISSION, + )); + } + + public function widgets(): array + { + return [SearchConversionWidget::class]; + } + + public function searchSources(): array + { + return [QuerySearchSource::class]; + } + + public function langNamespaces(): array + { + return ['search-relevance']; + } +} diff --git a/packages/search-relevance/src/Panel/Widgets/SearchConversionWidget.php b/packages/search-relevance/src/Panel/Widgets/SearchConversionWidget.php new file mode 100644 index 0000000000..f407cec7d3 --- /dev/null +++ b/packages/search-relevance/src/Panel/Widgets/SearchConversionWidget.php @@ -0,0 +1,84 @@ +kpis->forWindow($range->start(), $range->end()); + $previous = $this->kpis->forWindow($range->previousStart(), $range->previousEnd()); + + return [ + 'searches' => $current['searches'], + 'previous_searches' => $previous['searches'], + 'conversion_rate' => $current['conversion_rate'], + 'previous_conversion_rate' => $previous['conversion_rate'], + 'searches_delta' => $this->delta($current['searches'], $previous['searches']), + 'conversion_delta' => $this->delta($current['conversion_rate'], $previous['conversion_rate']), + 'url' => route('panel.search-relevance.index', ['range' => $range->value]), + ]; + } + + /** @return array{value: string, tone: string}|null */ + protected function delta(float $current, float $previous): ?array + { + if ($previous == 0.0 && $current == 0.0) { + return null; + } + + if ($previous == 0.0) { + return ['value' => __('search-relevance::panel.delta_new'), 'tone' => 'sage']; + } + + $rounded = round(($current - $previous) / abs($previous) * 100); + + return [ + 'value' => sprintf('%s%d%%', $rounded >= 0 ? '+' : '', $rounded), + 'tone' => $rounded > 0 ? 'sage' : ($rounded < 0 ? 'danger' : 'neutral'), + ]; + } +} diff --git a/packages/search-relevance/src/Pipelines/PartNumberFallback.php b/packages/search-relevance/src/Pipelines/PartNumberFallback.php new file mode 100644 index 0000000000..23619c54fe --- /dev/null +++ b/packages/search-relevance/src/Pipelines/PartNumberFallback.php @@ -0,0 +1,48 @@ +request; + $overrides = $request->context['relevance_part_number_overrides'] ?? null; + + // No overrides (the Database engine) means the rerun would be the + // same query, so there is nothing to fall back to. + if (! is_array($overrides) || $overrides['keys'] === [] || $response->results->count > 0) { + return $next($response); + } + + $engine = clone $request->engine; + + $engine->withoutParams(...$overrides['keys']) + ->withParams($overrides['previous']) + ->withoutPipelineStages(PartNumberRetrieval::class) + ->page($request->requestedPage) + ->perPage($request->requestedPerPage); + + $results = $engine->get(); + + if (! $results instanceof SearchResults) { + return $next($response); + } + + $response->results = $results; + + return $response; + } +} diff --git a/packages/search-relevance/src/Pipelines/PartNumberRetrieval.php b/packages/search-relevance/src/Pipelines/PartNumberRetrieval.php new file mode 100644 index 0000000000..5955948f5c --- /dev/null +++ b/packages/search-relevance/src/Pipelines/PartNumberRetrieval.php @@ -0,0 +1,97 @@ +engine->getQuery(); + + if ($this->config->get('lunar.search_relevance.mode', 'shadow') === 'off' + || $query === '' + || ! $this->normaliser->isPartNumber($query)) { + return $next($request); + } + + $fields = $this->exactMatchFields($request->engine->getModelType()); + + if ($fields === []) { + return $next($request); + } + + $params = []; + + if ($request->engine instanceof TypesenseEngine) { + $params = [ + 'query_by' => implode(',', $fields), + 'query_by_weights' => null, + 'prefix' => true, + 'infix' => implode(',', array_fill(0, count($fields), 'always')), + 'num_typos' => implode(',', array_fill(0, count($fields), '0')), + 'drop_tokens_threshold' => 0, + 'vector_query' => null, + ]; + } + + if ($request->engine instanceof MeilisearchEngine) { + $params = [ + 'attributesToSearchOn' => $fields, + 'matchingStrategy' => 'all', + ]; + + if ($this->config->get('lunar.search.meilisearch.embedder')) { + $params['hybrid'] = ['semanticRatio' => 0]; + } + } + + $request->context['relevance_part_number'] = true; + $request->context['relevance_part_number_overrides'] = [ + 'keys' => array_keys($params), + 'previous' => array_intersect_key($request->engine->getParams(), $params), + ]; + + if ($params !== []) { + $request->engine->withParams($params); + } + + return $next($request); + } + + /** @return array */ + protected function exactMatchFields(string $modelType): array + { + $model = new $modelType; + + if (! method_exists($model, 'indexer')) { + return []; + } + + $indexer = $model->indexer(); + + return method_exists($indexer, 'getExactMatchFields') + ? array_values($indexer->getExactMatchFields()) + : []; + } +} diff --git a/packages/search-relevance/src/Pipelines/RankResults.php b/packages/search-relevance/src/Pipelines/RankResults.php new file mode 100644 index 0000000000..5e9eedd2ec --- /dev/null +++ b/packages/search-relevance/src/Pipelines/RankResults.php @@ -0,0 +1,323 @@ +request->context['relevance'] ?? null; + + if (! $context instanceof RankingContext) { + return $next($response); + } + + $searchId = (string) Str::ulid(); + + if (empty($response->request->context['relevance_widened'])) { + $this->stampPassthrough($response, $context, $searchId); + + return $next($response); + } + + $request = $response->request; + $results = $response->results; + + $original = $this->toHits($results->hits); + $ranked = $this->rankedWindow($response, $context, $original); + + $displayed = $context->mode === 'on' ? $ranked : $original; + // The engine's total, not the window's: a search matching more than + // the window keeps its later pages, which the engine serves unwidened. + // Learned products unioned into the window add to it. + $count = $results->count + max(0, $displayed->count() - $original->count()); + $perPage = max(1, $request->requestedPerPage); + $page = max(1, $request->requestedPage); + $offset = ($page - 1) * $perPage; + + $hitsById = collect($results->hits)->keyBy(fn (SearchHit $hit) => (int) ($hit->document['id'] ?? 0)); + + $pageHits = []; + foreach ($displayed->slice($offset, $perPage)->values() as $index => $hit) { + $pageHits[] = $this->toSearchHit($hit, $hitsById->get($hit->productId), $offset + $index + 1); + } + + $paginator = new LengthAwarePaginator($pageHits, $count, $perPage, $page, [ + 'path' => LengthAwarePaginator::resolveCurrentPath(), + ]); + + $response->results = SearchResults::from([ + 'query' => $results->query, + 'count' => $count, + 'page' => $page, + 'perPage' => $perPage, + 'totalPages' => max(1, (int) ceil($count / $perPage)), + 'hits' => $pageHits, + 'facets' => $results->facets, + 'links' => $paginator->links(), + 'sortField' => $results->sortField, + 'sortDirection' => $results->sortDirection, + 'meta' => [ + ...$results->meta, + 'search_id' => $searchId, + 'ranking_mode' => $context->mode, + 'ranking_version' => $context->version, + ], + ]); + + $logged = (int) $this->config->get('lunar.search_relevance.impressions_logged', 50); + $rankedIds = array_slice($ranked->productIds(), 0, $logged); + $shownIds = array_slice($displayed->productIds(), 0, $logged); + + $this->logger->log( + $searchId, + $context, + (string) $request->engine->getQuery(), + $count, + $shownIds, + $rankedIds === $shownIds ? null : $rankedIds, + $this->features($ranked, $logged), + ); + + return $next($response); + } + + /** Build (or read from cache) the learned-union plus ranked window. */ + protected function rankedWindow(SearchResponse $response, RankingContext $context, HitCollection $original): HitCollection + { + $key = 'lunar.search_relevance.window:'.md5(implode('|', [ + $context->modelType, + $context->version, + $context->mode, + $context->normalisedQuery, + $context->filtersHash, + (string) $context->customerId, + ])); + + $cached = $this->cache->get($key); + + if (is_array($cached)) { + return HitCollection::fromArrays($cached); + } + + $window = $this->unionLearned($response, $context, new HitCollection($original->all())); + $ranked = $this->ranker->rank($context, $window); + + $this->cache->put($key, $ranked->toArrays(), (int) $this->config->get('lunar.search_relevance.cache_ttl', 300)); + + return $ranked; + } + + /** + * Learned products the engine did not return are inserted at the head of + * the second bucket: they can win a first-page slot but never leap above + * the strongest engine matches. + */ + protected function unionLearned(SearchResponse $response, RankingContext $context, HitCollection $hits): HitCollection + { + $union = $this->config->get('lunar.search_relevance.learned_union', []); + $max = (int) ($union['max'] ?? 5); + $minRelative = (float) ($union['min_relative'] ?? 0.1); + + if ($max < 1) { + return $hits; + } + + $learned = array_filter($this->affinity->learned($context), fn (float $relative) => $relative >= $minRelative); + $missing = array_slice(array_values(array_diff(array_keys($learned), $hits->productIds())), 0, $max); + + if ($missing === []) { + return $hits; + } + + $documents = $this->fetchDocuments($response, $context, $missing); + + if ($documents === []) { + return $hits; + } + + $insertAt = min((int) $this->config->get('lunar.search_relevance.bucket_size', 10), $hits->count()); + $extra = array_map(fn (array $document) => new Hit((int) $document['id'], 0, 0.0, $document, 'learned'), $documents); + + $merged = collect($hits->slice(0, $insertAt)->all()) + ->concat($extra) + ->concat($hits->slice($insertAt)->all()) + ->values() + ->map(fn (Hit $hit, int $index) => $hit->withPosition($index + 1)); + + return new HitCollection($merged->all()); + } + + /** + * Fetch documents by id through a copy of the engine that served the + * request, keeping only the ids asked for and the learned order. The + * Database engine ignores filters, so its documents come from the model. + * Any failure just skips the union. + * + * @param array $ids + * @return array> + */ + protected function fetchDocuments(SearchResponse $response, RankingContext $context, array $ids): array + { + try { + $documents = $response->request->engine instanceof DatabaseEngine + ? $this->documentsFromModel($context->modelType, $ids) + : $this->documentsFromEngine($response, $ids); + } catch (Throwable) { + return []; + } + + return array_values(array_filter(array_map(fn (int $id) => $documents[$id] ?? null, $ids))); + } + + /** @return array> keyed by product id */ + protected function documentsFromEngine(SearchResponse $response, array $ids): array + { + $engine = clone $response->request->engine; + $results = $engine->query('')->filter(['id' => $ids])->page(1)->perPage(count($ids))->get(); + + if (! $results instanceof SearchResults) { + return []; + } + + $wanted = array_flip($ids); + $documents = []; + + foreach ($results->hits as $hit) { + $id = (int) ($hit->document['id'] ?? 0); + + if (isset($wanted[$id]) && ! isset($documents[$id])) { + $documents[$id] = $hit->document; + } + } + + return $documents; + } + + /** @return array> keyed by product id */ + protected function documentsFromModel(string $modelType, array $ids): array + { + /** @var Model $prototype */ + $prototype = new $modelType; + $query = $prototype->newQuery()->whereKey($ids); + + if (method_exists($prototype, 'indexer')) { + $query = $prototype->indexer()->makeAllSearchableUsing($query); + } + + return $query->get() + ->mapWithKeys(fn (Model $model) => [(int) $model->getKey() => $model->toSearchableArray()]) + ->all(); + } + + /** Stamp a search that ran at its requested size so it can still be tracked. */ + protected function stampPassthrough(SearchResponse $response, RankingContext $context, string $searchId): void + { + $results = $response->results; + $offset = ($results->page - 1) * $results->perPage; + $shown = []; + + foreach ($results->hits as $index => $hit) { + $position = $offset + $index + 1; + $hit->meta = [...$hit->meta, 'position' => $position, 'original_position' => $position, 'source' => 'organic']; + $shown[] = (int) ($hit->document['id'] ?? 0); + } + + $results->meta = [ + ...$results->meta, + 'search_id' => $searchId, + 'ranking_mode' => $context->mode, + 'ranking_version' => $context->version, + ]; + + $this->logger->log( + $searchId, + $context, + (string) $response->request->engine->getQuery(), + $results->count, + array_slice($shown, 0, (int) $this->config->get('lunar.search_relevance.impressions_logged', 50)), + null, + ); + } + + /** @param array $hits */ + protected function toHits(array $hits): HitCollection + { + $collection = new HitCollection; + + foreach (array_values($hits) as $index => $hit) { + $collection->push(new Hit( + productId: (int) ($hit->document['id'] ?? 0), + originalPosition: $index + 1, + score: (float) ($hit->meta['score'] ?? 0), + document: $hit->document, + )); + } + + return $collection; + } + + protected function toSearchHit(Hit $hit, ?SearchHit $engineHit, int $position): SearchHit + { + return SearchHit::from([ + 'highlights' => $engineHit?->highlights ?? [], + 'document' => $hit->document, + 'meta' => [ + ...($engineHit?->meta ?? []), + 'position' => $position, + 'original_position' => $hit->originalPosition, + 'boost' => $hit->boost, + 'source' => $hit->source, + ], + ]); + } + + /** + * Request-time features per logged impression, the raw material for a + * future learning-to-rank model. + * + * @return array> + */ + protected function features(HitCollection $ranked, int $limit): array + { + return $ranked->take($limit)->values()->map(fn (Hit $hit, int $index) => [ + 'id' => $hit->productId, + 'pos' => $index + 1, + 'orig' => $hit->originalPosition, + 'score' => $hit->score, + 'boost' => $hit->boost, + 'src' => $hit->source, + ])->all(); + } +} diff --git a/packages/search-relevance/src/Pipelines/WidenRequest.php b/packages/search-relevance/src/Pipelines/WidenRequest.php new file mode 100644 index 0000000000..4d95773f8d --- /dev/null +++ b/packages/search-relevance/src/Pipelines/WidenRequest.php @@ -0,0 +1,89 @@ +engine; + $modelType = $engine->getModelType(); + $mode = (string) $this->config->get('lunar.search_relevance.mode', 'shadow'); + + if ($mode === 'off' || ! in_array($modelType, $this->config->get('lunar.search_relevance.models', []), true)) { + return $next($request); + } + + if (! $this->logger->shouldLog()) { + return $next($request); + } + + $rawQuery = (string) $engine->getQuery(); + $normalised = $this->normaliser->normalise($rawQuery); + + // Nothing to learn from a browse listing; this also keeps the learned + // union's own fetch-by-id request out of the log. + if ($normalised === '' || $normalised === '*') { + return $next($request); + } + + $context = new RankingContext( + modelType: $modelType, + normalisedQuery: $normalised, + sessionId: $this->logger->sessionId(), + customerId: $this->logger->customerId(), + mode: $mode, + sort: $this->explicitSort($engine->getSort()), + filtersHash: md5(json_encode([$engine->getFilters(), $engine->getFacets()])), + version: $this->version->current($modelType), + ); + + $request->context['relevance'] = $context; + + $window = (int) $this->config->get('lunar.search_relevance.window', 250); + $isPartNumber = (bool) ($request->context['relevance_part_number'] ?? false); + + if ($context->shouldRank() && ! $isPartNumber && $request->requestedPage * $request->requestedPerPage <= $window) { + $request->context['relevance_widened'] = true; + $engine->page(1)->perPage($window); + } + + return $next($request); + } + + /** + * The sort a shopper chose, or null when the request is in the engine's + * relevance order, whether unsorted or sorted by one of the configured + * relevance fields. Lunar's storefront sends `relevance:asc` by default, + * and a ranker only reorders relevance-ordered results. + */ + protected function explicitSort(?string $sort): ?string + { + $field = explode(':', (string) $sort, 2)[0]; + + if ($field === '' || in_array($field, $this->config->get('lunar.search_relevance.relevance_sorts', []), true)) { + return null; + } + + return $sort; + } +} diff --git a/packages/search-relevance/src/Rankers/BucketedRanker.php b/packages/search-relevance/src/Rankers/BucketedRanker.php new file mode 100644 index 0000000000..4f3ea7042f --- /dev/null +++ b/packages/search-relevance/src/Rankers/BucketedRanker.php @@ -0,0 +1,44 @@ +bucketSize = max(1, (int) $config->get('lunar.search_relevance.bucket_size', 10)); + } + + public function rank(RankingContext $context, HitCollection $hits): HitCollection + { + if (! $context->shouldRank() || $hits->isEmpty()) { + return $hits; + } + + $boosts = $this->signals->combine($context, $hits->productIds()); + + $hits->each(fn (Hit $hit) => $hit->boost = $boosts[$hit->productId] ?? 0.0); + + $ranked = $hits + ->chunk($this->bucketSize) + ->flatMap(fn ($bucket) => $bucket->sortBy([ + fn (Hit $a, Hit $b) => $b->boost <=> $a->boost, + fn (Hit $a, Hit $b) => $a->originalPosition <=> $b->originalPosition, + ])) + ->values(); + + return new HitCollection($ranked->all()); + } +} diff --git a/packages/search-relevance/src/Rankers/NullRanker.php b/packages/search-relevance/src/Rankers/NullRanker.php new file mode 100644 index 0000000000..785f422963 --- /dev/null +++ b/packages/search-relevance/src/Rankers/NullRanker.php @@ -0,0 +1,15 @@ +driver($modelType); + + return implode(':', [ + 'n'.$this->config->get('lunar.search_relevance.normaliser_version', 1), + $driver, + $this->isHybrid($modelType, $driver) ? 'hybrid' : 'keyword', + ]); + } + + public function driver(string $modelType): string + { + return (string) ($this->config->get('lunar.search.engine_map', [])[$modelType] + ?? $this->config->get('scout.driver', 'database')); + } + + protected function isHybrid(string $modelType, string $driver): bool + { + if ($driver === 'typesense') { + $fields = $this->config->get("scout.typesense.model-settings.{$modelType}.collection-schema.fields", []); + + return collect($fields)->contains(fn ($field) => ($field['name'] ?? null) === 'embedding'); + } + + if ($driver === 'meilisearch') { + return (bool) $this->config->get('lunar.search.meilisearch.embedder'); + } + + return false; + } +} diff --git a/packages/search-relevance/src/Scoring/MySqlScoreAggregator.php b/packages/search-relevance/src/Scoring/MySqlScoreAggregator.php new file mode 100644 index 0000000000..c6aa9f4432 --- /dev/null +++ b/packages/search-relevance/src/Scoring/MySqlScoreAggregator.php @@ -0,0 +1,21 @@ +connection(); + $prefix = $this->config->get('lunar.database.table_prefix'); + + $weights = $config['weights'] ?? []; + $eta = (float) ($config['position_eta'] ?? 0.7); + $maxPositionWeight = (float) ($config['max_position_weight'] ?? 5); + $halfLife = (float) ($config['half_life_days'] ?? 30); + $minSessions = (int) ($config['min_sessions'] ?? 3); + $maxProducts = (int) ($config['max_products_per_query'] ?? 50); + $windowStart = $start->copy()->subDays((int) ($config['window_days'] ?? 180)); + $busy = $this->busySessions($connection, $prefix, $windowStart, (int) ($config['max_searches_per_minute'] ?? PHP_INT_MAX)); + $trustedOnly = (bool) ($config['trusted_sessions_only'] ?? false); + [$excluded, $resets] = $this->overrides($connection, $prefix); + + /** @var array}> $groups */ + $groups = []; + /** @var array $seen one event per query, product, session and type */ + $seen = []; + + $connection->table($prefix.'search_events as e') + ->join($prefix.'search_queries as q', 'q.id', '=', 'e.search_id') + ->where('e.created_at', '>', $windowStart) + ->where('q.version', $version) + ->select(['e.id', 'q.model_type', 'q.normalised_query', 'q.customer_id', 'e.product_id', 'e.type', 'e.source', 'e.position', 'e.session_id', 'e.created_at']) + ->orderBy('e.id') + ->chunk(1000, function ($events) use (&$groups, &$seen, $busy, $trustedOnly, $excluded, $resets, $weights, $eta, $maxPositionWeight, $halfLife, $start) { + foreach ($events as $event) { + if (isset($busy[$event->session_id])) { + continue; + } + + if ($trustedOnly && ! str_starts_with($event->session_id, 'cart:') && $event->customer_id === null) { + continue; + } + + $queryKey = $event->model_type.'|'.$event->normalised_query; + + if (isset($excluded[$queryKey][(int) $event->product_id])) { + continue; + } + + if (isset($resets[$queryKey]) && Carbon::parse($event->created_at)->lte($resets[$queryKey])) { + continue; + } + + $seenKey = $queryKey.'|'.$event->product_id.'|'.$event->session_id.'|'.$event->type; + + if (isset($seen[$seenKey])) { + continue; + } + $seen[$seenKey] = true; + + $weight = (float) ($weights[$event->type] ?? 0); + $positionWeight = $event->source === 'explore' ? 1.0 : min(pow((int) $event->position, $eta), $maxPositionWeight); + $ageDays = max(0, $start->getTimestamp() - Carbon::parse($event->created_at)->getTimestamp()) / 86400; + $decay = pow(0.5, $ageDays / $halfLife); + + $key = $event->model_type.'|'.$event->normalised_query.'|'.$event->product_id; + $groups[$key] ??= [ + 'model_type' => $event->model_type, + 'query' => $event->normalised_query, + 'product_id' => (int) $event->product_id, + 'score' => 0.0, + 'sessions' => [], + ]; + $groups[$key]['score'] += $weight * $positionWeight * $decay; + $groups[$key]['sessions'][$event->session_id] = true; + } + }); + + $byQuery = []; + + foreach ($groups as $group) { + if (count($group['sessions']) < $minSessions) { + continue; + } + + $byQuery[$group['model_type'].'|'.$group['query']][] = $group; + } + + $rows = []; + + foreach ($byQuery as $products) { + usort($products, fn ($a, $b) => [$b['score'], $a['product_id']] <=> [$a['score'], $b['product_id']]); + $max = $products[0]['score'] ?: 1.0; + + foreach (array_slice($products, 0, $maxProducts) as $product) { + $rows[] = [ + 'model_type' => $product['model_type'], + 'normalised_query' => $product['query'], + 'product_id' => $product['product_id'], + 'score' => $product['score'], + 'relative' => $product['score'] / $max, + 'sessions' => count($product['sessions']), + 'version' => $version, + 'updated_at' => $start, + ]; + } + } + + $scores = $connection->table($prefix.'search_query_scores'); + + foreach (array_chunk($rows, 500) as $chunk) { + $scores->upsert($chunk, ['model_type', 'normalised_query', 'product_id'], ['score', 'relative', 'sessions', 'version', 'updated_at']); + } + + $keep = array_values(array_unique([...($config['keep_versions'] ?? []), $version])); + (clone $scores)->where('version', $version)->where('updated_at', '<', $start)->delete(); + (clone $scores)->whereNotIn('version', $keep)->delete(); + + $this->affinity->forget(); + + return count($rows); + } + + /** + * Sessions that searched more than the guard allows in any single minute. + * + * @return array + */ + protected function busySessions(Connection $connection, string $prefix, Carbon $windowStart, int $max): array + { + $counts = []; + + $connection->table($prefix.'search_queries') + ->where('created_at', '>', $windowStart) + ->select(['session_id', 'created_at']) + ->orderBy('id') + ->chunk(1000, function ($searches) use (&$counts) { + foreach ($searches as $search) { + $minute = Carbon::parse($search->created_at)->format('Y-m-d H:i'); + $counts[$search->session_id][$minute] = ($counts[$search->session_id][$minute] ?? 0) + 1; + } + }); + + $busy = []; + + foreach ($counts as $sessionId => $minutes) { + if (max($minutes) > $max) { + $busy[$sessionId] = true; + } + } + + return $busy; + } + + /** + * Excluded products per query, and the latest reset per query. + * + * @return array{0: array>, 1: array} + */ + protected function overrides(Connection $connection, string $prefix): array + { + $excluded = []; + $resets = []; + + foreach ($connection->table($prefix.'search_learning_overrides')->get() as $override) { + $key = $override->model_type.'|'.$override->normalised_query; + + if ($override->type === 'exclude') { + $excluded[$key][(int) $override->product_id] = true; + } elseif ($override->type === 'reset') { + $at = Carbon::parse($override->created_at); + $resets[$key] = isset($resets[$key]) && $resets[$key]->gt($at) ? $resets[$key] : $at; + } + } + + return [$excluded, $resets]; + } + + protected function connection(): Connection + { + return $this->db->connection($this->config->get('lunar.database.connection')); + } +} diff --git a/packages/search-relevance/src/Scoring/PostgresScoreAggregator.php b/packages/search-relevance/src/Scoring/PostgresScoreAggregator.php new file mode 100644 index 0000000000..4a516cf510 --- /dev/null +++ b/packages/search-relevance/src/Scoring/PostgresScoreAggregator.php @@ -0,0 +1,21 @@ +where('created_at', '>=', $since) + ->whereNotNull('ranked') + ->when($modelType, fn ($query) => $query->where('model_type', $modelType)) + ->whereHas('events', fn ($query) => $query->where('type', 'purchase')) + ->with(['events' => fn ($query) => $query->where('type', 'purchase')]) + ->orderBy('id') + ->chunk(500, function ($chunk) use (&$searches, &$shownSum, &$rankedSum, &$improved, &$worsened) { + foreach ($chunk as $search) { + $shown = array_map('intval', $search->shown ?? []); + $ranked = array_map('intval', $search->ranked ?? []); + + foreach ($search->events->pluck('product_id')->unique() as $productId) { + $shownPosition = array_search((int) $productId, $shown, true); + $rankedPosition = array_search((int) $productId, $ranked, true); + + if ($shownPosition === false && $rankedPosition === false) { + continue; + } + + $shownRr = $shownPosition === false ? 0.0 : 1 / ($shownPosition + 1); + $rankedRr = $rankedPosition === false ? 0.0 : 1 / ($rankedPosition + 1); + + $searches++; + $shownSum += $shownRr; + $rankedSum += $rankedRr; + + if ($rankedRr > $shownRr) { + $improved++; + } elseif ($rankedRr < $shownRr) { + $worsened++; + } + } + } + }); + + return new ReplayResult( + searches: $searches, + mrrShown: $searches ? $shownSum / $searches : 0.0, + mrrRanked: $searches ? $rankedSum / $searches : 0.0, + improvedShare: $searches ? $improved / $searches : 0.0, + worsenedShare: $searches ? $worsened / $searches : 0.0, + ); + } +} diff --git a/packages/search-relevance/src/Scoring/ReplayResult.php b/packages/search-relevance/src/Scoring/ReplayResult.php new file mode 100644 index 0000000000..09b001016c --- /dev/null +++ b/packages/search-relevance/src/Scoring/ReplayResult.php @@ -0,0 +1,14 @@ +connection(); + $prefix = $this->config->get('lunar.database.table_prefix'); + $events = $prefix.'search_events'; + $queries = $prefix.'search_queries'; + $scores = $prefix.'search_query_scores'; + $overrides = $prefix.'search_learning_overrides'; + $trustedOnly = (bool) ($config['trusted_sessions_only'] ?? false); + $trust = $trustedOnly ? "AND (q.session_id LIKE 'cart:%' OR q.customer_id IS NOT NULL)" : ''; + + $weights = $config['weights'] ?? []; + $windowStart = $start->copy()->subDays((int) ($config['window_days'] ?? 180)); + $f = $this->floatPlaceholder(); + $age = $this->ageSecondsExpression(); + $minute = $this->minuteBucketExpression(); + + $sql = << ? + GROUP BY session_id, {$minute} + HAVING COUNT(*) > ? + ), + resets AS ( + SELECT model_type, normalised_query, MAX(created_at) AS reset_at + FROM {$overrides} + WHERE type = 'reset' + GROUP BY model_type, normalised_query + ), + deduped AS ( + SELECT q.model_type, q.normalised_query, e.product_id, e.session_id, e.type, + MIN(e.source) AS source, MIN(e.position) AS position, MAX(e.created_at) AS created_at + FROM {$events} e + JOIN {$queries} q ON q.id = e.search_id + LEFT JOIN resets r ON r.model_type = q.model_type AND r.normalised_query = q.normalised_query + WHERE e.created_at > ? + AND (r.reset_at IS NULL OR e.created_at > r.reset_at) + AND q.version = ? + AND e.session_id NOT IN (SELECT session_id FROM busy) + {$trust} + AND NOT EXISTS ( + SELECT 1 FROM {$overrides} x + WHERE x.type = 'exclude' AND x.model_type = q.model_type + AND x.normalised_query = q.normalised_query AND x.product_id = e.product_id + ) + GROUP BY q.model_type, q.normalised_query, e.product_id, e.session_id, e.type + ), + raw AS ( + SELECT e.model_type, e.normalised_query, e.product_id, + SUM( + (CASE e.type WHEN 'click' THEN {$f} WHEN 'basket' THEN {$f} WHEN 'purchase' THEN {$f} ELSE 0 END) + * (CASE WHEN e.source = 'explore' THEN 1 ELSE LEAST(POWER(e.position, {$f}), {$f}) END) + * POWER(0.5, ({$age}) / 86400.0 / {$f}) + ) AS score, + COUNT(DISTINCT e.session_id) AS sessions + FROM deduped e + GROUP BY e.model_type, e.normalised_query, e.product_id + HAVING COUNT(DISTINCT e.session_id) >= ? + ), + ranked AS ( + SELECT model_type, normalised_query, product_id, score, sessions, + score / MAX(score) OVER (PARTITION BY model_type, normalised_query) AS relative, + ROW_NUMBER() OVER (PARTITION BY model_type, normalised_query ORDER BY score DESC, product_id ASC) AS rn + FROM raw + ) + SELECT model_type, normalised_query, product_id, score, relative, sessions + FROM ranked + WHERE rn <= ? + SQL; + + $rows = $connection->select($sql, [ + $windowStart, + (int) ($config['max_searches_per_minute'] ?? PHP_INT_MAX), + $windowStart, + $version, + (float) ($weights['click'] ?? 1), + (float) ($weights['basket'] ?? 3), + (float) ($weights['purchase'] ?? 5), + (float) ($config['position_eta'] ?? 0.7), + (float) ($config['max_position_weight'] ?? 5), + $start->format('Y-m-d H:i:s'), + (float) ($config['half_life_days'] ?? 30), + (int) ($config['min_sessions'] ?? 3), + (int) ($config['max_products_per_query'] ?? 50), + ]); + + $written = 0; + + foreach (array_chunk($rows, 500) as $chunk) { + $values = array_map(fn ($row) => [ + 'model_type' => $row->model_type, + 'normalised_query' => $row->normalised_query, + 'product_id' => (int) $row->product_id, + 'score' => (float) $row->score, + 'relative' => (float) $row->relative, + 'sessions' => (int) $row->sessions, + 'version' => $version, + 'updated_at' => $start, + ], $chunk); + + $connection->table($scores)->upsert( + $values, + ['model_type', 'normalised_query', 'product_id'], + ['score', 'relative', 'sessions', 'version', 'updated_at'], + ); + + $written += count($values); + } + + $this->sweep($connection->table($scores), $version, $config, $start); + $this->affinity->forget(); + + return $written; + } + + /** Drop rows this run did not refresh, and rows of any version no longer in use. */ + protected function sweep(Builder $scores, string $version, array $config, DateTimeInterface $start): void + { + $keep = array_values(array_unique([...($config['keep_versions'] ?? []), $version])); + + (clone $scores)->where('version', $version)->where('updated_at', '<', $start)->delete(); + (clone $scores)->whereNotIn('version', $keep)->delete(); + } + + protected function connection(): Connection + { + return $this->db->connection($this->config->get('lunar.database.connection')); + } +} diff --git a/packages/search-relevance/src/SearchRelevanceServiceProvider.php b/packages/search-relevance/src/SearchRelevanceServiceProvider.php new file mode 100644 index 0000000000..06c68d42f2 --- /dev/null +++ b/packages/search-relevance/src/SearchRelevanceServiceProvider.php @@ -0,0 +1,180 @@ +mergeConfigFrom("{$this->root}/config/search-relevance.php", 'lunar.search_relevance'); + + $this->app->bind(QueryNormaliser::class, fn ($app) => $app->make(config('lunar.search_relevance.normaliser'))); + + $this->app->bind(SignalCombiner::class, fn ($app) => new SignalCombiner( + collect(config('lunar.search_relevance.signals', [])) + ->mapWithKeys(fn (float $weight, string $class) => [$class => $weight]) + ->all(), + $app, + )); + + $this->app->bind(Ranker::class, fn ($app) => $app->make(config('lunar.search_relevance.ranker'))); + + $this->app->bind(ScoreAggregator::class, function ($app) { + return match ($app['db']->connection(config('lunar.database.connection'))->getDriverName()) { + 'mysql', 'mariadb' => $app->make(MySqlScoreAggregator::class), + 'pgsql' => $app->make(PostgresScoreAggregator::class), + default => $app->make(PhpScoreAggregator::class), + }; + }); + } + + public function boot(): void + { + $this->publishes([ + "{$this->root}/config/search-relevance.php" => config_path('lunar/search-relevance.php'), + ], 'lunar.search-relevance.config'); + + if (! config('lunar.database.disable_migrations', false)) { + $this->loadMigrationsFrom("{$this->root}/database/migrations"); + } + + $this->loadTranslationsFrom("{$this->root}/resources/lang", 'search-relevance'); + $this->loadRoutesFrom("{$this->root}/routes/storefront.php"); + + Blade::anonymousComponentPath("{$this->root}/resources/views/components", 'lunar-search-relevance'); + + ModelManifest::addDirectory(__DIR__.'/Models'); + + $this->registerRateLimiting(); + $this->registerPipelines(); + $this->registerListeners(); + $this->registerConsole(); + $this->registerPanel(); + } + + /** + * Append the package stages to the search pipelines. Hosts that set the + * pipelines explicitly in their own config keep full control of the order. + */ + protected function registerPipelines(): void + { + $request = config('lunar.search.pipelines.request', []); + $results = config('lunar.search.pipelines.results', []); + + foreach ([PartNumberRetrieval::class, WidenRequest::class] as $stage) { + if (! in_array($stage, $request, true)) { + $request[] = $stage; + } + } + + if (! in_array(RankResults::class, $results, true)) { + $results[] = RankResults::class; + } + + // The fallback must run before RankResults, or the empty part-number + // search is logged before its rerun is. + if (! in_array(PartNumberFallback::class, $results, true)) { + array_splice($results, (int) array_search(RankResults::class, $results, true), 0, [PartNumberFallback::class]); + } + + config([ + 'lunar.search.pipelines.request' => $request, + 'lunar.search.pipelines.results' => $results, + ]); + } + + /** Events endpoint limit per shopper and per IP, whichever trips first. */ + protected function registerRateLimiting(): void + { + $this->app->make(RateLimiter::class)->for('lunar-search-relevance-events', function (Request $request) { + [$attempts, $minutes] = array_pad(explode(',', (string) config('lunar.search_relevance.guards.events_rate_limit', '60,1')), 2, 1); + $shopper = $request->input('session_id') ?: $this->app->make(SearchLogger::class)->sessionId(); + + return [ + Limit::perMinutes((int) $minutes, (int) $attempts)->by('shopper:'.$shopper), + Limit::perMinutes((int) $minutes, (int) $attempts)->by('ip:'.$request->ip()), + ]; + }); + } + + protected function registerListeners(): void + { + CartLine::observe(CartLineObserver::class); + Event::listen(OrderPlaced::class, AttributeOrderLines::class); + Event::listen([OrderCancelled::class, OrderRefunded::class], ForgetPurchases::class); + } + + protected function registerConsole(): void + { + if (! $this->app->runningInConsole()) { + return; + } + + $this->commands([ + ScoreCommand::class, + ReplayCommand::class, + PruneCommand::class, + ]); + + $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { + $schedule->command('lunar:search-relevance:score') + ->dailyAt(config('lunar.search_relevance.scoring.schedule', '02:00')) + ->withoutOverlapping(); + + $schedule->command('lunar:search-relevance:prune')->weekly(); + }); + } + + /** The panel section only exists when lunarphp/panel is installed and booted. */ + protected function registerPanel(): void + { + if (! class_exists(PanelManager::class) || ! $this->app->bound(PanelManager::class)) { + return; + } + + Panel::section(new SearchRelevanceSection); + + $this->app->make(PanelManager::class)->vite('search-relevance', [ + 'input' => 'resources/js/addon.ts', + 'hotFile' => null, + 'buildDirectory' => 'vendor/lunar-panel/search-relevance', + '__buildSourcePath' => "{$this->root}/build", + ]); + } +} diff --git a/packages/search-relevance/src/Signals/QueryAffinitySignal.php b/packages/search-relevance/src/Signals/QueryAffinitySignal.php new file mode 100644 index 0000000000..031247769d --- /dev/null +++ b/packages/search-relevance/src/Signals/QueryAffinitySignal.php @@ -0,0 +1,60 @@ +learned($context), array_flip($productIds)); + } + + /** @return array every learned product for the query, product_id => relative, best first */ + public function learned(RankingContext $context): array + { + $key = implode(':', [ + 'lunar.search_relevance.scores', + $this->generation(), + $context->version, + md5($context->modelType.'|'.$context->normalisedQuery), + ]); + + return $this->cache->remember($key, (int) $this->config->get('lunar.search_relevance.cache_ttl', 300), function () use ($context) { + return $this->db->connection($this->config->get('lunar.database.connection')) + ->table($this->config->get('lunar.database.table_prefix').'search_query_scores') + ->where('model_type', $context->modelType) + ->where('version', $context->version) + ->where('normalised_query', $context->normalisedQuery) + ->orderByDesc('relative') + ->pluck('relative', 'product_id') + ->map(fn ($relative) => (float) $relative) + ->all(); + }); + } + + /** Bump the generation so every cached lookup misses without flushing the store. */ + public function forget(): void + { + $this->cache->forever(self::CACHE_GENERATION_KEY, $this->generation() + 1); + } + + protected function generation(): int + { + return (int) $this->cache->get(self::CACHE_GENERATION_KEY, 0); + } +} diff --git a/packages/search-relevance/src/Signals/SignalCombiner.php b/packages/search-relevance/src/Signals/SignalCombiner.php new file mode 100644 index 0000000000..29e2dc5b7c --- /dev/null +++ b/packages/search-relevance/src/Signals/SignalCombiner.php @@ -0,0 +1,38 @@ +, float> $weights */ + public function __construct( + protected array $weights, + protected Container $container, + ) {} + + /** + * Weighted sum of every configured signal, clamped to 0..1. + * + * @param array $productIds + * @return array + */ + public function combine(RankingContext $context, array $productIds): array + { + $combined = []; + + foreach ($this->weights as $class => $weight) { + /** @var Signal $signal */ + $signal = $this->container->make($class); + + foreach ($signal->scores($context, $productIds) as $productId => $score) { + $combined[$productId] = ($combined[$productId] ?? 0.0) + $score * $weight; + } + } + + return array_map(fn (float $score) => min(1.0, max(0.0, $score)), $combined); + } +} diff --git a/packages/search-relevance/src/Support/Attribution.php b/packages/search-relevance/src/Support/Attribution.php new file mode 100644 index 0000000000..20f16cf445 --- /dev/null +++ b/packages/search-relevance/src/Support/Attribution.php @@ -0,0 +1,56 @@ +config->get('lunar.search_relevance.attribution_ttl_minutes', 30); + + $this->session?->put(self::KEY.'.'.$productId, [ + 'search_id' => $searchId, + 'position' => $position, + 'source' => $source, + 'session_id' => $sessionId, + 'expires' => now()->addMinutes($ttl)->getTimestamp(), + ]); + } + + /** @return array{search_id: string, position: int, source: string, session_id: string, expires: int}|null */ + public function find(int $productId): ?array + { + $attribution = $this->session?->get(self::KEY.'.'.$productId); + + if (! is_array($attribution) || ! isset($attribution['search_id'])) { + return null; + } + + if (($attribution['expires'] ?? 0) < now()->getTimestamp()) { + $this->forget($productId); + + return null; + } + + return $attribution; + } + + public function forget(int $productId): void + { + $this->session?->forget(self::KEY.'.'.$productId); + } +} diff --git a/packages/search-relevance/src/helpers.php b/packages/search-relevance/src/helpers.php new file mode 100644 index 0000000000..b275bedfce --- /dev/null +++ b/packages/search-relevance/src/helpers.php @@ -0,0 +1,40 @@ +meta['search_id'] ?? null; + $productId = $hit->document['id'] ?? null; + + if (! $searchId || $productId === null) { + return new HtmlString(''); + } + + $attributes = [ + 'data-lunar-search-id' => $searchId, + 'data-lunar-product-id' => $productId, + 'data-lunar-position' => $hit->meta['position'] ?? '', + 'data-lunar-source' => $hit->meta['source'] ?? 'organic', + ]; + + return new HtmlString(collect($attributes) + ->map(fn ($value, $name) => $name.'="'.e((string) $value).'"') + ->join(' ')); + } +} + +if (! function_exists('lunar_search_tracking_script')) { + /** The shared storefront client's IIFE build, inlined by the Blade tracking component. */ + function lunar_search_tracking_script(): string + { + return (string) file_get_contents(dirname(__DIR__).'/resources/client/dist/tracking.iife.js'); + } +} diff --git a/packages/search-relevance/vite.config.js b/packages/search-relevance/vite.config.js new file mode 100644 index 0000000000..f8654266aa --- /dev/null +++ b/packages/search-relevance/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import lunarPanelPlugin from '@lunarphp/panel-vite-plugin'; + +// Compiles resources/js/addon.ts to one IIFE bundle sharing the panel's Vue instance. +export default defineConfig({ + plugins: [ + vue(), + lunarPanelPlugin({ name: 'LunarSearchRelevanceAddon' }), + ], + build: { + outDir: 'build', + rollupOptions: { + input: 'resources/js/addon.ts', + }, + }, +}); diff --git a/packages/search/config/search.php b/packages/search/config/search.php index 8ff5594c9d..caf65c37c9 100644 --- a/packages/search/config/search.php +++ b/packages/search/config/search.php @@ -3,6 +3,44 @@ use Lunar\Core\Models\Product; return [ + /* + |-------------------------------------------------------------------------- + | Pipelines + |-------------------------------------------------------------------------- + | + | `request` stages run before the engine queries and receive a + | Lunar\Search\Pipelines\SearchRequest; they may change page, per page, + | sort, filters or engine parameters. `results` stages run after + | SearchResults is built and receive a Lunar\Search\Pipelines\SearchResponse; + | they may reorder, annotate or replace hits. Stages run top to bottom. + | + */ + 'pipelines' => [ + 'request' => [], + 'results' => [], + ], + + 'typesense' => [ + /* + * Maximum vector distance for hybrid (semantic) matches. Without a + * threshold the k: 200 vector query pads every result set with the + * nearest neighbours of tokens that mean nothing. 0 disables it. + */ + 'vector_distance_threshold' => 0.6, + ], + + 'meilisearch' => [ + /* + * Name of a configured embedder to enable hybrid search, or null for + * keyword-only retrieval. `ranking_score_threshold` (0..1) drops weak + * matches when hybrid search is on; it is the counterpart of the + * Typesense distance threshold. + */ + 'embedder' => null, + 'semantic_ratio' => 0.5, + 'ranking_score_threshold' => null, + ], + 'facets' => [ Product::class => [ 'brand' => [], diff --git a/packages/search/src/Data/SearchHit.php b/packages/search/src/Data/SearchHit.php index 36cb501653..610befefe2 100644 --- a/packages/search/src/Data/SearchHit.php +++ b/packages/search/src/Data/SearchHit.php @@ -4,6 +4,7 @@ use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Data; +use Spatie\TypeScriptTransformer\Attributes\LiteralTypeScriptType; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript] @@ -13,5 +14,8 @@ public function __construct( #[DataCollectionOf(SearchHitHighlight::class)] public array $highlights, public array $document, + /** Engine score under `score` where available; pipeline stages add their own keys. */ + #[LiteralTypeScriptType("{ score?: number; position?: number; original_position?: number; boost?: number | null; source?: 'organic' | 'learned' | 'explore'; [key: string]: unknown }")] + public array $meta = [], ) {} } diff --git a/packages/search/src/Data/SearchResults.php b/packages/search/src/Data/SearchResults.php index 8cc08f4db3..af8091ff6b 100644 --- a/packages/search/src/Data/SearchResults.php +++ b/packages/search/src/Data/SearchResults.php @@ -27,6 +27,9 @@ public function __construct( public ?string $sortField = null, #[LiteralTypeScriptType("'asc' | 'desc' | null")] public ?string $sortDirection = null, + /** Annotations from results-pipeline stages, e.g. a search id. */ + #[LiteralTypeScriptType("{ search_id?: string; ranking_mode?: 'off' | 'shadow' | 'on'; ranking_version?: string; [key: string]: unknown }")] + public array $meta = [], ) {} public function toArray(): array diff --git a/packages/search/src/Engines/AbstractEngine.php b/packages/search/src/Engines/AbstractEngine.php index 5c4457c2b7..899062bff3 100644 --- a/packages/search/src/Engines/AbstractEngine.php +++ b/packages/search/src/Engines/AbstractEngine.php @@ -3,9 +3,14 @@ namespace Lunar\Search\Engines; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\Paginator; +use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; use Lunar\Core\Models\Product; use Lunar\Search\Data\Builder\SearchQuery; +use Lunar\Search\Data\SearchResults; +use Lunar\Search\Pipelines\SearchRequest; +use Lunar\Search\Pipelines\SearchResponse; abstract class AbstractEngine { @@ -21,10 +26,27 @@ abstract class AbstractEngine protected int $perPage = 50; + /** Null until page() is called: the page then resolves from the request, as Scout does. */ + protected ?int $page = null; + + /** Extra engine request parameters, merged into the engine request last. */ + protected array $params = []; + + /** When true, neither search pipeline runs for this request. */ + protected bool $withoutPipelines = false; + + /** @var array Pipeline stages skipped for this request. */ + protected array $skippedStages = []; + protected string $sort = ''; protected string $sortRaw = ''; + public function getModelType(): string + { + return $this->modelType; + } + public function extendQuery(\Closure $callable): self { $this->queryExtenders[] = $callable; @@ -60,6 +82,73 @@ public function perPage(int $perPage): self return $this; } + public function page(int $page): static + { + $this->page = max(1, $page); + + return $this; + } + + public function getPage(): int + { + return $this->page ?? Paginator::resolveCurrentPage(); + } + + public function getPerPage(): int + { + return $this->perPage; + } + + /** + * Merge engine-specific request parameters, applied after everything the + * engine builds itself. A null value removes the parameter from the + * request. Used by request-pipeline stages to change retrieval without + * engine-specific code living in the engine. + */ + public function withParams(array $params): static + { + $this->params = [...$this->params, ...$params]; + + return $this; + } + + /** + * Remove withParams() overrides by key, so the engine sends its own value + * for them again. + */ + public function withoutParams(string ...$keys): static + { + foreach ($keys as $key) { + unset($this->params[$key]); + } + + return $this; + } + + public function getParams(): array + { + return $this->params; + } + + /** + * Run this request without the request and results pipelines, for a + * lookup that is not a shopper's search, such as autocomplete. + */ + public function withoutPipelines(): static + { + $this->withoutPipelines = true; + + return $this; + } + + /** Skip individual pipeline stages for this request. */ + public function withoutPipelineStages(string ...$stages): static + { + $this->skippedStages = array_values(array_unique([...$this->skippedStages, ...$stages])); + + return $this; + } + public function getFacets(): array { return $this->facets; @@ -124,7 +213,71 @@ public function getQuery(): ?string protected function getRawResults(\Closure $builder): LengthAwarePaginator { - return $this->modelType::search($this->query, $builder)->paginateRaw(perPage: $this->perPage); + return $this->modelType::search($this->query, $builder)->paginateRaw(perPage: $this->perPage, page: $this->page); + } + + /** + * Run the request pipeline. Engines call this at the top of get() so + * stages can adjust the request before the engine queries. + */ + protected function pipeRequest(): SearchRequest + { + $request = new SearchRequest($this, $this->getPage(), $this->perPage); + + return app(Pipeline::class) + ->send($request) + ->through($this->pipelineStages('request')) + ->thenReturn(); + } + + /** + * Run the results pipeline. Engines wrap their return value in this so + * stages can reorder, annotate or replace the built results. + */ + protected function pipeResults(SearchRequest $request, SearchResults $results): SearchResults + { + $response = app(Pipeline::class) + ->send(new SearchResponse($request, $results)) + ->through($this->pipelineStages('results')) + ->thenReturn(); + + return $response->results; + } + + /** + * The configured stages for one pipeline, less any this request skips. + * + * @return array + */ + protected function pipelineStages(string $pipeline): array + { + if ($this->withoutPipelines) { + return []; + } + + return array_values(array_filter( + config("lunar.search.pipelines.{$pipeline}", []), + fn (mixed $stage) => ! is_string($stage) || ! in_array($stage, $this->skippedStages, true), + )); + } + + /** + * Apply withParams() overrides to a built request array. Null removes + * the key so a stage can drop a parameter the engine would otherwise send. + */ + protected function applyParamOverrides(array $params): array + { + foreach ($this->params as $key => $value) { + if ($value === null) { + unset($params[$key]); + + continue; + } + + $params[$key] = $value; + } + + return $params; } protected function getFacetConfig(?string $field = null): ?array diff --git a/packages/search/src/Engines/DatabaseEngine.php b/packages/search/src/Engines/DatabaseEngine.php index 400e969f4f..a405c75852 100644 --- a/packages/search/src/Engines/DatabaseEngine.php +++ b/packages/search/src/Engines/DatabaseEngine.php @@ -9,20 +9,22 @@ class DatabaseEngine extends AbstractEngine { public function get(): mixed { + $request = $this->pipeRequest(); + // Scout's builder, not the admin package's get_search_builder() helper — // this package must work without lunar/admin installed. Eager-load the // relations the indexer touches so mapping hits below doesn't lazy-load // per row. $results = $this->modelType::search($this->query) ->query(fn ($query) => (new $this->modelType)->indexer()->makeAllSearchableUsing($query)) - ->paginate($this->perPage); + ->paginate($this->perPage, 'page', $this->page); $documents = collect($results->items())->map(fn ($hit) => SearchHit::from([ 'highlights' => collect(), 'document' => $hit->toSearchableArray(), ])); - return SearchResults::from([ + return $this->pipeResults($request, SearchResults::from([ 'query' => $this->query, 'totalPages' => $results->lastPage(), 'page' => $results->currentPage(), @@ -31,7 +33,7 @@ public function get(): mixed 'hits' => $documents, 'facets' => collect(), 'links' => $results->links(), - ]); + ])); } protected function getFieldConfig(): array diff --git a/packages/search/src/Engines/MeilisearchEngine.php b/packages/search/src/Engines/MeilisearchEngine.php index 4218916808..0cce3e45b9 100644 --- a/packages/search/src/Engines/MeilisearchEngine.php +++ b/packages/search/src/Engines/MeilisearchEngine.php @@ -8,6 +8,7 @@ use Lunar\Search\Data\SearchFacetValue; use Lunar\Search\Data\SearchHit; use Lunar\Search\Data\SearchResults; +use Meilisearch\Contracts\HybridSearchOptions; use Meilisearch\Contracts\SearchQuery; use Meilisearch\Endpoints\Indexes; @@ -15,6 +16,8 @@ class MeilisearchEngine extends AbstractEngine { public function get(): SearchResults { + $request = $this->pipeRequest(); + $paginator = $this->getRawResults(function (Indexes $indexes, string $query, array $options) { $engine = app(EngineManager::class)->engine('meilisearch'); @@ -48,7 +51,7 @@ public function get(): SearchResults [$sortField, $sortDirection] = $this->getSortParts(); - return SearchResults::from([ + return $this->pipeResults($request, SearchResults::from([ 'query' => $results['query'], 'totalPages' => $paginator->lastPage(), 'page' => $paginator->currentPage(), @@ -56,17 +59,23 @@ public function get(): SearchResults 'perPage' => $paginator->perPage(), 'sortField' => $sortField, 'sortDirection' => $sortDirection, - 'hits' => collect($results['hits'])->map(fn ($hit) => SearchHit::from([ - 'highlights' => collect(), - 'document' => $hit, - ])), + 'hits' => collect($results['hits'])->map(function ($hit) { + $score = $hit['_rankingScore'] ?? null; + unset($hit['_rankingScore']); + + return SearchHit::from([ + 'highlights' => collect(), + 'document' => $hit, + 'meta' => $score === null ? [] : ['score' => (float) $score], + ]); + }), 'facets' => $this->mapFacets($results), 'links' => (clone $paginator)->setCollection( collect($results['hits']) )->appends([ 'facets' => $this->facets, ])->links(), - ]); + ])); } protected function buildSearch(array $options, Indexes $indexes): array @@ -105,12 +114,68 @@ protected function buildSearch(array $options, Indexes $indexes): array } $msQuery->setFilter($filters->toArray()); + $msQuery->setShowRankingScore(true); + + $this->applyHybrid($msQuery, $searchQuery->query); + $this->applyParams($msQuery); + $requests[] = $msQuery; } return $requests; } + /** + * Hybrid retrieval when an embedder is configured. Only applies alongside + * a search term; browse mode has nothing to embed. + */ + protected function applyHybrid(SearchQuery $msQuery, string $query): void + { + $embedder = config('lunar.search.meilisearch.embedder'); + + if (! $embedder || $query === '') { + return; + } + + $msQuery->setHybrid( + (new HybridSearchOptions) + ->setEmbedder($embedder) + ->setSemanticRatio((float) config('lunar.search.meilisearch.semantic_ratio', 0.5)) + ); + + if ($threshold = config('lunar.search.meilisearch.ranking_score_threshold')) { + $msQuery->setRankingScoreThreshold((float) $threshold); + } + } + + /** + * Map withParams() overrides onto the query object. Keys are Meilisearch + * request parameter names (`attributesToSearchOn`, `matchingStrategy`, + * `rankingScoreThreshold`, `hybrid`, ...) and resolve to the matching + * setter. `hybrid` accepts an array with `embedder` and `semanticRatio`; + * null removes the hybrid options. + */ + protected function applyParams(SearchQuery $msQuery): void + { + foreach ($this->getParams() as $key => $value) { + if ($key === 'hybrid') { + $msQuery->setHybrid( + (new HybridSearchOptions) + ->setEmbedder($value['embedder'] ?? config('lunar.search.meilisearch.embedder', '')) + ->setSemanticRatio((float) ($value['semanticRatio'] ?? 0)) + ); + + continue; + } + + $setter = 'set'.ucfirst($key); + + if (method_exists($msQuery, $setter)) { + $msQuery->{$setter}($value); + } + } + } + public function mapFacets(array $results): Collection { $facets = collect($results['facetDistribution'] ?? [])->map( diff --git a/packages/search/src/Engines/TypesenseEngine.php b/packages/search/src/Engines/TypesenseEngine.php index 6a246f4bd9..7bfff89d73 100644 --- a/packages/search/src/Engines/TypesenseEngine.php +++ b/packages/search/src/Engines/TypesenseEngine.php @@ -34,6 +34,8 @@ public function maxFacetValues(int $count): self public function get(): SearchResults { + $request = $this->pipeRequest(); + try { $paginator = $this->getRawResults(function (Documents $documents, string $query, array $options) { $engine = app(EngineManager::class)->engine('typesense'); @@ -121,6 +123,7 @@ public function get(): SearchResults ])]; }), 'document' => $hit['document'], + 'meta' => isset($hit['text_match']) ? ['score' => (float) $hit['text_match']] : [], ])); // The raw facet_counts are keyed by field name (the multi-search merge @@ -162,7 +165,7 @@ public function get(): SearchResults [$sortField, $sortDirection] = $this->getSortParts(); - return SearchResults::from([ + return $this->pipeResults($request, SearchResults::from([ 'query' => $this->query, 'totalPages' => $paginator->lastPage(), 'page' => $paginator->currentPage(), @@ -177,7 +180,7 @@ public function get(): SearchResults )->appends([ 'facets' => http_build_query($this->facets), ])->links(), - ]); + ])); } protected function buildSearch(array $options): array @@ -294,7 +297,7 @@ protected function buildSearch(array $options): array // pin k/alpha with a `vector_query` search parameter; it only // applies alongside a search term, so browse mode drops it. if ($this->query && $this->schemaHasEmbeddingField()) { - $params['vector_query'] ??= 'embedding:([], k: 200)'; + $params['vector_query'] ??= $this->defaultVectorQuery(); } else { unset($params['vector_query']); } @@ -303,7 +306,7 @@ protected function buildSearch(array $options): array $params['filter_by'] = $filters->join(' && '); } - $requests[] = $params; + $requests[] = $this->applyParamOverrides($params); } return $requests; @@ -336,6 +339,20 @@ protected function stripListEntry(mixed $list, int $index): mixed return implode(',', $values); } + /** + * The hybrid vector query with the configured distance threshold, so a + * token with no meaning cannot pad the result set with its nearest + * neighbours. A threshold of 0 sends the bare k: 200 query. + */ + protected function defaultVectorQuery(): string + { + $threshold = (float) config('lunar.search.typesense.vector_distance_threshold', 0); + + return $threshold > 0 + ? "embedding:([], k: 200, distance_threshold: {$threshold})" + : 'embedding:([], k: 200)'; + } + protected function schemaHasEmbeddingField(): bool { return collect($this->getFieldConfig()) diff --git a/packages/search/src/Pipelines/SearchRequest.php b/packages/search/src/Pipelines/SearchRequest.php new file mode 100644 index 0000000000..88d864ee4d --- /dev/null +++ b/packages/search/src/Pipelines/SearchRequest.php @@ -0,0 +1,22 @@ + $context */ + public function __construct( + public AbstractEngine $engine, + public int $requestedPage, + public int $requestedPerPage, + public array $context = [], + ) {} +} diff --git a/packages/search/src/Pipelines/SearchResponse.php b/packages/search/src/Pipelines/SearchResponse.php new file mode 100644 index 0000000000..5de5fc4381 --- /dev/null +++ b/packages/search/src/Pipelines/SearchResponse.php @@ -0,0 +1,17 @@ +tests/search/Feature tests/search/Unit + + tests/search-relevance/Feature + tests/search-relevance/Unit + tests/stripe/Unit diff --git a/scripts/check-npm-drift.mjs b/scripts/check-npm-drift.mjs index a26d7a47a5..267737bbe1 100644 --- a/scripts/check-npm-drift.mjs +++ b/scripts/check-npm-drift.mjs @@ -29,6 +29,13 @@ const PACKAGES = { '@lunarphp/panel-vite-plugin': { dir: 'packages/panel/resources/package', }, + '@lunarphp/search-relevance': { + dir: 'packages/search-relevance/resources/client', + // dist is tracked (the Blade component inlines the IIFE build), but + // refuse to compare against a stale one. + requires: 'packages/search-relevance/resources/client/dist/index.d.ts', + hint: 'run `npm run build --workspace @lunarphp/search-relevance` first', + }, }; const npm = (...args) => execFileSync('npm', args, { encoding: 'utf8' }).trim(); diff --git a/specs/0084-search-relevance.md b/specs/0084-search-relevance.md new file mode 100644 index 0000000000..a999cac265 --- /dev/null +++ b/specs/0084-search-relevance.md @@ -0,0 +1,527 @@ +# 0084 — Search relevance: learned ranking for Lunar search + +- Status: accepted +- Author: Glenn Jacobs +- Created: 2026-09-14 +- TODO item: search relevance layer (query-aware learned ranking, engine-agnostic) +- Target branch: `2.x` (monorepo) and `next` (docs; the v2 pages live under `2.x/` on that branch) +- Prototype: `~/Tmp/search-relevancy` (Laravel app proving the design; see its `RESULTS.md`) + +## Problem + +Lunar search returns results in the order the engine chooses. Nothing in the stack learns from what shoppers click, add to basket or buy, so a merchant's search never improves with traffic. Clients currently pay Algolia or Loop54 for that learning, at per-record and per-request prices that scale with their success. + +The prototype proved a small, engine-agnostic layer on top of Typesense gives a large part of that value: + +| Retrieval | Metric | Engine order | Learned order | +|---|---|---|---| +| Keyword only | MRR | 0.339 | 0.426 | +| Hybrid (keyword + vector) | MRR | 0.541 | 0.715 | +| Hybrid | nDCG@10 | 0.726 | 0.755 | + +Reranking cost under 1ms per search. Applying learned scores inside the engine (Typesense `_eval`) was measurably worse than reordering in Laravel, and position correction in the scoring job was necessary to stop the ranking learning position bias. This spec turns that prototype into a Lunar package. + +Concretely, today: + +- `packages/search` engines (`TypesenseEngine`, `MeilisearchEngine`, `DatabaseEngine`) build `SearchResults` inside `get()` with no hook between "engine returned hits" and "storefront renders them". +- Pagination is delegated to Scout per page (`AbstractEngine::getRawResults()` calls `paginateRaw(perPage:)`), so nothing can fetch a wider candidate window and reorder it. +- No search interaction data is captured anywhere in Lunar. + +## Proposal + +Three deliverables, in order: + +1. **Two small hooks in `lunarphp/search`**: a request pipeline and a results pipeline, plus `page()` control on `AbstractEngine`. Generic, usable by any package. +2. **A new package `lunarphp/search-relevance`** (`packages/search-relevance`, namespace `Lunar\SearchRelevance`) that logs searches and events, scores them nightly, reranks results, and ships an Inertia panel section. +3. **A docs PR** to `lunarphp/docs`. + +Engine-agnostic by construction: the ranker only needs product ids in engine order, which every engine returns. Hybrid retrieval stays an engine concern (already wired for Typesense in 2.x; Meilisearch needs an external embedder). Nothing engine-specific lives in the relevance package. + +### Part 1: hooks in `lunarphp/search` + +#### 1.1 Request and results pipelines + +Two config-driven pipelines in `packages/search/config/search.php`, mirroring `lunar.cart.pipelines` and `lunar.orders.pipelines`: + +```php +'pipelines' => [ + // Run before the engine queries. Stages may change page, perPage, sort, filters. + 'request' => [], + // Run after SearchResults is built. Stages may reorder, annotate, or replace hits. + 'results' => [], +], +``` + +Passable objects: + +```php +namespace Lunar\Search\Pipelines; + +final class SearchRequest +{ + public function __construct( + public AbstractEngine $engine, + public int $requestedPage, + public int $requestedPerPage, + public array $context = [], // free-form bag for stages to talk to each other + ) {} +} + +final class SearchResponse +{ + public function __construct( + public SearchRequest $request, + public SearchResults $results, + ) {} +} +``` + +`AbstractEngine` gains: + +```php +protected int $page = 1; + +public function page(int $page): static; +public function getPage(): int; +public function getPerPage(): int; + +/** Extra engine request parameters, merged last. Used by request-pipeline stages (see 1.4). */ +public function withParams(array $params): static; +public function getParams(): array; + +/** Runs the request pipeline. Called by engines at the top of get(). */ +protected function pipeRequest(): SearchRequest; + +/** Runs the results pipeline. Called by engines before returning from get(). */ +protected function pipeResults(SearchRequest $request, SearchResults $results): SearchResults; +``` + +`getRawResults()` passes `page: $this->page` to `paginateRaw()`. The three built-in engines call `pipeRequest()` first and wrap their return value in `pipeResults()`. Custom engines opt in by doing the same; existing custom engines keep working untouched (non-breaking). + +Pipelines run through `Illuminate\Pipeline\Pipeline`, classes resolved from the container, same as cart pipelines. + +#### 1.2 Data object additions + +- `SearchHit`: add `public array $meta = []`. Engines populate `meta['score']` where available (Typesense `text_match`, Meilisearch `_rankingScore`, which the engine now requests; Database leaves it unset). +- `SearchResults`: add `public array $meta = []`. Stages annotate (the relevance stage sets `search_id`, `ranking_mode`, `ranking_version`). + +Both additions have defaults, so existing `SearchResults::from()` calls are unaffected. + +#### 1.3 Tests + +`tests/search/` Pest tests: pipeline stages run in config order for each engine; `page()` is honoured by `getRawResults()`; a results stage can reorder hits; `meta` defaults. + +#### 1.4 Retrieval baseline fixes (Typesense and Meilisearch) + +A client reported partial part numbers returning a few family members plus unrelated products, the same ones every time. The prototype reproduced it against the 2.x engine request and measured the fix (`RESULTS.md`, "Retrieval baseline"): page one for a five-character part-number prefix is 84% unrelated under the current request and 3% after the changes below, with no change to natural-language quality. + +Changes in `packages/search`, shipped with 1.1 to 1.3: + +- The `exlude_fields` typo in `TypesenseEngine::buildSearch()` the prototype found was already fixed on `2.x` before this spec landed; nothing further to do. +- Add `distance_threshold` to the vector query, configurable as `lunar.search.typesense.vector_distance_threshold`, default `0.6`. Without it the `k: 200` vector query pads every result set, and a nonsense query returns 200 products. Meilisearch counterpart when an embedder is configured (`lunar.search.meilisearch.embedder`): `lunar.search.meilisearch.ranking_score_threshold`. +- Allow request-pipeline stages to override request parameters: `AbstractEngine::withParams(array $params)` merged into the engine request last, for both `TypesenseEngine` and `MeilisearchEngine`. A null value removes the parameter. This is how the relevance package applies part-number retrieval without engine-specific code in the engine itself. `withoutParams(string ...$keys)` removes overrides again. +- Allow a caller to opt a request out of the pipelines: `withoutPipelines()` skips both, for lookups that are not a shopper's search (autocomplete), and `withoutPipelineStages(string ...$stages)` skips named stages. +- `lunar:meilisearch:setup` (in `packages/meilisearch`) additionally applies `typoTolerance.disableOnAttributes` for the fields an indexer returns from `getExactMatchFields()` (`ProductIndexer` returns the SKU fields). Today it only sets filterable and sortable attributes. +- `ProductIndexer` already indexes variant SKUs as `skus`. Add `skus_normalised` (uppercased, separators stripped) so `HAGMB` matches `HAG-MB-32A`. In the Typesense collection schema both are `string[]` with `infix: true`, and both must appear in the host app's `scout.typesense.model-settings..search-parameters.query_by`. Both live in host config, so the docs must show the exact entries, and the reindex. + +The relevance package then ships a `PartNumberRetrieval` request-pipeline stage: when `QueryNormaliser::isPartNumber()` is true (one token mixing letters and digits, not a bare unit like `20mm`), it restricts retrieval to the fields the model's indexer returns from `getExactMatchFields()` (`ProductIndexer` returns the SKU fields; a store whose shoppers also type supplier part numbers or barcodes returns those too) and removes typo tolerance and semantic search. An indexer that returns no fields turns part-number retrieval off for that model. When the restricted search finds nothing, `PartNumberFallback` reruns it as an ordinary search, so a code the indexer does not hold as exact still finds its product. Part-number queries also skip stemming in the normaliser. The stage maps to each engine through `withParams()`: + +| | Typesense | Meilisearch | +|---|---|---| +| Restrict to exact-match fields | `query_by: skus,skus_normalised` (the indexer's fields) | `attributesToSearchOn: ['skus', 'skus_normalised']` (the indexer's fields) | +| Partial match | `prefix: true`, `infix: always` per field | Prefix on the last word is always on. No infix: `MB32A` will not match `HAG-MB-32A`. Document as a known limitation. | +| No typos | `num_typos: 0` per field | Index setting `typoTolerance.disableOnAttributes: ['skus', 'skus_normalised']`, applied by `lunar:meilisearch:setup` | +| Keep every token | `drop_tokens_threshold: 0` | `matchingStrategy: all` | +| No semantic padding | omit `vector_query` | Not applicable unless an embedder is configured; then `hybrid.semanticRatio: 0` | + +**How much of the client's problem applies to Meilisearch.** Measured with the same query sets against Meilisearch 1.24 (`RESULTS.md`, "Meilisearch"). Of the three causes found on Typesense, only typo tolerance applies. Lunar's `MeilisearchEngine` sends no vector query unless an embedder is configured, so there is no semantic padding, and Meilisearch prefix-matches the last query word by default, so recall of every SKU family was already complete. Typo tolerance is the defect: an eight-character code returns 6.8 unrelated products per page under default settings and none once typos are disabled on the SKU attributes. That is an index setting, so `lunar:meilisearch:setup` must apply it; it is not something a request stage can do. + +Two Meilisearch behaviours to document rather than fix: + +- No infix matching. A fragment from inside a code (`MB32A` for `HAG-MB-32A`) returns nothing on Meilisearch and the full family on Typesense. Clients whose customers search mid-code fragments need Typesense. +- Page padding. Meilisearch's default `last` matching strategy lists every full match first and then fills the page with documents matching fewer terms. For letter-only prefixes such as `HAG-MB`, which the classifier treats as text because they contain no digit, page one is the whole family followed by partial matches. Not a ranking error, but a storefront that shows result counts will show inflated totals. + +**Classifier rule, both engines**: a part number is one token, letters and digits mixed, hyphens, dots and slashes allowed, and not a bare unit like `20mm`. Letter-only codes are treated as text on purpose; widening the rule would send words like `cable-gland` to SKU-only retrieval. + +### Part 2: `lunarphp/search-relevance` + +#### 2.1 Package skeleton + +``` +packages/search-relevance/ + composer.json name lunarphp/search-relevance, requires lunarphp/search; lunarphp/panel is optional + config/search-relevance.php merged as lunar.search_relevance + database/migrations/ + resources/js/ panel add-on (Vue), built like packages/panel-addon-example + resources/lang/en/ + resources/views/ Blade components for storefront tracking + routes/storefront.php events endpoint + src/ + SearchRelevanceServiceProvider.php + Contracts/{QueryNormaliser,Signal,Ranker,ScoreAggregator}.php + DataObjects/{RankingContext,Hit,HitCollection}.php + Normalisers/DefaultQueryNormaliser.php + Signals/{SignalCombiner,QueryAffinitySignal}.php + Rankers/{BucketedRanker,NullRanker}.php + Pipelines/{PartNumberRetrieval,WidenRequest,RankResults}.php + Logging/SearchLogger.php + Jobs/{LogSearch,RecordEvent}.php + Support/Attribution.php + Http/Controllers/SearchEventController.php + Observers/CartLineObserver.php + Listeners/{AttributeOrderLines,ForgetPurchases}.php + Learning/Overrides.php + Scoring/{SqlScoreAggregator,MySqlScoreAggregator,PostgresScoreAggregator,PhpScoreAggregator,Replay,ReplayResult}.php + Console/{ScoreCommand,ReplayCommand,PruneCommand}.php + Models/{SearchQuery,SearchEvent,SearchQueryScore,LearningOverride}.php + Panel/{SearchRelevanceSection.php, Widgets/SearchConversionWidget.php} + RetrievalVersion.php +``` + +Ported from the prototype (`app/Relevance/*`, `app/Search/SearchService.php`, `app/Console/Commands/RelevanceScore.php`) with namespaces changed. The simulator, evaluator, holdout, LTR and native mode are not ported. + +#### 2.2 Configuration (`lunar.search_relevance`) + +```php +return [ + 'mode' => env('LUNAR_SEARCH_RELEVANCE_MODE', 'shadow'), // off | shadow | on + 'models' => [Product::class], // which searchable models are ranked + 'window' => 250, // candidate window fetched from the engine + 'bucket_size' => 10, // reorder only within buckets of this many hits + 'cache_ttl' => 300, + 'learned_union' => ['max' => 5, 'min_relative' => 0.1], + 'impressions_logged' => 50, + 'attribution_ttl_minutes' => 30, + 'session_key' => 'cart', // cart | session: what identifies a shopper + + 'normaliser' => DefaultQueryNormaliser::class, + 'normaliser_version' => 1, // bump when normalisation rules change + + 'ranker' => BucketedRanker::class, + 'signals' => [ + QueryAffinitySignal::class => 1.0, + ], + + 'scoring' => [ + 'weights' => ['click' => 1, 'basket' => 3, 'purchase' => 5], + 'position_eta' => 0.7, + 'max_position_weight' => 5, + 'half_life_days' => 30, + 'window_days' => 180, + 'min_sessions' => 3, + 'max_products_per_query' => 50, + 'schedule' => '02:00', + ], + + 'retention_days' => 400, // raw queries and events pruned after this + + 'guards' => [ + 'events_rate_limit' => '60,1', // per minute per shopper + 'max_searches_per_minute' => 30, // sessions above this are ignored by scoring + ], +]; +``` + +Modes: + +- `off`: nothing is logged or ranked. +- `shadow`: everything is logged, the reranked order is computed and stored alongside the shown order, the engine order is displayed. Default after install, so training data accrues from day one and `replay` can prove uplift before switching on. +- `on`: reranked order is displayed. + +The mode is store configuration, set in code or through `LUNAR_SEARCH_RELEVANCE_MODE` like every other Lunar setting. The panel shows the current value but cannot change it: switching a store's search behaviour is a developer decision that belongs in version control and can differ per environment. + +#### 2.3 Database + +Four tables, prefixed via `Lunar\Core\Database\Migration::$prefix` (the fourth, `search_learning_overrides`, is described in 2.10.1). All types chosen to work on MySQL 8 and Postgres. No partitioning (a `PruneCommand` replaces it). + +**`{prefix}search_queries`** + +| Column | Type | Notes | +|---|---|---| +| id | ulid PK | the `search_id` | +| model_type | string | searchable model class | +| raw_query | text | | +| normalised_query | string(255) | indexed | +| filters_hash | char(32) | md5 of filters + facets | +| session_id | string(64) | indexed | +| customer_id | bigint null | Lunar customer if known | +| version | string(32) | indexed, see RetrievalVersion | +| mode | string(8) | off, shadow, on | +| result_count | int | | +| shown | json | first N product ids in displayed order | +| ranked | json null | reranked order when it differs (shadow and on) | +| features | json null | per-impression request-time features, reserved for LTR | +| created_at | timestamp | indexed | + +**`{prefix}search_events`** + +| Column | Type | Notes | +|---|---|---| +| id | bigint PK | | +| search_id | ulid FK | | +| product_id | bigint | product level, not variant | +| position | smallint | 1-based, as displayed | +| type | string(16) | click, basket, purchase | +| source | string(16) | organic, learned, explore | +| session_id | string(64) | | +| created_at | timestamp | index (created_at, search_id) | + +**`{prefix}search_query_scores`** + +| Column | Type | Notes | +|---|---|---| +| model_type | string | | +| normalised_query | string(255) | | +| product_id | bigint | | +| score | double | | +| relative | double | 0..1 | +| sessions | int | | +| version | string(32) | | +| updated_at | timestamp | | + +Primary key `(model_type, normalised_query, product_id)`. Index on `(version, normalised_query)`. + +Keep `normalised_query` at 255 so it can be indexed on MySQL without prefix indexes. Truncate longer queries at log time. + +#### 2.4 Contracts and data objects + +```php +namespace Lunar\SearchRelevance\Contracts; + +interface QueryNormaliser +{ + public function normalise(string $query): string; + + /** One token mixing letters and digits, not a bare unit such as 20mm. Drives PartNumberRetrieval. */ + public function isPartNumber(string $query): bool; +} + +interface Signal +{ + /** @return array product_id => 0..1 */ + public function scores(RankingContext $context, array $productIds): array; +} + +interface Ranker +{ + public function rank(RankingContext $context, HitCollection $hits): HitCollection; +} + +interface ScoreAggregator +{ + /** Upserts search_query_scores for the given version and returns rows written. */ + public function aggregate(string $version, array $config): int; +} +``` + +`RankingContext`: `modelType`, `normalisedQuery`, `sessionId`, `customerId`, `mode`, `sort`, `filtersHash`, `version`. `shouldRank()` is false when the query is empty or `*`, an explicit sort is set, or mode is `off`. A sort on one of the `relevance_sorts` fields (`relevance`, `_text_match`) is the engine's own order, not an explicit sort: Lunar's storefront sends `relevance:asc` by default, and without this no storefront search would ever be ranked. + +`Hit`: `productId`, `originalPosition`, `score` (engine score or 0), `document`, `source`, mutable `boost`. Serialises to array for the cache; never cache the objects themselves (prototype hit `__PHP_Incomplete_Class` across CLI and FPM). + +`HitCollection extends Illuminate\Support\Collection` with `productIds()`. + +All four contracts are bound in the service provider from config so a host app or client package can replace any of them. + +#### 2.5 Pipeline stages + +Request pipeline order: `PartNumberRetrieval`, then `WidenRequest`. Results pipeline: `PartNumberFallback`, then `RankResults`. The package registers all four into `lunar.search.pipelines` from its service provider; hosts can reorder them in config, and a caller can skip them for one request with `withoutPipelines()` or `withoutPipelineStages()`. + +**`PartNumberRetrieval`** (request pipeline). When mode is not `off`, `QueryNormaliser::isPartNumber()` is true and the indexer declares exact-match fields, calls `withParams()` with the engine-specific parameters from the table in 1.4 and records the keys it overrode in `context`. Part-number searches are still logged, but `WidenRequest` skips ranking for them because per-code queries are too sparse to learn from. + +**`PartNumberFallback`** (results pipeline, before `RankResults`). When a part-number search returned no hits, reruns it on a clone of the engine with the overrides removed and `PartNumberRetrieval` skipped. The rerun passes through both pipelines itself, so it is widened, ranked and logged as an ordinary search; the stage then returns without calling the rest of the results pipeline so the empty search is not logged twice. + +**`WidenRequest`** (request pipeline). If ranking applies (`models` contains the engine's model, mode is not `off`, `shouldRank()`), records `requestedPage`/`requestedPerPage` in `context`, then sets `page(1)` and `perPage(window)` on the engine when `requestedPage * requestedPerPage <= window`. Beyond the window it leaves the request alone. Also builds and stores the `RankingContext` in `context`. + +**`RankResults`** (results pipeline). If `WidenRequest` widened the request: + +1. Build `HitCollection` from `results->hits`, read the cached window if present (key: model, version, mode, normalised query, filters hash, customer id). +2. Union learned products the engine did not return: fetch by ids through the engine (`filter` on id, `perPage` = count), insert at the head of the second bucket, cap by `learned_union`. +3. `Ranker::rank()`. +4. Cache the ranked window as arrays for `cache_ttl`. +5. Slice to `requestedPage`/`requestedPerPage`, rebuild `SearchResults` with correct `page`, `perPage`, `totalPages`, `links`. `count` stays the engine's total (plus any learned products unioned in), not the window size, so a search matching more than the window keeps its later pages. Facets pass through unchanged. +6. In `shadow` mode, display the original order but keep the ranked order for logging. +7. Set `results->meta`: `search_id`, `ranking_mode`, `ranking_version`. Set each `hit->meta`: `position`, `original_position`, `boost`, `source`. +8. Dispatch `LogSearch` (queued) with the query, shown order, ranked order, impressions and features. + +Beyond the window, stages pass through but the search is still logged. + +A `SearchResults` produced this way is what the storefront already consumes, so existing storefront code needs no change to keep working. It needs one change to start learning: rendering the tracking attributes. + +#### 2.6 Storefront tracking + +Shopper identity: the Lunar cart session identifier when `session_key` is `cart` (it survives login and is what basket and order lines already relate to), else the Laravel session id. + +Blade storefronts: + +```blade +{{-- once per results page, outputs the beacon script and CSRF wiring --}} + + +{{-- on each result --}} +
...
+``` + +`lunar_search_attrs()` renders `data-lunar-search-id`, `data-lunar-product-id`, `data-lunar-position`, `data-lunar-source`. The script sends `navigator.sendBeacon()` to `POST /lunar/search/events` on click of any element inside a tracked node that navigates. + +Headless and Inertia storefronts: the same endpoint accepts JSON. `SearchResults->meta['search_id']` and `hit->meta` are in the API payload. + +#### 2.6.1 Storefront client (`@lunarphp/search-relevance`) + +The Blade component must not be the only packaged implementation, because the Lunar storefront starter kit is Inertia and Vue. One client, published to npm from `packages/search-relevance/resources/client`, serves both: + +- Framework-agnostic entry `@lunarphp/search-relevance`: `sendSearchEvent(payload, options)` (`navigator.sendBeacon` with a keepalive `fetch` fallback, CSRF token read from the page or passed in, optional `session_id`), `eventFor()` / `trackHit()` built from the results and hit `meta`, `trackingAttributes()` for the `data-lunar-*` markup, and `attachSearchTracking()` for delegated click tracking. +- Vue entry `@lunarphp/search-relevance/vue`: `useSearchTracking(results, options)` returning `track(hit)`, `attrs(hit)` and `send(payload)`, plus a `v-lunar-search-hit` directive. +- An IIFE build (`dist/tracking.iife.js`, `window.LunarSearchRelevance`) that the Blade tracking component inlines, so Blade and headless storefronts run the same code. +- `SearchHit::$meta` and `SearchResults::$meta` carry `LiteralTypeScriptType` shapes so the generated `Lunar.Search` types describe `search_id`, `position`, `source` and friends. + +`dist/` is tracked because the Composer package reads the IIFE at render time. The package is added to the npm workspace, the drift check, the publish workflow and the panel JS CI job (vitest, type-check, build). + +Endpoint validation: `search_id` exists, `product_id` is in that search's `shown` list, `position` in range, rate limited per shopper. Everything else is rejected with 204 so bots learn nothing. + +#### 2.7 Basket and purchase attribution + +- A click stores `attribution.{product_id} => {search_id, position, source, expires}` in the session for `attribution_ttl_minutes`. +- `CartLineObserver` is an Eloquent `created` observer on `Lunar\Core\Models\CartLine`. It resolves the line's product (purchasable to product), looks up attribution, and if present writes `meta['search_attribution']` on the line and dispatches a `basket` event. +- `AttributeOrderLines` listens to `Lunar\Core\Events\Orders\OrderPlaced`. For every order line whose `meta` carries `search_attribution`, dispatch a `purchase` event. `Lunar\Core\Pipelines\Order\Creation\CreateOrderLines` already copies cart line `meta` to the order line (verified on 2.x), so no order pipeline step is needed. + +All writes are queued; nothing touches the request path. + +#### 2.8 Scoring + +`php artisan lunar:search-relevance:score`, scheduled daily at `scoring.schedule` via `callAfterResolving(Schedule::class)` as `LunarServiceProvider` does. + +The aggregation is one query on MySQL 8 and Postgres: + +```sql +WITH raw AS ( + SELECT q.model_type, q.normalised_query, e.product_id, + SUM(weight(e.type) + * CASE WHEN e.source = 'explore' THEN 1 ELSE LEAST(POWER(e.position, :eta), :max_pos) END + * POWER(0.5, {age_seconds} / 86400 / :half_life)) AS score, + COUNT(DISTINCT e.session_id) AS sessions + FROM search_events e JOIN search_queries q ON q.id = e.search_id + WHERE e.created_at > :window_start AND q.version = :version + GROUP BY q.model_type, q.normalised_query, e.product_id + HAVING COUNT(DISTINCT e.session_id) >= :min_sessions +), +ranked AS ( + SELECT *, score / MAX(score) OVER (PARTITION BY model_type, normalised_query) AS relative, + ROW_NUMBER() OVER (PARTITION BY model_type, normalised_query ORDER BY score DESC) AS rn + FROM raw +) +SELECT ... FROM ranked WHERE rn <= :max_products +``` + +Driver differences are limited to `{age_seconds}`: `EXTRACT(EPOCH FROM now() - e.created_at)` on Postgres, `TIMESTAMPDIFF(SECOND, e.created_at, NOW())` on MySQL. `weight()` is a `CASE` with bound values. `:window_start` is computed in PHP so `make_interval` is not needed. Rows are written with the query builder's `upsert()`, which works on both drivers. Afterwards delete rows for the current version with `updated_at` older than the job start, and all rows with other versions. + +`PhpScoreAggregator` does the same in chunked PHP for SQLite so the package test suite can run locally without a server database; CI runs the SQL aggregators on the existing mysql/pgsql matrix. + +Guards applied inside the aggregation: sessions exceeding `max_searches_per_minute` are excluded. + +`RetrievalVersion::current()` = `n{normaliser_version}:{scout driver}:{hybrid?}`. `QueryAffinitySignal` reads only rows with the current version, so a normaliser or engine change relearns cleanly instead of applying stale scores (the prototype measured stale scores making results worse). + +`lunar:search-relevance:prune` deletes queries and events older than `retention_days`. Scheduled weekly. + +#### 2.9 Replay (the proof) + +`php artisan lunar:search-relevance:replay --days=30` + +For every logged search in the period that led to a purchase, compare the purchased product's position in `shown` versus `ranked`. Print mean reciprocal rank for both, the number of searches, and the share where the reranked order placed the purchased product higher. This is the number a client sees before `mode` is switched to `on`, and the same table drives the panel's "Uplift" card. + +#### 2.10 Panel section (Inertia) + +Built as an add-on to `lunarphp/panel`, following `packages/panel-addon-example`: a `Section` registered with `Panel::section()`, Vue pages registered via `window.LunarPanel.registerPages()`, own Vite build under `vendor/lunar-panel/search-relevance`, `lang` namespace `search-relevance`. No Filament work. + +`SearchRelevanceSection`: + +- **Permission** `search:manage-relevance`, seeded by the package migration the same way first-party handles are. +- **Navigation**: group `search`, item "Search relevance", icon from the panel's built-in set. +- **Routes** (all under `can:search:manage-relevance`): + - `panel.search-relevance.index`: KPIs for a date range (searches, click-through rate, search conversion rate, zero-result rate, mean click position) plus the replay uplift card. Top queries table with counts and conversion, zero-result queries table, queries with no clicks (merchandising opportunities). + - `panel.search-relevance.query`: one normalised query. Learned products in score order with the explainability breakdown per product: relative score, clicks, baskets, purchases, distinct sessions, last event, and the engine position it typically comes from. Raw query variants that normalise to it. + - `panel.settings.search-relevance.index`: read-only status: the configured mode with the env var that sets it, the event weights, the scoring schedule and last run, and the retrieval version per model. +- **Dashboard widget** `SearchConversionWidget` (`WidgetSpan::Half`): searches and conversion for the dashboard range. +- **Slot** on `products.edit:content:after` showing "Search performance" for the product (queries it wins, clicks, purchases). +- **Global search source**: normalised queries, so staff can jump to a query page from the palette. + +Vue pages use the panel's existing table and card components; nothing custom beyond a small bar for relative score. Ship `en` translations only; other locales fall back per the panel's namespace rules. + +#### 2.10.1 Abuse and manipulation guards + +Learned ranking is a feedback loop, so bots and bad actors can try to feed it. The design limits the blast radius structurally (the bucketed ranker only reorders within buckets of ten and the learned union lands at position 11 or later, so nothing can be pushed into the top ten unless the engine already put it there) and adds these guards: + +- **Event validation**: an event needs a real `search_id`, a product from that search's shown list, a position inside it, and must arrive within `guards.event_window_minutes` of the search (default 120). The events table is unique on `(search_id, product_id, type)`, so replaying a click adds nothing. +- **Scoring dedupe**: each session contributes at most one event of each type per query and product; a session cannot vote twice by re-running the search. +- **Trusted sessions**: with `guards.trusted_sessions_only` (default on), only sessions that hold a cart or belong to a known customer count. A bot minting fresh sessions gains nothing; it must interact with the storefront to get a cart, and even then one vote per session. +- **Crawlers** are neither logged nor ranked (`guards.ignored_user_agents`), so reporting stays honest and the tables stay small. Sessionless headless clients are kept, since they identify the shopper explicitly on the events endpoint. +- **Refunds and cancellations** remove the purchase events their attributed order lines produced (`Listeners\ForgetPurchases` on `OrderCancelled` and `OrderRefunded`). +- **Staff overrides** in the panel query page, stored in `{prefix}search_learning_overrides`: exclude a product from learning for a query (its score is removed immediately and future events ignored, reversible) and reset learning for a query (discards everything learned and ignores events before the reset). Both go through `Lunar\SearchRelevance\Learning\Overrides`, which both aggregators honour. + +Not done on purpose: storing IP addresses for abuse analysis. The per-IP rate limit on the events endpoint covers the crude case without the privacy obligations. + +#### 2.11 Tests + +`tests/search-relevance/` (Pest, per monorepo layout): + +- Unit: normaliser cases (units, part numbers, punctuation, plurals), signal combination and clamping, bucket ordering including the learned-union insertion point, `RetrievalVersion`. +- Feature: request and results pipelines widen and slice correctly across the window boundary using the Database engine and a fake for Typesense/Meilisearch; shadow mode displays engine order but logs ranked order; events endpoint rejects unknown search ids, products not shown, and out-of-range positions; cart line and order attribution end to end; scoring on MySQL and Postgres in CI (`cross-db` group), PHP aggregator on SQLite; prune; replay output. +- Panel: routes gated by permission; index and query pages render with fixture data; the settings page renders from config and exposes no update route. + +## Alternatives considered + +- **Inside `lunarphp/search`.** Rejected: brings four tables, a scheduler, queue jobs and a panel section into a package every search user installs. Optional package with two generic hooks is cleaner and keeps `search` small. +- **Engine-native boosting** (Typesense `sort_by=_eval`, Meilisearch ranking rules). Rejected on evidence: prototype MRR 0.390 versus 0.715 for Laravel-side reranking under hybrid retrieval, because a `sort_by` discards rank fusion and Typesense buckets by score range rather than count. Also not portable across engines. +- **Learning-to-rank model now.** Prototype showed +0.03 nDCG over the lookup table and no meaningful cold-start gain, at the cost of a Python training sidecar. Deferred. The `features` column keeps the door open. +- **Postgres-only with partitioning.** Rejected: most Lunar installs run MySQL. Replaced by a retention prune and driver-specific age expressions. +- **Separate impressions table.** Rejected for write volume (50 rows per search). A JSON `shown` column on the query row is enough for validation and replay. +- **Do nothing.** Clients keep paying per-request search bills for learning Lunar could provide at fixed cost. + +## Migration impact + +- **Database**: four new tables from `search-relevance`. The `search` package adds nothing to the database. +- **Public contract surface**: `AbstractEngine` gains `page()`, `getPage()`, `getPerPage()`, `withParams()`, `getParams()`, `pipeRequest()`, `pipeResults()`; `SearchHit` and `SearchResults` gain `meta` with defaults; `ScoutIndexer` gains `getExactMatchFields()`. Non-breaking. Custom engines that do not call the pipeline methods keep their current behaviour and do not get ranking. +- **Upgrade path for v1.x**: none planned. The package targets 2.x only. `lunarphp/upgrade` needs no change. +- **Translation / locale impact**: new `search-relevance` lang namespace, `en` shipped; 15 other locales fall back. Storefront components carry no translatable text. +- **Filament / admin impact**: none. Panel section is Inertia only. Filament users get logging, scoring and ranking but no admin screens. +- **Queues**: the package assumes a queue worker. With the `sync` driver it still works, with writes on the request path. Documented. +- **Cache**: window cache uses the default store. Arrays only, so any driver works. + +## Docs PR (`lunarphp/docs`) + +New and changed pages, each added to `docs.json` under the v2.x navigation: + +1. **`2.x/addons/search-relevance.mdx`** (Add-ons, General, after `search`). Sections: part-number search and how queries are classified (the field and engine setup lives on the `search` page); what it does and what it does not (retrieval stays with the engine); installation; modes and the shadow-first rollout; storefront tracking for Blade and headless with the exact attributes and payload; attribution and what cart line `meta` contains; scoring explained in plain language with the config table; versioning and why changing the normaliser relearns; scheduling and queues; the replay command with sample output; supported engines table (Typesense, Meilisearch, custom engines that opt into the pipelines). +2. **`2.x/addons/search.mdx`**: new "Pipelines" section documenting `lunar.search.pipelines.request` and `results`, the passable objects, `page()`, and the `meta` fields, with a short custom-stage example; the Typesense vector distance threshold and the Meilisearch hybrid keys, and a "Product code search" section (`skus_normalised`, the Typesense schema and `query_by` entries, the Meilisearch typo-tolerance step and reindex), since they are `search` and core settings that apply with or without the add-on. +3. **`2.x/extending/search.mdx`**: new "Search relevance" section with examples of a custom `QueryNormaliser` (client-specific synonyms), a custom `Signal` (an example using stock level), and swapping the `Ranker`. State clearly that the same normaliser must be used at log time and search time. +4. **`2.x/admin/search-relevance.mdx`** (Admin Panel tab): the section's pages, the permission handle, reading the explainability breakdown, when to switch from shadow to on. +5. **`2.x/guides/search.mdx`**: add a short "Make search learn" subsection linking to the add-on and showing the two storefront lines. +6. **`logs/flight-plan.mdx`**: entry for the feature. + +Every class, config key and command in the docs must be verified against the monorepo `2.x` branch before merge, per the docs repo's `CLAUDE.md`. Code samples use full namespaces (`Lunar\SearchRelevance\...`). + +## Resolved questions + +1. **Where does the panel persist the mode switch?** It does not. Store behaviour is configured in code throughout Lunar, so the mode lives in config and the panel settings page is read-only. An earlier revision persisted a panel override in a settings table; it was removed for that reason. +2. **Shopper identity for headless storefronts.** The events endpoint accepts an explicit `session_id` for API clients without a cart session cookie; the storefront passes its cart identifier. The Blade component and the default headless payload omit it and let the server resolve it. +3. **Product-level versus variant-level events.** Product ids only. A nullable `variant_id` can be added later without breaking the tables. +4. **Meilisearch `showRankingScore`.** Requested by the engine as part of Part 1, so `hit->meta['score']` is populated on Meilisearch too. +5. **Rate limiting key.** Per shopper id and per IP, whichever trips first. +6. **Zero-result behaviour for nonsense queries.** Left to the storefront. The guide recommends a zero-results state rather than the engine padding the page; the add-on ships no fallback stage. + +## References + +- Prototype results: `~/Tmp/search-relevancy/RESULTS.md` +- [[0040-storefront-context]] for the session and customer resolution the logger reuses +- [[0049-inertia-admin-panel]] and `packages/panel-addon-example` for the panel add-on pattern + +## Implementation plan + +- [x] Slice 1 — `search`: pipelines, `page()`, `withParams()`, `meta` fields, retrieval baseline fixes, tests +- [x] Slice 2 — `search-relevance`: skeleton, migrations, config, normaliser, logging, events endpoint, storefront tracking, attribution, prune +- [x] Slice 3 — `search-relevance`: scoring aggregators, schedule, `RetrievalVersion`, replay command +- [x] Slice 4 — `search-relevance`: contracts, `QueryAffinitySignal`, `BucketedRanker`, `WidenRequest`, `RankResults`, learned union, shadow logging +- [x] Slice 5 — Panel section, widget, product slot, search source, settings +- [x] Slice 6 — Docs PR (`lunarphp/docs`) +- [x] Slice 8 — Abuse guards: event window and dedupe, trusted sessions, crawler skip, refund/cancel forgetting, panel exclusions and reset +- [x] Slice 7 — Storefront client `@lunarphp/search-relevance` (framework-agnostic + Vue), shared with the Blade component, typed `meta` +- [ ] Follow-ups (separate specs): `AccountHistorySignal`, exploration strip diff --git a/specs/README.md b/specs/README.md index 9ca366bf1e..da25b68a6c 100644 --- a/specs/README.md +++ b/specs/README.md @@ -93,3 +93,4 @@ Each spec carries a `Status:` line in its frontmatter / header: | 0072 | Panel Discounts section | accepted | | 0073 | Split `AmountOff` into `PercentageOff` and `FixedAmountOff` | implemented | | 0074 | Panel global search (command palette) | implemented | +| 0084 | Search relevance: learned ranking for Lunar search | accepted | diff --git a/tests/core/Unit/Search/ProductIndexerTest.php b/tests/core/Unit/Search/ProductIndexerTest.php index 8fb3cdbb22..3c0a0fd0e0 100644 --- a/tests/core/Unit/Search/ProductIndexerTest.php +++ b/tests/core/Unit/Search/ProductIndexerTest.php @@ -64,6 +64,7 @@ expect($data)->toHaveKey('id'); expect($data['skus'])->toBe([$variant->sku]); + expect($data['skus_normalised'])->toBe([strtoupper(preg_replace('/[^A-Za-z0-9]+/', '', $variant->sku))]); expect($data['status'])->toEqual((string) $product->status); expect($data['product_type'])->toEqual($product->productType->name); expect($data['brand'])->toEqual($product->brand?->name); @@ -77,3 +78,17 @@ expect($data['name_en'])->toBe('Trainers'); expect($data['name_dk'])->toBe('Løbesko'); }); + +test('normalised skus drop separators and uppercase the code', function () { + Language::factory()->create(['code' => 'en', 'default' => true]); + + $product = Product::factory()->create(); + + ProductVariant::factory()->create(['product_id' => $product->id, 'sku' => 'hag-mb-32a/bcu.1']); + ProductVariant::factory()->create(['product_id' => $product->id, 'sku' => null]); + + $data = app(ProductIndexer::class)->toSearchableArray($product->fresh()); + + expect($data['skus_normalised'])->toBe(['HAGMB32ABCU1']) + ->and(app(ProductIndexer::class)->getExactMatchFields())->toBe(['skus', 'skus_normalised']); +}); diff --git a/tests/search-relevance/Feature/AttributionTest.php b/tests/search-relevance/Feature/AttributionTest.php new file mode 100644 index 0000000000..d36d0b02c1 --- /dev/null +++ b/tests/search-relevance/Feature/AttributionTest.php @@ -0,0 +1,87 @@ +group('search-relevance'); + +beforeEach(function () { + Fixtures::storefront(); + $this->variant = ProductVariant::factory()->create(); + $this->search = SearchQuery::factory()->create(['shown' => [$this->variant->product_id, 999]]); +}); + +it('credits a new cart line to the clicked search and records a basket event', function () { + app(Attribution::class)->remember($this->variant->product_id, $this->search->id, 1, 'organic', 'cart:7'); + + $line = CartLine::factory()->create(['purchasable_id' => $this->variant->id]); + + expect($line->fresh()->meta['search_attribution'])->toBe([ + 'search_id' => $this->search->id, + 'position' => 1, + 'source' => 'organic', + 'session_id' => 'cart:7', + ]); + + expect(SearchEvent::query()->sole())->toMatchArray([ + 'search_id' => $this->search->id, + 'product_id' => $this->variant->product_id, + 'position' => 1, + 'type' => 'basket', + 'session_id' => 'cart:7', + ]); +}); + +it('leaves a cart line alone without attribution', function () { + $line = CartLine::factory()->create(['purchasable_id' => $this->variant->id]); + + expect($line->fresh()->meta)->toBeNull() + ->and(SearchEvent::query()->count())->toBe(0); +}); + +it('ignores expired attribution', function () { + app(Attribution::class)->remember($this->variant->product_id, $this->search->id, 1, 'organic', 'cart:7'); + $this->travel(31)->minutes(); + + CartLine::factory()->create(['purchasable_id' => $this->variant->id]); + + expect(SearchEvent::query()->count())->toBe(0) + ->and(app(Attribution::class)->find($this->variant->product_id))->toBeNull(); +}); + +it('records a purchase for every placed order line carrying attribution', function () { + $order = Order::factory()->create(); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'purchasable_id' => $this->variant->id, + 'meta' => ['search_attribution' => ['search_id' => $this->search->id, 'position' => 1, 'source' => 'learned', 'session_id' => 'cart:7']], + ]); + OrderLine::factory()->create(['order_id' => $order->id]); + + event(new OrderPlaced($order)); + + expect(SearchEvent::query()->sole())->toMatchArray([ + 'search_id' => $this->search->id, + 'product_id' => $this->variant->product_id, + 'position' => 1, + 'type' => 'purchase', + 'source' => 'learned', + 'session_id' => 'cart:7', + ]); +}); + +it('is null-safe without a session', function () { + $attribution = new Attribution(app('config'), null); + + $attribution->remember(1, $this->search->id, 1, 'organic', 'cart:1'); + + expect($attribution->find(1))->toBeNull(); +}); diff --git a/tests/search-relevance/Feature/CommandsTest.php b/tests/search-relevance/Feature/CommandsTest.php new file mode 100644 index 0000000000..20d359b997 --- /dev/null +++ b/tests/search-relevance/Feature/CommandsTest.php @@ -0,0 +1,67 @@ +group('search-relevance'); + +it('scores every configured model version', function () { + // Sessions must hold a cart to count under the default trusted_sessions_only guard. + foreach (['cart:1', 'cart:2', 'cart:3'] as $session) { + ScoringFixture::event(ScoringFixture::search('w', $session, [1]), 1, 1, 'click'); + } + + artisan('lunar:search-relevance:score') + ->expectsOutputToContain('[n1:database:keyword] wrote 1 query/product rows.') + ->assertSuccessful(); + + expect(SearchQueryScore::query()->sole())->toMatchArray(['normalised_query' => 'w', 'product_id' => 1, 'sessions' => 3]); +}); + +it('prunes searches and events past retention', function () { + $old = ScoringFixture::search('old', 's1', [1], at: now()->subDays(401)); + ScoringFixture::event($old, 1, 1, 'click', at: now()->subDays(401)); + $recent = ScoringFixture::search('recent', 's1', [1]); + ScoringFixture::event($recent, 1, 1, 'click'); + + artisan('lunar:search-relevance:prune') + ->expectsOutputToContain('Pruned 1 searches and 1 events') + ->assertSuccessful(); + + expect(SearchQuery::query()->pluck('id')->all())->toBe([$recent->id]) + ->and(SearchEvent::query()->count())->toBe(1); +}); + +it('replays purchases against the shown and ranked orders', function () { + $improved = SearchQuery::factory()->create(['shown' => [1, 2, 3], 'ranked' => [3, 1, 2]]); + ScoringFixture::event($improved, 3, 3, 'purchase'); + $worsened = SearchQuery::factory()->create(['shown' => [1, 2, 3], 'ranked' => [2, 3, 1]]); + ScoringFixture::event($worsened, 1, 1, 'purchase'); + $same = SearchQuery::factory()->create(['shown' => [1, 2, 3], 'ranked' => [1, 3, 2]]); + ScoringFixture::event($same, 1, 1, 'purchase'); + $notRanked = SearchQuery::factory()->create(['shown' => [1, 2, 3], 'ranked' => null]); + ScoringFixture::event($notRanked, 2, 2, 'purchase'); + $noPurchase = SearchQuery::factory()->create(['shown' => [1, 2, 3], 'ranked' => [3, 2, 1]]); + ScoringFixture::event($noPurchase, 3, 3, 'click'); + $tooOld = SearchQuery::factory()->create(['shown' => [1, 2, 3], 'ranked' => [3, 2, 1], 'created_at' => now()->subDays(40)]); + ScoringFixture::event($tooOld, 3, 3, 'purchase'); + + $result = app(Replay::class)->run(now()->subDays(30)); + + expect($result->searches)->toBe(3) + ->and($result->mrrShown)->toEqualWithDelta((1 / 3 + 1 + 1) / 3, 0.001) + ->and($result->mrrRanked)->toEqualWithDelta((1 + 1 / 3 + 1) / 3, 0.001) + ->and($result->improvedShare)->toEqualWithDelta(1 / 3, 0.001) + ->and($result->worsenedShare)->toEqualWithDelta(1 / 3, 0.001); + + artisan('lunar:search-relevance:replay', ['--days' => 30]) + ->expectsOutputToContain('Searches with a purchase') + ->expectsOutputToContain('33.3%') + ->assertSuccessful(); +}); diff --git a/tests/search-relevance/Feature/EventsEndpointTest.php b/tests/search-relevance/Feature/EventsEndpointTest.php new file mode 100644 index 0000000000..3503837fef --- /dev/null +++ b/tests/search-relevance/Feature/EventsEndpointTest.php @@ -0,0 +1,101 @@ +group('search-relevance'); + +beforeEach(function () { + Fixtures::storefront(); + $this->search = SearchQuery::factory()->create(['shown' => [10, 20, 30]]); +}); + +it('records a click and stores attribution in the session', function () { + $response = postJson(route('lunar.search-relevance.events'), [ + 'search_id' => $this->search->id, + 'product_id' => 20, + 'position' => 2, + 'source' => 'learned', + ]); + + $response->assertNoContent(); + + $event = SearchEvent::query()->sole(); + + expect($event)->toMatchArray(['search_id' => $this->search->id, 'product_id' => 20, 'position' => 2, 'type' => 'click', 'source' => 'learned']) + ->and($event->session_id)->toStartWith('session:') + ->and($event->created_at)->not->toBeNull(); + + $attribution = session()->get('lunar_search_relevance.attribution.20'); + + expect($attribution)->toMatchArray(['search_id' => $this->search->id, 'position' => 2, 'source' => 'learned', 'session_id' => $event->session_id]) + ->and($attribution['expires'])->toBeGreaterThan(now()->getTimestamp()); +}); + +it('accepts a form post with an explicit shopper id', function () { + post(route('lunar.search-relevance.events'), [ + 'search_id' => $this->search->id, + 'product_id' => 10, + 'position' => 1, + 'session_id' => 'cart:99', + ])->assertNoContent(); + + expect(SearchEvent::query()->sole())->toMatchArray(['product_id' => 10, 'source' => 'organic', 'session_id' => 'cart:99']); +}); + +it('answers 204 without recording anything for bad input', function (array $payload) { + postJson(route('lunar.search-relevance.events'), $payload)->assertNoContent(); + + expect(SearchEvent::query()->count())->toBe(0) + ->and(session()->get('lunar_search_relevance.attribution'))->toBeNull(); +})->with([ + 'unknown search' => fn () => ['search_id' => '01ARZ3NDEKTSV4RRFFQ69G5FAV', 'product_id' => 10, 'position' => 1], + 'malformed search id' => fn () => ['search_id' => 'nope', 'product_id' => 10, 'position' => 1], + 'missing product' => fn () => ['search_id' => $this->search->id, 'position' => 1], + 'position below one' => fn () => ['search_id' => $this->search->id, 'product_id' => 10, 'position' => 0], + 'unknown source' => fn () => ['search_id' => $this->search->id, 'product_id' => 10, 'position' => 1, 'source' => 'paid'], +]); + +it('drops events for products the search did not show or positions out of range', function () { + postJson(route('lunar.search-relevance.events'), ['search_id' => $this->search->id, 'product_id' => 99, 'position' => 1])->assertNoContent(); + postJson(route('lunar.search-relevance.events'), ['search_id' => $this->search->id, 'product_id' => 10, 'position' => 4])->assertNoContent(); + + expect(SearchEvent::query()->count())->toBe(0); +}); + +it('rate limits per shopper', function () { + Config::set('lunar.search_relevance.guards.events_rate_limit', '2,1'); + + $payload = ['search_id' => $this->search->id, 'product_id' => 10, 'position' => 1]; + + postJson(route('lunar.search-relevance.events'), $payload)->assertNoContent(); + postJson(route('lunar.search-relevance.events'), $payload)->assertNoContent(); + postJson(route('lunar.search-relevance.events'), $payload)->assertStatus(429); +}); + +it('records each event type once per search and product', function () { + $search = SearchQuery::factory()->create(['shown' => [7, 8]]); + $payload = ['search_id' => $search->id, 'product_id' => 7, 'position' => 1]; + + $this->postJson(route('lunar.search-relevance.events'), $payload)->assertNoContent(); + $this->postJson(route('lunar.search-relevance.events'), [...$payload, 'position' => 2])->assertNoContent(); + + expect(SearchEvent::query()->count())->toBe(1) + ->and(SearchEvent::query()->first()->position)->toBe(1); +}); + +it('drops events for searches older than the event window', function () { + Config::set('lunar.search_relevance.guards.event_window_minutes', 60); + $search = SearchQuery::factory()->create(['shown' => [7], 'created_at' => now()->subMinutes(61)]); + + $this->postJson(route('lunar.search-relevance.events'), ['search_id' => $search->id, 'product_id' => 7, 'position' => 1])->assertNoContent(); + + expect(SearchEvent::query()->count())->toBe(0) + ->and(session()->has('lunar_search_relevance.attribution.7'))->toBeFalse(); +}); diff --git a/tests/search-relevance/Feature/GuardsTest.php b/tests/search-relevance/Feature/GuardsTest.php new file mode 100644 index 0000000000..9d99f8016c --- /dev/null +++ b/tests/search-relevance/Feature/GuardsTest.php @@ -0,0 +1,81 @@ +group('search-relevance'); + +beforeEach(function () { + Fixtures::storefront(); +}); + +/** Route product searches to a fake Typesense engine that answers with two hits. */ +function fakeTypesenseForGuards(): void +{ + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + + $engine = partialMock(TypesenseEngine::class, function (MockInterface $mock) { + $mock->shouldAllowMockingProtectedMethods()->shouldReceive('getRawResults')->andReturn(new LengthAwarePaginator( + items: ['hits' => [['document' => ['id' => '1']], ['document' => ['id' => '2']]], 'facet_counts' => []], + total: 2, + perPage: 50, + currentPage: 1, + )); + }); + Search::extend('typesense', fn () => $engine); +} + +it('neither logs nor stamps searches from crawlers', function (string $agent) { + fakeTypesenseForGuards(); + app('request')->headers->set('User-Agent', $agent); + + $results = Search::model(Product::class)->query('hoodie')->get(); + + expect($results->meta)->not->toHaveKey('search_id') + ->and(SearchQuery::query()->count())->toBe(0); +})->with(['Mozilla/5.0 (compatible; Googlebot/2.1)', 'curl/8.4.0', 'python-requests/2.31']); + +it('logs an ordinary browser search, with or without a session', function () { + fakeTypesenseForGuards(); + app('request')->headers->set('User-Agent', 'Mozilla/5.0 Safari'); + + $results = Search::model(Product::class)->query('hoodie')->get(); + + expect($results->meta)->toHaveKey('search_id') + ->and(SearchQuery::query()->count())->toBe(1); +}); + +it('forgets purchase events when the order is cancelled or refunded', function (string $eventClass) { + $variant = ProductVariant::factory()->create(); + $search = SearchQuery::factory()->create(['shown' => [$variant->product_id]]); + SearchEvent::factory()->create(['search_id' => $search->id, 'product_id' => $variant->product_id, 'type' => 'purchase']); + SearchEvent::factory()->create(['search_id' => $search->id, 'product_id' => $variant->product_id, 'type' => 'click']); + + $order = Order::factory()->create(); + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'purchasable_id' => $variant->id, + 'meta' => ['search_attribution' => ['search_id' => $search->id, 'position' => 1, 'source' => 'organic']], + ]); + + app(ForgetPurchases::class)->handle(new $eventClass($order)); + + expect(SearchEvent::query()->pluck('type')->all())->toBe(['click']); +})->with([OrderCancelled::class, OrderRefunded::class]); diff --git a/tests/search-relevance/Feature/Panel/SearchRelevancePanelTest.php b/tests/search-relevance/Feature/Panel/SearchRelevancePanelTest.php new file mode 100644 index 0000000000..2556df57af --- /dev/null +++ b/tests/search-relevance/Feature/Panel/SearchRelevancePanelTest.php @@ -0,0 +1,381 @@ +create(['admin' => $admin]); + + if ($permitted) { + $staff->givePermissionTo(PERMISSION); + } + + return $staff; +} + +/** + * Three searches for "red shoes" (one raw variant), one for "blue hat" with no + * results, one for "green bag" with results but no clicks. Two clicks and one + * purchase on the shoes searches. + * + * @return array{product: Product, searches: array} + */ +function relevanceFixture(): array +{ + $product = Product::factory()->create(); + + $searches = SearchQuery::factory()->count(3)->create([ + 'model_type' => Product::class, + 'raw_query' => 'Red Shoes', + 'normalised_query' => 'red shoes', + 'result_count' => 12, + 'shown' => [$product->id], + ])->all(); + + $searches[0]->update(['raw_query' => 'red shoes!']); + + SearchQuery::factory()->create([ + 'model_type' => Product::class, + 'raw_query' => 'blue hat', + 'normalised_query' => 'blue hat', + 'result_count' => 0, + 'shown' => [], + ]); + + SearchQuery::factory()->create([ + 'model_type' => Product::class, + 'raw_query' => 'green bag', + 'normalised_query' => 'green bag', + 'result_count' => 4, + 'shown' => [$product->id], + ]); + + SearchEvent::factory()->create(['search_id' => $searches[0]->id, 'product_id' => $product->id, 'position' => 3, 'type' => 'click']); + SearchEvent::factory()->create(['search_id' => $searches[1]->id, 'product_id' => $product->id, 'position' => 1, 'type' => 'click']); + SearchEvent::factory()->create(['search_id' => $searches[1]->id, 'product_id' => $product->id, 'position' => 1, 'type' => 'basket']); + SearchEvent::factory()->create(['search_id' => $searches[1]->id, 'product_id' => $product->id, 'position' => 1, 'type' => 'purchase']); + + return ['product' => $product, 'searches' => $searches]; +} + +beforeEach(function () { + Language::factory()->create(['default' => true, 'code' => 'en']); +}); + +it('gates every route behind the permission', function () { + $product = Product::factory()->create(); + + $this->actingAs(relevanceStaff(), 'staff'); + + $this->get(route('panel.search-relevance.index'))->assertForbidden(); + $this->get(route('panel.search-relevance.query', ['query' => 'red shoes']))->assertForbidden(); + $this->getJson(route('panel.search-relevance.product', $product))->assertForbidden(); + $this->get(route('panel.settings.search-relevance.index'))->assertForbidden(); +}); + +it('allows the routes to permitted staff and admins', function () { + $this->actingAs(relevanceStaff(permitted: true), 'staff') + ->get(route('panel.search-relevance.index')) + ->assertOk(); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.search-relevance.index')) + ->assertOk(); +}); + +it('shows the navigation only to staff with the permission', function () { + $hasItem = fn ($groups): bool => collect($groups)->firstWhere('key', 'search') !== null + && collect($groups)->last()['key'] === 'search' + && collect(collect($groups)->firstWhere('key', 'search')['items'])->firstWhere('key', 'search-relevance')['icon'] === 'chart'; + + $this->actingAs(relevanceStaff(permitted: true), 'staff') + ->get('/panel') + ->assertInertia(fn (Assert $page) => $page + ->where('navigation.groups', fn ($groups) => $hasItem($groups)) + ->where('settingsNavigation.groups', fn ($groups) => collect(collect($groups)->firstWhere('key', 'store')['items']) + ->firstWhere('key', 'search-relevance')['url'] === route('panel.settings.search-relevance.index'))); + + $this->actingAs(relevanceStaff(), 'staff') + ->get('/panel') + ->assertInertia(fn (Assert $page) => $page + ->where('navigation.groups', fn ($groups) => ! collect($groups)->pluck('key')->contains('search'))); +}); + +it('renders the index page with kpis and query tables', function () { + relevanceFixture(); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.search-relevance.index', ['range' => '7d'])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('search-relevance::Index', false) + ->where('range', '7d') + ->where('kpis.searches', 5) + ->where('kpis.click_through_rate', 40) + ->where('kpis.conversion_rate', 20) + ->where('kpis.zero_result_rate', 20) + ->where('kpis.mean_click_position', 2) + ->has('uplift.searches') + ->has('uplift.mrr_shown') + ->has('uplift.mrr_ranked') + ->has('uplift.improved_share') + ->has('uplift.worsened_share') + ->has('top_queries', 3) + ->where('top_queries.0.query', 'red shoes') + ->where('top_queries.0.searches', 3) + ->where('top_queries.0.clicks', 2) + ->where('top_queries.0.conversions', 1) + ->where('top_queries.0.conversion_rate', 33.3) + ->where('top_queries.0.url', route('panel.search-relevance.query', ['query' => 'red shoes'])) + ->has('zero_result_queries', 1) + ->where('zero_result_queries.0.query', 'blue hat') + ->has('no_click_queries', 1) + ->where('no_click_queries.0.query', 'green bag') + ->where('urls.index', route('panel.search-relevance.index'))); +}); + +it('renders the query page with explainability rows and raw variants', function () { + ['product' => $product] = relevanceFixture(); + + SearchQueryScore::factory()->create([ + 'model_type' => Product::class, + 'normalised_query' => 'red shoes', + 'product_id' => $product->id, + 'score' => 4.2, + 'relative' => 1.0, + 'sessions' => 3, + 'version' => app(RetrievalVersion::class)->current(Product::class), + ]); + + // A stale version is ignored. + SearchQueryScore::factory()->create([ + 'model_type' => Product::class, + 'normalised_query' => 'red shoes', + 'product_id' => Product::factory()->create()->id, + 'score' => 9, + 'relative' => 1.0, + 'sessions' => 9, + 'version' => 'stale', + ]); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.search-relevance.query', ['query' => 'red shoes'])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('search-relevance::Query', false) + ->where('query', 'red shoes') + ->where('model_type', Product::class) + ->has('learned', 1) + ->where('learned.0.product_id', $product->id) + ->where('learned.0.name', (string) $product->translate('name')) + ->where('learned.0.relative', 1) + ->where('learned.0.score', 4.2) + ->where('learned.0.clicks', 2) + ->where('learned.0.baskets', 1) + ->where('learned.0.purchases', 1) + ->where('learned.0.sessions', 3) + ->where('learned.0.typical_position', 1.5) + ->whereNot('learned.0.last_event_at', null) + ->where('learned.0.url', route('panel.search-relevance.product', $product)) + ->where('learned.0.edit_url', route('panel.products.edit', $product)) + ->has('variants', 2) + ->where('variants.0.raw_query', 'Red Shoes') + ->where('variants.0.searches', 2) + ->where('variants.1.raw_query', 'red shoes!')); +}); + +it('renders the product search report page', function () { + ['product' => $product] = relevanceFixture(); + + SearchQueryScore::factory()->create([ + 'model_type' => Product::class, + 'normalised_query' => 'red shoes', + 'product_id' => $product->id, + 'score' => 4.2, + 'relative' => 0.75, + 'sessions' => 3, + 'version' => app(RetrievalVersion::class)->current(Product::class), + ]); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.search-relevance.product', $product)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('search-relevance::Product', false) + ->where('product.id', $product->id) + ->where('product.edit_url', route('panel.products.edit', $product)) + ->has('queries', 1) + ->where('queries.0.query', 'red shoes') + ->where('queries.0.relative', 0.75) + ->where('queries.0.clicks', 2) + ->where('queries.0.baskets', 1) + ->where('queries.0.purchases', 1) + ->where('queries.0.url', route('panel.search-relevance.query', ['query' => 'red shoes']))); +}); + +it('returns the top queries and a report link for the product sidebar card', function () { + ['product' => $product] = relevanceFixture(); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->getJson(route('panel.search-relevance.product.summary', $product)) + ->assertOk() + ->assertJsonCount(1, 'queries') + ->assertJsonPath('queries.0.query', 'red shoes') + ->assertJsonPath('total', 1) + ->assertJsonPath('url', route('panel.search-relevance.product', $product)); +}); + +it('shares the product sidebar card on the product edit page', function () { + $product = Product::factory()->create(); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.products.edit', $product)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('slots', fn ($slots) => collect($slots->get('products.edit:sidebar:after')) + ->contains(fn ($entry) => $entry['component'] === 'search-relevance::ProductSearchPerformance'))); +}); + +it('renders the read-only settings page from config', function () { + Config::set('lunar.search_relevance.mode', 'on'); + SearchQueryScore::factory()->create(['updated_at' => now()->subHour()]); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.settings.search-relevance.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('search-relevance::Settings/Index', false) + ->where('mode', 'on') + ->where('mode_env', 'LUNAR_SEARCH_RELEVANCE_MODE') + ->where('weights', config('lunar.search_relevance.scoring.weights')) + ->where('schedule', config('lunar.search_relevance.scoring.schedule')) + ->where('last_run', fn ($value) => $value !== null) + ->where('versions.0.model', Product::class) + ->where('versions.0.label', 'Product') + ->where('versions.0.version', app(RetrievalVersion::class)->current(Product::class))); +}); + +it('has no route to change the mode from the panel', function () { + expect(app('router')->has('panel.settings.search-relevance.update'))->toBeFalse(); +}); + +it('contributes the search conversion dashboard widget with deferred data', function () { + relevanceFixture(); + + $this->actingAs(relevanceStaff(admin: true), 'staff') + ->get(route('panel.dashboard', ['range' => '7d'])) + ->assertInertia(fn (Assert $page) => $page + ->component('Dashboard') + ->where('widgets', fn ($widgets) => collect($widgets)->firstWhere('key', 'search-relevance-conversion')['component'] + === 'search-relevance::SearchConversionWidget') + ->loadDeferredProps(fn (Assert $props) => $props + ->where('widgetData.search-relevance-conversion.searches', 5) + ->where('widgetData.search-relevance-conversion.conversion_rate', 20) + ->where('widgetData.search-relevance-conversion.previous_searches', 0) + ->where('widgetData.search-relevance-conversion.searches_delta.value', 'new'))); +}); + +it('hides the dashboard widget from staff without the permission', function () { + $this->actingAs(relevanceStaff(), 'staff') + ->get(route('panel.dashboard')) + ->assertInertia(fn (Assert $page) => $page + ->where('widgets', fn ($widgets) => ! collect($widgets)->pluck('key')->contains('search-relevance-conversion'))); +}); + +it('finds normalised queries through the global search', function () { + relevanceFixture(); + + $rows = collect( + $this->actingAs(relevanceStaff(admin: true), 'staff')->getJson('/panel/search?q=shoes')->assertOk()->json('data') + ); + + $row = $rows->firstWhere('kind', 'search-queries'); + + expect($rows->where('kind', 'search-queries'))->toHaveCount(1) + ->and($row['id'])->toBe('red shoes') + ->and($row['label'])->toBe('red shoes') + ->and($row['hint'])->toBe('3 searches') + ->and($row['url'])->toBe(route('panel.search-relevance.query', ['query' => 'red shoes'])); + + $hidden = collect( + $this->actingAs(relevanceStaff(), 'staff')->getJson('/panel/search?q=shoes')->assertOk()->json('data') + ); + + expect($hidden->where('kind', 'search-queries'))->toBeEmpty(); +}); + +it('serves the add-on lang group from the translations endpoint', function () { + $this->getJson('/panel/translations/en') + ->assertOk() + ->assertJsonPath('messages.search-relevance::panel.title', 'Search relevance'); +}); + +it('lets staff exclude a product from learning and allow it again', function () { + $staff = Staff::factory()->create(['admin' => true]); + $product = Product::factory()->create(); + SearchQueryScore::factory()->create(['normalised_query' => 'hoodie', 'product_id' => $product->id, 'version' => app(RetrievalVersion::class)->current(Product::class)]); + + $this->actingAs($staff, 'staff') + ->from(route('panel.search-relevance.query', ['query' => 'hoodie'])) + ->post(route('panel.search-relevance.exclude', ['query' => 'hoodie', 'productId' => $product->id])) + ->assertRedirect(route('panel.search-relevance.query', ['query' => 'hoodie'])) + ->assertSessionHas('success'); + + expect(SearchQueryScore::query()->count())->toBe(0) + ->and(app(Overrides::class)->excluded(Product::class, 'hoodie')->all())->toBe([$product->id]); + + $this->actingAs($staff, 'staff') + ->get(route('panel.search-relevance.query', ['query' => 'hoodie'])) + ->assertInertia(fn (Assert $page) => $page + ->component('search-relevance::Query', false) + ->has('excluded', 1) + ->where('excluded.0.product_id', $product->id) + ->where('reset_at', null) + ->has('urls.reset')); + + $this->actingAs($staff, 'staff') + ->delete(route('panel.search-relevance.include', ['query' => 'hoodie', 'productId' => $product->id])) + ->assertRedirect(); + + expect(app(Overrides::class)->excluded(Product::class, 'hoodie')->isEmpty())->toBeTrue(); +}); + +it('lets staff reset learning for a query', function () { + $staff = Staff::factory()->create(['admin' => true]); + SearchQueryScore::factory()->create(['normalised_query' => 'hoodie', 'product_id' => 1, 'version' => app(RetrievalVersion::class)->current(Product::class)]); + SearchQueryScore::factory()->create(['normalised_query' => 'mug', 'product_id' => 1, 'version' => app(RetrievalVersion::class)->current(Product::class)]); + + $this->actingAs($staff, 'staff') + ->post(route('panel.search-relevance.reset', ['query' => 'hoodie'])) + ->assertRedirect() + ->assertSessionHas('success'); + + expect(SearchQueryScore::query()->pluck('normalised_query')->all())->toBe(['mug']) + ->and(app(Overrides::class)->resetAt(Product::class, 'hoodie'))->not->toBeNull(); + + $this->actingAs($staff, 'staff') + ->get(route('panel.search-relevance.query', ['query' => 'hoodie'])) + ->assertInertia(fn (Assert $page) => $page->where('reset_at', fn ($value) => $value !== null)); +}); + +it('gates the override routes behind the permission', function () { + $staff = Staff::factory()->create(['admin' => false]); + + $this->actingAs($staff, 'staff') + ->post(route('panel.search-relevance.reset', ['query' => 'hoodie'])) + ->assertForbidden(); +}); diff --git a/tests/search-relevance/Feature/PhpScoreAggregatorTest.php b/tests/search-relevance/Feature/PhpScoreAggregatorTest.php new file mode 100644 index 0000000000..f40645f65c --- /dev/null +++ b/tests/search-relevance/Feature/PhpScoreAggregatorTest.php @@ -0,0 +1,46 @@ +group('search-relevance'); + +it('is the bound aggregator on sqlite', function () { + expect(app(ScoreAggregator::class))->toBeInstanceOf(PhpScoreAggregator::class); +}); + +it('aggregates events into scores', function () { + ScoringFixture::seed(); + + ScoringFixture::assertScores(app(PhpScoreAggregator::class)); +}); + +it('keeps only the top products per query', function () { + ScoringFixture::seed(); + + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, [...ScoringFixture::config(), 'max_products_per_query' => 2]); + + expect(SearchQueryScore::query()->where('normalised_query', 'w')->orderByDesc('score')->pluck('product_id')->all())->toBe([2, 3]) + ->and(SearchQueryScore::query()->where('normalised_query', 'p')->orderByDesc('score')->pluck('product_id')->all())->toBe([3, 2]); +}); + +it('invalidates the learned-score cache', function () { + Config::set('lunar.search_relevance.cache_ttl', 3600); + $context = new RankingContext(Product::class, 'w', 'session:x', null, 'on', null, md5('[]'), ScoringFixture::VERSION); + $signal = app(QueryAffinitySignal::class); + + expect($signal->learned($context))->toBe([]); + + ScoringFixture::seed(); + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, ScoringFixture::config()); + + expect(array_keys($signal->learned($context)))->toBe([2, 3, 1]) + ->and($signal->scores($context, [1, 2]))->toMatchArray([1 => 0.2, 2 => 1.0]); +}); diff --git a/tests/search-relevance/Feature/PipelinesTest.php b/tests/search-relevance/Feature/PipelinesTest.php new file mode 100644 index 0000000000..b960a175f3 --- /dev/null +++ b/tests/search-relevance/Feature/PipelinesTest.php @@ -0,0 +1,460 @@ +group('search-relevance'); + +/** + * A fake engine whose getRawResults() answers with each item set in turn, so + * the learned-union fetch (the second call) can return different documents. + */ +function fakeEngine(string $class, string $driver, array ...$responses): object +{ + $engine = partialMock($class, function (MockInterface $mock) use ($responses) { + $paginators = array_map(fn ($items) => new LengthAwarePaginator( + items: $items, + total: count($items['hits']), + perPage: 50, + currentPage: 1, + ), $responses); + + $mock->shouldAllowMockingProtectedMethods() + ->shouldReceive('getRawResults') + ->andReturn(...$paginators); + }); + + Search::extend($driver, fn () => $engine); + + return $engine; +} + +function typesenseHits(array $ids): array +{ + return ['hits' => array_map(fn ($id) => ['document' => ['id' => (string) $id], 'text_match' => 100 - $id], $ids), 'facet_counts' => []]; +} + +function ids($results): array +{ + return collect($results->hits)->map(fn ($hit) => (int) $hit->document['id'])->all(); +} + +function learn(int $productId, float $relative = 1.0, string $query = 'cable', string $version = 'n1:database:keyword'): void +{ + SearchQueryScore::factory()->create([ + 'normalised_query' => $query, + 'product_id' => $productId, + 'relative' => $relative, + 'score' => $relative * 10, + 'version' => $version, + ]); +} + +beforeEach(function () { + Fixtures::storefront(); + Fixtures::databaseEngine(); + Config::set('lunar.search_relevance.mode', 'shadow'); + Config::set('lunar.search_relevance.window', 250); + Config::set('lunar.search_relevance.bucket_size', 10); +}); + +it('widens the request to the window and slices back to the requested page', function () { + $products = Fixtures::products(30); + $expected = collect($products)->pluck('id')->slice(10, 10)->values()->all(); + + $results = Search::model(Product::class)->query('cable')->perPage(10)->page(2)->get(); + + expect($results->count)->toBe(30) + ->and($results->page)->toBe(2) + ->and($results->perPage)->toBe(10) + ->and($results->totalPages)->toBe(3) + ->and(ids($results))->toBe($expected) + ->and($results->hits[0]->meta['position'])->toBe(11) + ->and($results->hits[0]->meta['original_position'])->toBe(11) + ->and($results->hits[0]->meta['source'])->toBe('organic') + ->and($results->meta['ranking_mode'])->toBe('shadow') + ->and($results->meta['ranking_version'])->toBe('n1:database:keyword') + ->and($results->meta['search_id'])->toHaveLength(26) + ->and($results->toArray()['links'])->not->toBeEmpty(); + + $logged = SearchQuery::query()->find($results->meta['search_id']); + + expect($logged)->not->toBeNull() + ->and($logged->raw_query)->toBe('cable') + ->and($logged->normalised_query)->toBe('cable') + ->and($logged->result_count)->toBe(30) + ->and($logged->shown)->toBe(collect($products)->pluck('id')->all()) + ->and($logged->ranked)->toBeNull() + ->and($logged->session_id)->toStartWith('session:') + ->and($logged->mode)->toBe('shadow'); +}); + +it('leaves a page beyond the window alone but still logs it', function () { + Config::set('lunar.search_relevance.window', 20); + $products = Fixtures::products(30); + + $results = Search::model(Product::class)->query('cable')->perPage(10)->page(3)->get(); + + expect($results->page)->toBe(3) + ->and(ids($results))->toBe(collect($products)->pluck('id')->slice(20, 10)->values()->all()) + ->and($results->hits[0]->meta['position'])->toBe(21) + ->and($results->meta['search_id'])->toHaveLength(26); + + $logged = SearchQuery::query()->find($results->meta['search_id']); + + expect($logged->shown)->toBe(collect($products)->pluck('id')->slice(20, 10)->values()->all()) + ->and($logged->ranked)->toBeNull(); +}); + +it('caps the logged impressions', function () { + Config::set('lunar.search_relevance.impressions_logged', 5); + Fixtures::products(8); + + $results = Search::model(Product::class)->query('cable')->perPage(4)->get(); + + expect(SearchQuery::query()->find($results->meta['search_id'])->shown)->toHaveCount(5); +}); + +it('displays the ranked order in on mode', function () { + Config::set('lunar.search_relevance.mode', 'on'); + $products = Fixtures::products(12); + learn($products[4]->id); + + $results = Search::model(Product::class)->query('cable')->perPage(10)->get(); + + $ids = collect($products)->pluck('id')->all(); + $expected = [$ids[4], $ids[0], $ids[1], $ids[2], $ids[3], $ids[5], $ids[6], $ids[7], $ids[8], $ids[9]]; + + expect(ids($results))->toBe($expected) + ->and($results->hits[0]->meta)->toMatchArray(['position' => 1, 'original_position' => 5, 'boost' => 1.0, 'source' => 'organic']) + ->and($results->meta['ranking_mode'])->toBe('on'); + + $logged = SearchQuery::query()->find($results->meta['search_id']); + + expect($logged->shown)->toBe([...$expected, $ids[10], $ids[11]]) + ->and($logged->ranked)->toBeNull(); +}); + +it('displays the engine order in shadow mode but logs the ranked order', function () { + $products = Fixtures::products(12); + learn($products[4]->id); + + $results = Search::model(Product::class)->query('cable')->perPage(10)->get(); + + $ids = collect($products)->pluck('id')->all(); + + expect(ids($results))->toBe(array_slice($ids, 0, 10)) + ->and($results->hits[4]->meta)->toMatchArray(['position' => 5, 'original_position' => 5, 'boost' => 1.0]); + + $logged = SearchQuery::query()->find($results->meta['search_id']); + + expect($logged->shown)->toBe($ids) + ->and($logged->ranked)->toBe([$ids[4], $ids[0], $ids[1], $ids[2], $ids[3], $ids[5], $ids[6], $ids[7], $ids[8], $ids[9], $ids[10], $ids[11]]) + ->and($logged->features)->toHaveCount(12) + ->and($logged->features[0])->toMatchArray(['id' => $ids[4], 'pos' => 1, 'orig' => 5, 'boost' => 1.0, 'src' => 'organic']); +}); + +it('logs nothing and stamps nothing in off mode', function () { + Config::set('lunar.search_relevance.mode', 'off'); + Fixtures::products(3); + + $results = Search::model(Product::class)->query('cable')->perPage(2)->get(); + + expect($results->meta)->toBe([]) + ->and($results->hits[0]->meta)->toBe([]) + ->and($results->hits)->toHaveCount(2) + ->and(SearchQuery::query()->count())->toBe(0); +}); + +it('ignores models that are not configured for ranking', function () { + Config::set('lunar.search_relevance.models', []); + Fixtures::products(3); + + $results = Search::model(Product::class)->query('cable')->get(); + + expect($results->meta)->toBe([]) + ->and(SearchQuery::query()->count())->toBe(0); +}); + +it('does not rank a sorted search but still logs it', function () { + $products = Fixtures::products(12); + learn($products[4]->id); + + $results = Search::model(Product::class)->query('cable')->sort('created_at:asc')->perPage(10)->get(); + + expect(ids($results))->toBe(collect($products)->pluck('id')->slice(0, 10)->values()->all()) + ->and($results->meta['search_id'])->toHaveLength(26) + ->and(SearchQuery::query()->find($results->meta['search_id'])->ranked)->toBeNull(); +}); + +it('unions learned products the engine missed at the head of the second bucket', function () { + Config::set('lunar.search_relevance.mode', 'on'); + Config::set('lunar.search_relevance.bucket_size', 3); + $cables = Fixtures::products(5); + $hammer = Fixtures::products(1, 'hammer')[0]; + $weak = Fixtures::products(1, 'spanner')[0]; + learn($hammer->id, 0.8); + learn($weak->id, 0.05); + + $results = Search::model(Product::class)->query('cable')->perPage(10)->get(); + + $ids = collect($cables)->pluck('id')->all(); + + expect(ids($results))->toBe([$ids[0], $ids[1], $ids[2], $hammer->id, $ids[3], $ids[4]]) + ->and($results->count)->toBe(6) + ->and($results->hits[3]->meta)->toMatchArray(['position' => 4, 'original_position' => 4, 'boost' => 0.8, 'source' => 'learned']) + ->and(collect($results->hits[3]->document)->contains('hammer 1'))->toBeTrue(); +}); + +it('keeps the engine order in shadow mode even when a learned product is unioned', function () { + Config::set('lunar.search_relevance.bucket_size', 3); + $cables = Fixtures::products(5); + $hammer = Fixtures::products(1, 'hammer')[0]; + learn($hammer->id); + + $results = Search::model(Product::class)->query('cable')->perPage(10)->get(); + + $ids = collect($cables)->pluck('id')->all(); + + expect(ids($results))->toBe($ids) + ->and(SearchQuery::query()->find($results->meta['search_id'])->ranked)->toBe([$ids[0], $ids[1], $ids[2], $hammer->id, $ids[3], $ids[4]]); +}); + +it('serves the ranked window from the cache on repeat searches', function () { + Config::set('lunar.search_relevance.mode', 'on'); + $products = Fixtures::products(4); + + $first = Search::model(Product::class)->query('cable')->perPage(10)->get(); + learn($products[2]->id); + $second = Search::model(Product::class)->query('cable')->perPage(10)->get(); + + expect(ids($second))->toBe(ids($first)) + ->and($second->meta['search_id'])->not->toBe($first->meta['search_id']) + ->and(SearchQuery::query()->count())->toBe(2); +}); + +it('does not widen or rank a part-number search on the database engine', function () { + $products = Fixtures::products(3, 'ab12'); + + $results = Search::model(Product::class)->query('ab12')->perPage(2)->get(); + + expect($results->hits)->toHaveCount(2) + ->and($results->totalPages)->toBe(2) + ->and($results->meta['search_id'])->toHaveLength(26) + ->and(SearchQuery::query()->find($results->meta['search_id']))->toMatchArray([ + 'normalised_query' => 'ab12', + 'ranked' => null, + 'shown' => [$products[0]->id, $products[1]->id], + ]); +}); + +it('restricts a part-number search to the sku fields on typesense', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + + $engine = fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([1, 2])); + + $results = Search::model(Product::class)->query('HAG-MB-32A')->get(); + + expect($engine->getParams())->toBe([ + 'query_by' => 'skus,skus_normalised', + 'query_by_weights' => null, + 'prefix' => true, + 'infix' => 'always,always', + 'num_typos' => '0,0', + 'drop_tokens_threshold' => 0, + 'vector_query' => null, + ]) + ->and($engine->getPerPage())->toBe(50) + ->and($results->meta['ranking_version'])->toBe('n1:typesense:keyword') + ->and(SearchQuery::query()->find($results->meta['search_id'])->ranked)->toBeNull(); +}); + +it('restricts a part-number search to the sku fields on meilisearch', function () { + Config::set('scout.driver', 'meilisearch'); + Config::set('lunar.search.engine_map', [Product::class => 'meilisearch']); + + $engine = fakeEngine(MeilisearchEngine::class, 'meilisearch', [ + 'hits' => [['id' => '1', '_rankingScore' => 0.9]], + 'query' => 'hagmb32', + 'facetDistribution' => [], + ]); + + Search::model(Product::class)->query('hagmb32')->get(); + + expect($engine->getParams())->toBe([ + 'attributesToSearchOn' => ['skus', 'skus_normalised'], + 'matchingStrategy' => 'all', + ]); +}); + +it('turns semantic search off for part numbers on meilisearch when an embedder is configured', function () { + Config::set('scout.driver', 'meilisearch'); + Config::set('lunar.search.engine_map', [Product::class => 'meilisearch']); + Config::set('lunar.search.meilisearch.embedder', 'default'); + + $engine = fakeEngine(MeilisearchEngine::class, 'meilisearch', [ + 'hits' => [], + 'query' => 'hagmb32', + 'facetDistribution' => [], + ]); + + Search::model(Product::class)->query('hagmb32')->get(); + + expect($engine->getParams()['hybrid'])->toBe(['semanticRatio' => 0]); +}); + +it('widens, ranks and unions through a typesense engine', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + Config::set('lunar.search_relevance.mode', 'on'); + Config::set('lunar.search_relevance.bucket_size', 3); + Config::set('lunar.search_relevance.window', 100); + learn(9, 1.0, version: 'n1:typesense:keyword'); + learn(5, 0.5, version: 'n1:typesense:keyword'); + + $engine = fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([1, 2, 3, 4, 5]), typesenseHits([9, 42])); + + $results = Search::model(Product::class)->query('cable')->perPage(2)->page(2)->get(); + + expect($engine->getPerPage())->toBe(100) + ->and($engine->getPage())->toBe(1) + // Window after union and ranking: [1,2,3] [9,5,4]; page two of two is [3, 9]. + ->and(ids($results))->toBe([3, 9]) + ->and($results->count)->toBe(6) + ->and($results->totalPages)->toBe(3) + ->and($results->hits[0]->meta)->toBe(['score' => 97.0, 'position' => 3, 'original_position' => 3, 'boost' => 0.0, 'source' => 'organic']) + ->and($results->hits[1]->meta)->toMatchArray(['position' => 4, 'original_position' => 4, 'boost' => 1.0, 'source' => 'learned']); + + $logged = SearchQuery::query()->find($results->meta['search_id']); + + expect($logged->shown)->toBe([1, 2, 3, 9, 5, 4]) + ->and($logged->version)->toBe('n1:typesense:keyword'); +}); + +it('restricts a part-number search to the exact-match fields the indexer declares', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + Config::set('lunar.search.indexers', [Product::class => SupplierCodeIndexer::class]); + + $engine = fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([1])); + + Search::model(Product::class)->query('FTP25')->get(); + + expect($engine->getParams())->toMatchArray([ + 'query_by' => 'skus,skus_normalised,mpns,eans', + 'infix' => 'always,always,always,always', + 'num_typos' => '0,0,0,0', + ]); +}); + +it('searches a part number as usual when the indexer declares no exact-match fields', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + Config::set('lunar.search.indexers', [Product::class => SupplierCodeIndexer::class]); + app()->bind(SupplierCodeIndexer::class, fn () => new SupplierCodeIndexer([])); + + $engine = fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([1, 2])); + + Search::model(Product::class)->query('FTP25')->get(); + + expect($engine->getParams())->toBe([]) + ->and($engine->getPerPage())->toBe(250); +}); + +it('leaves part-number searches alone when relevance is off', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + Config::set('lunar.search_relevance.mode', 'off'); + + $engine = fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([1])); + + $results = Search::model(Product::class)->query('HAG-MB-32A')->get(); + + expect($engine->getParams())->toBe([]) + ->and($results->meta)->not->toHaveKey('search_id') + ->and(SearchQuery::query()->count())->toBe(0); +}); + +it('reruns a part-number search that matches nothing as an ordinary search', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + + fakeEngine(TypesenseEngine::class, 'typesense', ['hits' => [], 'facet_counts' => []], typesenseHits([7, 8])); + + $results = Search::model(Product::class)->query('FTP25')->perPage(10)->get(); + + expect(ids($results))->toBe([7, 8]) + ->and(SearchQuery::query()->count())->toBe(1) + ->and(SearchQuery::query()->find($results->meta['search_id'])->shown)->toBe([7, 8]); +}); + +it('keeps the part-number results when the exact-match fields find something', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + + fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([3]), typesenseHits([7, 8])); + + $results = Search::model(Product::class)->query('FTP25')->get(); + + expect(ids($results))->toBe([3]) + ->and(SearchQuery::query()->count())->toBe(1); +}); + +it('registers the part-number fallback ahead of the ranking stage', function () { + $stages = config('lunar.search.pipelines.results'); + + expect(array_search(PartNumberFallback::class, $stages, true)) + ->toBeLessThan(array_search(RankResults::class, $stages, true)); +}); + +it('runs a search without either pipeline when asked', function () { + Fixtures::products(3); + + $results = Search::model(Product::class)->query('cable')->perPage(2)->withoutPipelines()->get(); + + expect($results->hits)->toHaveCount(2) + ->and($results->meta)->not->toHaveKey('search_id') + ->and(SearchQuery::query()->count())->toBe(0); +}); + +it('ranks a search sorted by relevance like an unsorted one', function (string $sort, int $perPage) { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + + $engine = fakeEngine(TypesenseEngine::class, 'typesense', typesenseHits([1, 2, 3])); + + Search::model(Product::class)->query('cable')->sort($sort)->perPage(2)->get(); + + expect($engine->getPerPage())->toBe($perPage); +})->with([ + 'relevance' => ['relevance:asc', 250], + 'text match' => ['_text_match:desc', 250], + 'a shopper sort' => ['price:asc', 2], +]); + +it('reports the engine total when a search matches more than the window', function () { + Config::set('lunar.search_relevance.window', 10); + Fixtures::products(30); + + $results = Search::model(Product::class)->query('cable')->perPage(5)->get(); + + expect($results->hits)->toHaveCount(5) + ->and($results->count)->toBe(30) + ->and($results->totalPages)->toBe(6) + ->and(SearchQuery::query()->find($results->meta['search_id'])->result_count)->toBe(30); +}); diff --git a/tests/search-relevance/Feature/ScoringGuardsTest.php b/tests/search-relevance/Feature/ScoringGuardsTest.php new file mode 100644 index 0000000000..6d35134b47 --- /dev/null +++ b/tests/search-relevance/Feature/ScoringGuardsTest.php @@ -0,0 +1,91 @@ +group('search-relevance'); + +function guardConfig(array $overrides = []): array +{ + return [...ScoringFixture::config(), 'min_sessions' => 1, ...$overrides]; +} + +function scoresFor(string $query): array +{ + return SearchQueryScore::query()->where('normalised_query', $query)->orderBy('product_id')->pluck('score', 'product_id')->map(fn ($s) => round((float) $s, 2))->all(); +} + +it('counts one event of each type per session, product and query', function () { + // One session replaying the same click across ten searches is worth one click. + for ($i = 0; $i < 10; $i++) { + ScoringFixture::event(ScoringFixture::search('dup', 'cart:1', [1, 2]), 1, 1, 'click'); + } + ScoringFixture::event(ScoringFixture::search('dup', 'cart:2', [1, 2]), 2, 1, 'click'); + + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig()); + + expect(scoresFor('dup'))->toBe([1 => 1.0, 2 => 1.0]); +}); + +it('ignores sessions without a cart or customer when trusted_sessions_only is set', function () { + foreach (['session:a', 'session:b', 'session:c'] as $session) { + ScoringFixture::event(ScoringFixture::search('trust', $session, [1, 2]), 1, 1, 'click'); + } + ScoringFixture::event(ScoringFixture::search('trust', 'cart:9', [1, 2]), 2, 1, 'click'); + $known = ScoringFixture::search('trust', 'session:d', [1, 2]); + $known->forceFill(['customer_id' => 42])->save(); + ScoringFixture::event($known, 2, 1, 'click'); + + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig(['trusted_sessions_only' => true])); + + expect(scoresFor('trust'))->toBe([2 => 2.0]); + + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig(['trusted_sessions_only' => false])); + + expect(scoresFor('trust'))->toBe([1 => 3.0, 2 => 2.0]); +}); + +it('never learns an excluded product and drops its score immediately', function () { + foreach (['cart:1', 'cart:2'] as $session) { + $search = ScoringFixture::search('ex', $session, [1, 2]); + ScoringFixture::event($search, 1, 1, 'click'); + ScoringFixture::event($search, 2, 2, 'click'); + } + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig()); + expect(array_keys(scoresFor('ex')))->toBe([1, 2]); + + app(Overrides::class)->exclude(Product::class, 'ex', 1); + + expect(array_keys(scoresFor('ex')))->toBe([2]) + ->and(app(Overrides::class)->excluded(Product::class, 'ex')->all())->toBe([1]); + + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig()); + expect(array_keys(scoresFor('ex')))->toBe([2]); + + app(Overrides::class)->include(Product::class, 'ex', 1); + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig()); + expect(array_keys(scoresFor('ex')))->toBe([1, 2]); +}); + +it('discards everything learned before a reset', function () { + ScoringFixture::event(ScoringFixture::search('rs', 'cart:1', [1, 2]), 1, 1, 'click', at: now()->subHour()); + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig()); + expect(array_keys(scoresFor('rs')))->toBe([1]); + + $this->travel(1)->minutes(); + app(Overrides::class)->reset(Product::class, 'rs'); + expect(scoresFor('rs'))->toBe([]) + ->and(app(Overrides::class)->resetAt(Product::class, 'rs'))->not->toBeNull() + ->and(LearningOverride::query()->where('type', 'reset')->count())->toBe(1); + + $this->travel(1)->minutes(); + ScoringFixture::event(ScoringFixture::search('rs', 'cart:2', [1, 2]), 2, 1, 'click'); + app(PhpScoreAggregator::class)->aggregate(ScoringFixture::VERSION, guardConfig()); + + expect(array_keys(scoresFor('rs')))->toBe([2]); +}); diff --git a/tests/search-relevance/Feature/SqlScoreAggregatorTest.php b/tests/search-relevance/Feature/SqlScoreAggregatorTest.php new file mode 100644 index 0000000000..402a7e5601 --- /dev/null +++ b/tests/search-relevance/Feature/SqlScoreAggregatorTest.php @@ -0,0 +1,21 @@ +group('search-relevance', 'cross-db'); + +$driver = env('DB_DRIVER', 'sqlite'); + +it('aggregates events into scores with the sql aggregator', function () use ($driver) { + $aggregator = app(ScoreAggregator::class); + + expect($aggregator)->toBeInstanceOf($driver === 'pgsql' ? PostgresScoreAggregator::class : MySqlScoreAggregator::class); + + ScoringFixture::seed(); + + ScoringFixture::assertScores($aggregator); +})->skip(! in_array($driver, ['mysql', 'pgsql'], true), 'Needs DB_DRIVER=mysql or pgsql'); diff --git a/tests/search-relevance/Feature/TrackingTest.php b/tests/search-relevance/Feature/TrackingTest.php new file mode 100644 index 0000000000..dd09f5b27d --- /dev/null +++ b/tests/search-relevance/Feature/TrackingTest.php @@ -0,0 +1,53 @@ +group('search-relevance'); + +function trackedResults(array $meta): SearchResults +{ + return SearchResults::from([ + 'query' => 'cable', + 'count' => 1, + 'page' => 1, + 'perPage' => 10, + 'totalPages' => 1, + 'hits' => [SearchHit::from(['highlights' => [], 'document' => ['id' => '7'], 'meta' => ['position' => 3, 'source' => 'learned']])], + 'facets' => [], + 'links' => (new LengthAwarePaginator([], 1, 10, 1))->links(), + 'meta' => $meta, + ]); +} + +it('renders the tracking attributes for a logged search', function () { + $results = trackedResults(['search_id' => '01ARZ3NDEKTSV4RRFFQ69G5FAV']); + + expect((string) lunar_search_attrs($results, $results->hits[0])) + ->toBe('data-lunar-search-id="01ARZ3NDEKTSV4RRFFQ69G5FAV" data-lunar-product-id="7" data-lunar-position="3" data-lunar-source="learned"'); +}); + +it('renders nothing when the search was not logged', function () { + $results = trackedResults([]); + + expect((string) lunar_search_attrs($results, $results->hits[0]))->toBe('') + ->and(trim(Blade::render('', ['results' => $results])))->toBe(''); +}); + +it('renders the beacon script once per results page', function () { + $results = trackedResults(['search_id' => '01ARZ3NDEKTSV4RRFFQ69G5FAV']); + + $html = Blade::render('', ['results' => $results]); + + // The script body is the shared client's IIFE build, inlined so Blade and + // headless storefronts run the same code. + expect($html)->toContain('data-lunar-search-tracking="01ARZ3NDEKTSV4RRFFQ69G5FAV"') + ->toContain('LunarSearchRelevance.attach({ endpoint: '.json_encode(route('lunar.search-relevance.events'))) + ->toContain('navigator.sendBeacon') + ->toContain('keepalive: true') + ->toContain('data-lunar-search-id') + ->toContain('_token'); +}); diff --git a/tests/search-relevance/PanelTestCase.php b/tests/search-relevance/PanelTestCase.php new file mode 100644 index 0000000000..46b7bdca33 --- /dev/null +++ b/tests/search-relevance/PanelTestCase.php @@ -0,0 +1,45 @@ +set('scout.driver', 'database'); + $app['config']->set('queue.default', 'sync'); + + $app['config']->set('inertia.pages.paths', [ + ...$app['config']->get('inertia.pages.paths', []), + dirname(__DIR__, 2).'/packages/search-relevance/resources/js/pages', + ]); + } +} diff --git a/tests/search-relevance/Support/Fixtures.php b/tests/search-relevance/Support/Fixtures.php new file mode 100644 index 0000000000..5df1dbb1af --- /dev/null +++ b/tests/search-relevance/Support/Fixtures.php @@ -0,0 +1,44 @@ +create(['default' => true, 'code' => 'en']); + Channel::factory()->create(['default' => true]); + Currency::factory()->create(['default' => true]); + CustomerGroup::factory()->create(['default' => true]); + } + + /** Route Product through the Database engine with a name-only Scout driver. */ + public static function databaseEngine(): void + { + Config::set('scout.driver', 'database'); + Config::set('lunar.search.engine_map', [Product::class => 'database']); + + app(EngineManager::class)->extend('database', fn () => new NameSearchScoutEngine); + } + + /** @return array in creation order */ + public static function products(int $count, string $prefix = 'cable'): array + { + $products = []; + + for ($i = 1; $i <= $count; $i++) { + $products[] = Product::factory()->create(['name' => collect(['en' => "{$prefix} {$i}"])]); + } + + return $products; + } +} diff --git a/tests/search-relevance/Support/MigrationState.php b/tests/search-relevance/Support/MigrationState.php new file mode 100644 index 0000000000..00942c9654 --- /dev/null +++ b/tests/search-relevance/Support/MigrationState.php @@ -0,0 +1,27 @@ +model->newQuery()->orderBy($builder->model->getKeyName()); + + if (filled($builder->query)) { + $query->where('name', 'like', '%'.$builder->query.'%'); + } + + return $this->constrainForSoftDeletes( + $builder, $this->addAdditionalConstraints($builder, $query->take($builder->limit)) + ); + } +} diff --git a/tests/search-relevance/Support/ScoringFixture.php b/tests/search-relevance/Support/ScoringFixture.php new file mode 100644 index 0000000000..864d7e17c3 --- /dev/null +++ b/tests/search-relevance/Support/ScoringFixture.php @@ -0,0 +1,175 @@ + */ + public static function config(): array + { + return [ + 'weights' => ['click' => 1, 'basket' => 3, 'purchase' => 5], + 'position_eta' => 0.7, + 'max_position_weight' => 5, + 'half_life_days' => 30, + 'window_days' => 180, + 'min_sessions' => 3, + 'max_products_per_query' => 50, + 'max_searches_per_minute' => 30, + 'keep_versions' => [self::VERSION, 'keep'], + ]; + } + + public static function seed(): void + { + // Weights: clicks, baskets and purchases from three sessions each. + foreach (['s1', 's2', 's3'] as $session) { + $search = self::search('w', $session, [1, 2, 3]); + self::event($search, 1, 1, 'click'); + self::event($search, 2, 1, 'purchase'); + self::event($search, 3, 1, 'basket'); + } + + // Position correction: pos 4 counts 4^0.7, pos 20 is capped at 5, explore is not corrected. + foreach (['s1', 's2', 's3'] as $session) { + $search = self::search('p', $session, range(1, 20)); + self::event($search, 1, 1, 'click'); + self::event($search, 2, 4, 'click'); + self::event($search, 3, 20, 'click'); + self::event($search, 4, 20, 'click', source: 'explore'); + } + + // Half-life: a click 30 days old is worth half. + foreach (['s1', 's2', 's3'] as $session) { + $search = self::search('d', $session, [1, 2]); + self::event($search, 1, 1, 'click'); + self::event($search, 2, 1, 'click', at: now()->subDays(30)); + } + + // Minimum sessions: two sessions, or one session clicking five times, is not enough. + foreach (['s1', 's2', 's3'] as $index => $session) { + $search = self::search('m', $session, [1, 2, 3]); + self::event($search, 2, 1, 'click'); + + if ($index < 2) { + self::event($search, 1, 1, 'click'); + } + } + for ($i = 0; $i < 5; $i++) { + self::event(self::search('m', 'loner', [1, 2, 3]), 3, 1, 'click'); + } + + // Version: events logged under another retrieval version are ignored. + foreach (['s1', 's2', 's3'] as $session) { + $search = self::search('v', $session, [1], version: 'n0:database:keyword'); + self::event($search, 1, 1, 'click'); + } + + // Window: events older than window_days are ignored. + foreach (['s1', 's2', 's3'] as $session) { + $search = self::search('old', $session, [1], at: now()->subDays(181)); + self::event($search, 1, 1, 'click', at: now()->subDays(181)); + } + + // Guard: a session searching 31 times within one minute is ignored; 31 across two minutes is fine. + $minute = now()->startOfMinute()->addSeconds(5); + for ($i = 0; $i < 31; $i++) { + $search = self::search('g', 'busy', [1, 2], at: $minute->copy()->addSeconds($i)); + } + self::event($search, 1, 1, 'click'); + for ($i = 0; $i < 31; $i++) { + $search = self::search('g', 'steady', [1, 2], at: $minute->copy()->subMinute()->addSeconds($i * 3)); + } + self::event($search, 2, 1, 'click'); + foreach (['s1', 's2'] as $session) { + $search = self::search('g', $session, [1, 2]); + self::event($search, 1, 1, 'click'); + self::event($search, 2, 1, 'click'); + } + + // Stale rows: another version is swept, a kept version survives, an unrefreshed current row goes. + SearchQueryScore::factory()->create(['normalised_query' => 'gone', 'product_id' => 1, 'version' => 'n0:database:keyword']); + SearchQueryScore::factory()->create(['normalised_query' => 'kept', 'product_id' => 1, 'version' => 'keep']); + SearchQueryScore::factory()->create(['normalised_query' => 'stale', 'product_id' => 1, 'version' => self::VERSION, 'updated_at' => now()->subDay()]); + } + + public static function assertScores(ScoreAggregator $aggregator): void + { + $written = $aggregator->aggregate(self::VERSION, self::config()); + + $rows = SearchQueryScore::query()->get() + ->groupBy('normalised_query') + ->map(fn ($group) => $group->keyBy('product_id')); + + expect($written)->toBe(11) + ->and($rows->keys()->sort()->values()->all())->toBe(['d', 'g', 'kept', 'm', 'p', 'w']); + + expect($rows['w'][2]->score)->toEqualWithDelta(15.0, 0.01) + ->and($rows['w'][3]->score)->toEqualWithDelta(9.0, 0.01) + ->and($rows['w'][1]->score)->toEqualWithDelta(3.0, 0.01) + ->and($rows['w'][2]->relative)->toEqualWithDelta(1.0, 0.001) + ->and($rows['w'][3]->relative)->toEqualWithDelta(0.6, 0.001) + ->and($rows['w'][1]->relative)->toEqualWithDelta(0.2, 0.001) + ->and($rows['w'][1]->sessions)->toBe(3) + ->and($rows['w'][1]->version)->toBe(self::VERSION); + + expect($rows['p'][1]->score)->toEqualWithDelta(3.0, 0.01) + ->and($rows['p'][2]->score)->toEqualWithDelta(3 * pow(4, 0.7), 0.01) + ->and($rows['p'][3]->score)->toEqualWithDelta(15.0, 0.01) + ->and($rows['p'][4]->score)->toEqualWithDelta(3.0, 0.01); + + expect($rows['d'][1]->relative)->toEqualWithDelta(1.0, 0.001) + ->and($rows['d'][2]->relative)->toEqualWithDelta(0.5, 0.001); + + expect($rows['m']->keys()->all())->toBe([2]); + + expect($rows['g']->keys()->all())->toBe([2]) + ->and($rows['g'][2]->sessions)->toBe(3); + + expect($rows->has('v'))->toBeFalse() + ->and($rows->has('old'))->toBeFalse() + ->and($rows->has('gone'))->toBeFalse() + ->and($rows->has('stale'))->toBeFalse() + ->and($rows['kept'][1]->version)->toBe('keep'); + } + + public static function search(string $query, string $session, array $shown, ?Carbon $at = null, string $version = self::VERSION): SearchQuery + { + return SearchQuery::factory()->create([ + 'model_type' => Product::class, + 'raw_query' => $query, + 'normalised_query' => $query, + 'session_id' => $session, + 'shown' => $shown, + 'result_count' => count($shown), + 'version' => $version, + 'created_at' => $at ?? now(), + ]); + } + + public static function event(SearchQuery $search, int $productId, int $position, string $type, ?Carbon $at = null, string $source = 'organic'): SearchEvent + { + return SearchEvent::factory()->create([ + 'search_id' => $search->id, + 'product_id' => $productId, + 'position' => $position, + 'type' => $type, + 'source' => $source, + 'session_id' => $search->session_id, + 'created_at' => $at ?? now(), + ]); + } +} diff --git a/tests/search-relevance/Support/SupplierCodeIndexer.php b/tests/search-relevance/Support/SupplierCodeIndexer.php new file mode 100644 index 0000000000..3ee1cbd3bc --- /dev/null +++ b/tests/search-relevance/Support/SupplierCodeIndexer.php @@ -0,0 +1,16 @@ +exactMatchFields; + } +} diff --git a/tests/search-relevance/TestCase.php b/tests/search-relevance/TestCase.php new file mode 100644 index 0000000000..7c6cd01bbf --- /dev/null +++ b/tests/search-relevance/TestCase.php @@ -0,0 +1,57 @@ +disableLogging(); + } + + protected function getPackageProviders($app): array + { + return [ + LunarServiceProvider::class, + MediaLibraryServiceProvider::class, + NestedSetServiceProvider::class, + BlinkServiceProvider::class, + ActivitylogServiceProvider::class, + LaravelDataServiceProvider::class, + ScoutServiceProvider::class, + SearchServiceProvider::class, + SearchRelevanceServiceProvider::class, + PermissionServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + parent::getEnvironmentSetUp($app); + + $app['config']->set('auth.providers.users.model', User::class); + $app['config']->set('scout.driver', 'database'); + $app['config']->set('queue.default', 'sync'); + } +} diff --git a/tests/search-relevance/Unit/BucketedRankerTest.php b/tests/search-relevance/Unit/BucketedRankerTest.php new file mode 100644 index 0000000000..8860280100 --- /dev/null +++ b/tests/search-relevance/Unit/BucketedRankerTest.php @@ -0,0 +1,92 @@ +group('search-relevance'); + +final class BoostSignal implements Signal +{ + /** @param array $scores */ + public function __construct(private array $scores) {} + + public function scores(RankingContext $context, array $productIds): array + { + return array_intersect_key($this->scores, array_flip($productIds)); + } +} + +function rankerContext(string $mode = 'on', ?string $sort = null, string $query = 'cable tie'): RankingContext +{ + return new RankingContext(Product::class, $query, 'session:x', null, $mode, $sort, md5('[]'), 'n1:database:keyword'); +} + +function hitsFor(array $ids): HitCollection +{ + return new HitCollection(array_map(fn ($id, $i) => new Hit($id, $i + 1, 0.0, ['id' => $id]), $ids, array_keys($ids))); +} + +function bucketedRanker(array $scores, int $bucketSize = 3): BucketedRanker +{ + $container = new Container; + $container->bind('signal', fn () => new BoostSignal($scores)); + + return new BucketedRanker( + new SignalCombiner(['signal' => 1.0], $container), + new Repository(['lunar' => ['search_relevance' => ['bucket_size' => $bucketSize]]]), + ); +} + +it('reorders within buckets only, preserving engine order on ties', function () { + $ranker = bucketedRanker([6 => 1.0, 3 => 0.5, 2 => 0.5]); + + $ranked = $ranker->rank(rankerContext(), hitsFor([1, 2, 3, 4, 5, 6, 7])); + + // Bucket one: 2 and 3 tie at 0.5 so engine order decides; bucket two: 6 wins; 7 alone. + expect($ranked->productIds())->toBe([2, 3, 1, 6, 4, 5, 7]) + ->and($ranked->first()->boost)->toBe(0.5) + ->and($ranked[2]->boost)->toBe(0.0); +}); + +it('leaves the order alone when the context says not to rank', function () { + $ranker = bucketedRanker([3 => 1.0]); + + expect($ranker->rank(rankerContext(mode: 'off'), hitsFor([1, 2, 3]))->productIds())->toBe([1, 2, 3]) + ->and($ranker->rank(rankerContext(sort: 'price:asc'), hitsFor([1, 2, 3]))->productIds())->toBe([1, 2, 3]) + ->and($ranker->rank(rankerContext(query: '*'), hitsFor([1, 2, 3]))->productIds())->toBe([1, 2, 3]) + ->and($ranker->rank(rankerContext(), hitsFor([]))->productIds())->toBe([]); +}); + +it('ranks in shadow mode so the ranked order can be logged', function () { + $ranker = bucketedRanker([3 => 1.0]); + + expect($ranker->rank(rankerContext(mode: 'shadow'), hitsFor([1, 2, 3]))->productIds())->toBe([3, 1, 2]); +}); + +it('the null ranker returns the hits untouched', function () { + $hits = hitsFor([1, 2, 3]); + + expect((new NullRanker)->rank(rankerContext(), $hits))->toBe($hits); +}); + +it('round-trips hits through arrays for the cache', function () { + $hit = new Hit(5, 2, 1.5, ['id' => 5], 'learned'); + $hit->boost = 0.4; + + $restored = Hit::fromArray($hit->toArray()); + + expect($restored->productId)->toBe(5) + ->and($restored->originalPosition)->toBe(2) + ->and($restored->score)->toBe(1.5) + ->and($restored->source)->toBe('learned') + ->and($restored->boost)->toBe(0.4) + ->and(HitCollection::fromArrays((new HitCollection([$hit]))->toArrays())->productIds())->toBe([5]); +}); diff --git a/tests/search-relevance/Unit/DefaultQueryNormaliserTest.php b/tests/search-relevance/Unit/DefaultQueryNormaliserTest.php new file mode 100644 index 0000000000..1057304c9c --- /dev/null +++ b/tests/search-relevance/Unit/DefaultQueryNormaliserTest.php @@ -0,0 +1,52 @@ +group('search-relevance'); + +beforeEach(function () { + $this->normaliser = new DefaultQueryNormaliser; +}); + +it('lowercases, trims and collapses whitespace', function () { + expect($this->normaliser->normalise(' Cable Gland '))->toBe('cable gland'); +}); + +it('joins a number to its unit', function () { + expect($this->normaliser->normalise('20 mm gland'))->toBe('20mm gland') + ->and($this->normaliser->normalise('32 A breaker'))->toBe('32a breaker'); +}); + +it('strips punctuation but keeps part-number separators', function () { + expect($this->normaliser->normalise('cable, ties!'))->toBe('cable tie') + ->and($this->normaliser->normalise('HAG-MB-32A'))->toBe('hag-mb-32a') + ->and($this->normaliser->normalise('AB/12.5'))->toBe('ab/12.5'); +}); + +it('applies light plural stemming', function () { + expect($this->normaliser->normalise('cable ties'))->toBe('cable tie') + ->and($this->normaliser->normalise('boxes'))->toBe('box') + ->and($this->normaliser->normalise('switches'))->toBe('switch') + ->and($this->normaliser->normalise('glands'))->toBe('gland') + ->and($this->normaliser->normalise('glass'))->toBe('glass'); +}); + +it('never stems a part number', function () { + expect($this->normaliser->normalise('MCB32S'))->toBe('mcb32s'); +}); + +it('classifies part numbers', function (string $query, bool $expected) { + expect($this->normaliser->isPartNumber($query))->toBe($expected); +})->with([ + ['HAG-MB-32A', true], + ['hagmb32', true], + ['ab/12.5', true], + ['20mm', false], + ['2.5mm', false], + ['32a', false], + ['cable-gland', false], + ['cable tie', false], + ['12345', false], + ['', false], + ['hag mb32', false], +]); diff --git a/tests/search-relevance/Unit/RetrievalVersionTest.php b/tests/search-relevance/Unit/RetrievalVersionTest.php new file mode 100644 index 0000000000..a9b41ed915 --- /dev/null +++ b/tests/search-relevance/Unit/RetrievalVersionTest.php @@ -0,0 +1,54 @@ +group('search-relevance'); + +function retrievalVersion(array $config): RetrievalVersion +{ + return new RetrievalVersion(new Repository($config)); +} + +it('combines the normaliser version, driver and retrieval kind', function () { + $version = retrievalVersion([ + 'lunar' => ['search_relevance' => ['normaliser_version' => 2]], + 'scout' => ['driver' => 'database'], + ]); + + expect($version->current(Product::class))->toBe('n2:database:keyword'); +}); + +it('reads the driver from the engine map before the scout default', function () { + $version = retrievalVersion([ + 'lunar' => ['search_relevance' => ['normaliser_version' => 1], 'search' => ['engine_map' => [Product::class => 'typesense']]], + 'scout' => ['driver' => 'database'], + ]); + + expect($version->current(Product::class))->toBe('n1:typesense:keyword'); +}); + +it('is hybrid when the typesense schema declares an embedding field', function () { + $version = retrievalVersion([ + 'lunar' => ['search_relevance' => ['normaliser_version' => 1]], + 'scout' => [ + 'driver' => 'typesense', + 'typesense' => ['model-settings' => [Product::class => ['collection-schema' => ['fields' => [ + ['name' => 'name', 'type' => 'string'], + ['name' => 'embedding', 'type' => 'float[]'], + ]]]]], + ], + ]); + + expect($version->current(Product::class))->toBe('n1:typesense:hybrid'); +}); + +it('is hybrid when meilisearch has an embedder', function () { + $version = retrievalVersion([ + 'lunar' => ['search_relevance' => ['normaliser_version' => 1], 'search' => ['meilisearch' => ['embedder' => 'default']]], + 'scout' => ['driver' => 'meilisearch'], + ]); + + expect($version->current(Product::class))->toBe('n1:meilisearch:hybrid'); +}); diff --git a/tests/search-relevance/Unit/SignalCombinerTest.php b/tests/search-relevance/Unit/SignalCombinerTest.php new file mode 100644 index 0000000000..536116a4f3 --- /dev/null +++ b/tests/search-relevance/Unit/SignalCombinerTest.php @@ -0,0 +1,49 @@ +group('search-relevance'); + +function relevanceContext(string $mode = 'on', ?string $sort = null, string $query = 'cable tie'): RankingContext +{ + return new RankingContext(Product::class, $query, 'session:x', null, $mode, $sort, md5('[]'), 'n1:database:keyword'); +} + +final class FixedSignal implements Signal +{ + /** @param array $scores */ + public function __construct(private array $scores) {} + + public function scores(RankingContext $context, array $productIds): array + { + return array_intersect_key($this->scores, array_flip($productIds)); + } +} + +it('sums weighted signals and clamps to 0..1', function () { + $container = new Container; + $container->bind('signal.a', fn () => new FixedSignal([1 => 0.5, 2 => 0.9, 3 => 0.2])); + $container->bind('signal.b', fn () => new FixedSignal([1 => 0.5, 2 => 0.9, 4 => -2.0])); + + $combiner = new SignalCombiner(['signal.a' => 1.0, 'signal.b' => 0.5], $container); + + expect($combiner->combine(relevanceContext(), [1, 2, 3, 4]))->toBe([ + 1 => 0.75, + 2 => 1.0, + 3 => 0.2, + 4 => 0.0, + ]); +}); + +it('only scores the products asked for', function () { + $container = new Container; + $container->bind('signal.a', fn () => new FixedSignal([1 => 0.5, 2 => 0.9])); + + $combiner = new SignalCombiner(['signal.a' => 1.0], $container); + + expect($combiner->combine(relevanceContext(), [2]))->toBe([2 => 0.9]); +}); diff --git a/tests/search/Feature/Engines/TypesenseEngineTest.php b/tests/search/Feature/Engines/TypesenseEngineTest.php index ee0af4a6bd..203e7fc449 100644 --- a/tests/search/Feature/Engines/TypesenseEngineTest.php +++ b/tests/search/Feature/Engines/TypesenseEngineTest.php @@ -352,6 +352,12 @@ public function exposedStripListEntry(mixed $list, int $index): mixed [['name' => 'name', 'type' => 'string'], ['name' => 'embedding', 'type' => 'float[]']] ); + expect(typesenseTestEngine()->query('shoes')->exposedBuildSearch($options)[0]['vector_query']) + ->toBe('embedding:([], k: 200, distance_threshold: 0.6)'); + + // A zero threshold sends the bare vector query. + Config::set('lunar.search.typesense.vector_distance_threshold', 0); + expect(typesenseTestEngine()->query('shoes')->exposedBuildSearch($options)[0]['vector_query']) ->toBe('embedding:([], k: 200)'); @@ -534,3 +540,41 @@ public function exposedStripListEntry(mixed $list, int $index): mixed expect($filters) ->toContain('colour:=`Red`'); }); + +it('merges withParams() overrides into the request last and drops null keys', function () { + $options = ['query_by' => 'name, embedding', 'filter_by' => []]; + + Config::set( + 'scout.typesense.model-settings.'.Product::class.'.collection-schema.fields', + [['name' => 'name', 'type' => 'string'], ['name' => 'embedding', 'type' => 'float[]']] + ); + + $request = typesenseTestEngine() + ->query('HAGMB32A') + ->withParams([ + 'query_by' => 'skus,skus_normalised', + 'num_typos' => 0, + 'vector_query' => null, + ]) + ->exposedBuildSearch($options)[0]; + + expect($request['query_by'])->toBe('skus,skus_normalised') + ->and($request['num_typos'])->toBe(0) + ->and($request)->not->toHaveKey('vector_query'); +}); + +it('exposes the text match score on each hit', function () { + mockTypesenseWithResponse([ + 'hits' => [ + ['document' => ['id' => '1', 'name' => 'Foo'], 'text_match' => 578730123365711993], + ['document' => ['id' => '2', 'name' => 'Bar']], + ], + 'facet_counts' => [], + ]); + + $results = Search::model(Product::class)->get(); + + expect($results->hits[0]->meta)->toBe(['score' => 578730123365711993.0]) + ->and($results->hits[1]->meta)->toBe([]) + ->and($results->meta)->toBe([]); +}); diff --git a/tests/search/Feature/Pipelines/SearchPipelinesTest.php b/tests/search/Feature/Pipelines/SearchPipelinesTest.php new file mode 100644 index 0000000000..7539b5f321 --- /dev/null +++ b/tests/search/Feature/Pipelines/SearchPipelinesTest.php @@ -0,0 +1,226 @@ +group('search'); +uses(RefreshDatabase::class); + +/** Records the order stages ran in and what each saw. */ +final class PipelineSpy +{ + /** @var array */ + public static array $log = []; + + public static ?SearchRequest $request = null; + + public static function reset(): void + { + self::$log = []; + self::$request = null; + } +} + +final class FirstRequestStage +{ + public function handle(SearchRequest $request, Closure $next): SearchRequest + { + PipelineSpy::$log[] = 'first'; + $request->context['requested'] = [$request->requestedPage, $request->requestedPerPage]; + PipelineSpy::$request = $request; + + return $next($request); + } +} + +final class WideningRequestStage +{ + public function handle(SearchRequest $request, Closure $next): SearchRequest + { + PipelineSpy::$log[] = 'widen'; + $request->engine->page(1)->perPage(100); + + return $next($request); + } +} + +final class ReverseResultsStage +{ + public function handle(SearchResponse $response, Closure $next): SearchResponse + { + PipelineSpy::$log[] = 'reverse'; + + $results = $response->results; + $response->results = SearchResults::from([ + ...$results->toArray(), + 'hits' => array_reverse($results->hits), + 'facets' => $results->facets, + 'links' => $results->links, + 'meta' => ['reversed' => true, 'context' => $response->request->context], + ]); + + return $next($response); + } +} + +function fakeEngineWithHits(string $class, string $driver, array $items): void +{ + $engine = partialMock($class, function (MockInterface $mock) use ($items) { + $mock->shouldAllowMockingProtectedMethods() + ->shouldReceive('getRawResults') + ->andReturnUsing(fn () => new LengthAwarePaginator( + items: $items, + total: count($items['hits']), + perPage: 50, + currentPage: 1 + )); + }); + + Search::extend($driver, fn () => $engine); +} + +beforeEach(function () { + PipelineSpy::reset(); + Language::factory()->create(['default' => true, 'code' => 'en']); +}); + +it('runs request and results stages in config order for the database engine', function () { + Config::set('scout.driver', 'database'); + Config::set('lunar.search.engine_map', [Product::class => 'database']); + Config::set('lunar.search.pipelines.request', [FirstRequestStage::class, WideningRequestStage::class]); + Config::set('lunar.search.pipelines.results', [ReverseResultsStage::class]); + + Product::factory()->count(3)->create(); + + $results = Search::model(Product::class)->perPage(2)->page(2)->get(); + + expect(PipelineSpy::$log)->toBe(['first', 'widen', 'reverse']) + ->and(PipelineSpy::$request->context['requested'])->toBe([2, 2]) + // The widening stage replaced the requested page with the full window. + ->and($results->hits)->toHaveCount(3) + ->and($results->page)->toBe(1) + ->and($results->meta['reversed'])->toBeTrue() + ->and($results->meta['context']['requested'])->toBe([2, 2]); +}); + +it('honours page() when paginating the database engine', function () { + Config::set('scout.driver', 'database'); + Config::set('lunar.search.engine_map', [Product::class => 'database']); + + Product::factory()->count(3)->create(); + + $results = Search::model(Product::class)->perPage(2)->page(2)->get(); + + expect($results->page)->toBe(2) + ->and($results->hits)->toHaveCount(1) + ->and($results->totalPages)->toBe(2); +}); + +it('runs the pipelines for the typesense engine and lets a stage reorder hits', function () { + Config::set('scout.driver', 'typesense'); + Config::set('lunar.search.engine_map', [Product::class => 'typesense']); + Config::set('lunar.search.pipelines.request', [FirstRequestStage::class]); + Config::set('lunar.search.pipelines.results', [ReverseResultsStage::class]); + + fakeEngineWithHits(TypesenseEngine::class, 'typesense', [ + 'hits' => [ + ['document' => ['id' => '1']], + ['document' => ['id' => '2']], + ], + 'facet_counts' => [], + ]); + + $results = Search::model(Product::class)->get(); + + expect(PipelineSpy::$log)->toBe(['first', 'reverse']) + ->and(collect($results->hits)->map(fn ($hit) => $hit->document['id'])->all())->toBe(['2', '1']) + ->and($results->meta['reversed'])->toBeTrue(); +}); + +it('runs the pipelines for the meilisearch engine and lets a stage reorder hits', function () { + Config::set('scout.driver', 'meilisearch'); + Config::set('lunar.search.engine_map', [Product::class => 'meilisearch']); + Config::set('lunar.search.pipelines.request', [FirstRequestStage::class]); + Config::set('lunar.search.pipelines.results', [ReverseResultsStage::class]); + + fakeEngineWithHits(MeilisearchEngine::class, 'meilisearch', [ + 'hits' => [ + ['id' => '1', '_rankingScore' => 0.9], + ['id' => '2', '_rankingScore' => 0.4], + ], + 'query' => 'foo', + 'facetDistribution' => [], + ]); + + $results = Search::model(Product::class)->query('foo')->get(); + + expect(PipelineSpy::$log)->toBe(['first', 'reverse']) + ->and(collect($results->hits)->map(fn ($hit) => $hit->document['id'])->all())->toBe(['2', '1']) + // The ranking score moves to meta rather than polluting the document. + ->and($results->hits[1]->meta)->toBe(['score' => 0.9]) + ->and($results->hits[1]->document)->not->toHaveKey('_rankingScore'); +}); + +it('defaults meta to an empty array on hits and results', function () { + Config::set('scout.driver', 'database'); + Config::set('lunar.search.engine_map', [Product::class => 'database']); + + Product::factory()->create(); + + $results = Search::model(Product::class)->get(); + + expect($results->meta)->toBe([]) + ->and($results->hits[0]->meta)->toBe([]) + ->and($results->toArray())->toHaveKey('meta'); +}); + +it('tracks page and params on the engine', function () { + $engine = new class extends AbstractEngine + { + public function get(): mixed + { + return null; + } + + protected function getFieldConfig(): array + { + return []; + } + }; + + expect($engine->getPage())->toBe(1) + ->and($engine->page(0)->getPage())->toBe(1) + ->and($engine->page(3)->getPage())->toBe(3) + ->and($engine->perPage(24)->getPerPage())->toBe(24) + ->and($engine->withParams(['a' => 1])->withParams(['b' => 2, 'a' => 3])->getParams())->toBe(['a' => 3, 'b' => 2]); +}); + +it('resolves the page from the request unless page() is called', function () { + Config::set('scout.driver', 'database'); + Config::set('lunar.search.engine_map', [Product::class => 'database']); + + Product::factory()->count(3)->create(); + + Paginator::currentPageResolver(fn () => 2); + + $engine = Search::model(Product::class)->perPage(2); + + expect($engine->getPage())->toBe(2) + ->and($engine->get()->page)->toBe(2) + ->and(Search::model(Product::class)->perPage(2)->page(1)->get()->page)->toBe(1); +});