Skip to content

Recommendation API contract

The consumable surface of the Recommendation & Right-Sizing engine (#4), exposed by issue #62. One server-side load path — getRecommendation in src/lib/server/recommendation.ts — computes the result; the HTTP endpoint and the guided flow's result surface both call it, so they can never disagree. No engine logic is re-derived downstream.

Upstream layers: rule representationmatching enginecost-tier right-sizingthis contract.

Request

GET /api/recommendation?options=<id>,<id>,...[&track=<slug>]

options is a comma-separated list of selected factor-option ids — the guided flow's SelectionContext (toSelectionContext produces exactly this list). Ids are trimmed and de-duplicated. Unknown or stale ids are not an error: they simply satisfy no rule condition. An empty or absent list yields the explained empty result below.

track (optional) is the buyer's track slug. It scopes right-sizing to the ruleset that track is assigned (#1270), so per-track rule sets take effect (#1271) — see the resolution path. It is resolved via resolveTrackRuleSetId: an absent, unknown, or retired-ruleset track collapses to the default ruleset, so omitting track reproduces the pre-track behavior exactly. track never changes the response shape — only which rules fire.

Response (RecommendationResult)

jsonc
{
	"context": { "optionIds": ["sound-high"] }, // echoed back
	"recommended": {
		// or null — see "Empty result"
		"systemId": "nw-acoustical-645",
		"name": "NW Acoustical 645",
		"tagline": "…", // string | null
		"budgetTier": "Premium", // string | null
		"media": {/* GalleryItem */}, // or null — primary image (#245)
		"role": "preferred", // "preferred" | "alternative"
		"matchedRuleIds": ["high-stc-requirement"], // best-priority first
		"rationales": ["Sound control above STC 45 needs an acoustically rated system."]
	},
	"alternatives": [/* same shape; cheapest tier first */],
	"overSpecified": {
		// or null — right-sizing flag (#61)
		"fromSystemId": "nw-acoustical-645",
		"fromTier": "Premium",
		"toSystemId": "sl45",
		"toTier": "Budget",
		"note": "nw-acoustical-645 (Premium) recommended — sl45 (Budget) may suffice for less."
	},
	"gallery": {
		// or null — lead pick's gallery (#245)
		"items": [/* GalleryItem[], position order */],
		"displayMode": "grid" // "grid" | "slideshow" (admin setting)
	},
	"explanation": null, // string ONLY when recommended is null
	"firedRules": [
		// engine trace for the explainer (#561), priority order
		{
			"ruleId": "high-stc-requirement",
			"name": "High acoustic requirement",
			"priority": 10,
			"contributed": true // false = matched the answers but surfaced no system
		}
	],
	"surfacedSystemCount": 4, // systems the matcher surfaced before right-sizing narrowed the shape
	"matchedProjects": [
		// "projects like yours" (#345); [] when nothing recommended
		{
			"uuid": "ce51dc04-…",
			"name": "Fairway Edge",
			"city": "Camas", // string | null
			"state": "WA", // string | null
			"location": "Camas, WA", // "City, ST" | "City" | "ST" | null
			"canonicalUrl": "https://www.nanawall.com/projects/fairway-edge", // always set — unlinkable projects are excluded (#983)
			"role": "recommended", // "recommended" | "alternative" | "related" (backfill, #983)
			"matchedSystemName": "NW Aluminum 640", // the system this project used; null on "related" rows (no claim)
			"image": {/* MediaItem */} // or null — primary project image
		}
	],
	"evidence": [
		// cited corpus passages (#455); [] when nothing recommended / no supporting content
		{
			"itemId": "corpus-…",
			"title": "NW Aluminum 640 spec sheet", // citation label
			"heading": "Acoustic performance", // string | null
			"text": "The all-aluminum design provides…", // the quoted passage
			"contentType": "tech-doc", // pdp | blog | tech-doc | marketing
			"sourceUrl": "https://www.nanawall.com/…", // resolved source link (#1422); null when unresolvable (upload / dead / unchecked)
			"version": "2026-06-01", // capture version
			"fetchedAt": "2026-06-01T00:00:00.000Z"
		}
	],
	"groundedRationale": {
		// blended "why this fits" (#455 AC3)
		"passages": [/* CitedPassage[] — same as evidence */],
		"project": {/* the strongest MatchedProject */}, // or null
		"resourceUrl": "https://www.nanawall.com/resources/nw-aluminum-640#resources", // or null
		"hasAny": true // false → omit the block
	}
}

Media fields (#245). media is the system's primary media image as a client-safe GalleryItem (see $lib/media/types): the primary attachment from the media library, or the first image by position when the primary is a video; null when the system has no image (render nothing — no placeholder contract). gallery carries the lead recommendation's full position-ordered gallery plus the admin's per-gallery display-mode; it is null when nothing is recommended or the gallery is empty. Render items with the $lib/media components — image URLs are built from GalleryItem.id via the /media/<id>/<spec> transform endpoint (sized renditions only). The legacy imageUrl field is gone: buyer surfaces read media.

  • recommended — the right-sized lead pick: the engine's top-ranked candidate. rationales (in matchedRuleIds order, text-deduped — #1385: two matched rules carrying the same sentence yield one line, so the list is not index-aligned to the ids) carry the admin-authored "why we recommend this" — render them verbatim; do not re-derive.
  • alternatives — systems strictly cheaper than the lead that still satisfy the requirement (every candidate already passed its rule's attribute conditions), cheapest tier first. This is #61's right-sizing, not a raw match list.
  • overSpecified — present when a cheaper tier would suffice; points at the cheapest sufficient alternative for a "may suffice for less" nudge.
  • firedRules / surfacedSystemCount — the engine trace behind the results explainer (#561): every rule whose option-conditions matched the answers (priority order) with whether it surfaced a system, plus how many systems the matcher surfaced before right-sizing. Computed by the same pure summarizeFiredRules the admin rule preview (#716) uses, from the same evaluation that produced the result — render it, never re-derive it.

Projects like yours (matchedProjects, #345/#983)

Real installed nanawall.com projects that used the buyer's recommended system — social proof tied to the exact recommendation, deep-linked to the canonical project page. Matching is system-driven: a project matches when it references the product term of the recommended system (role: "recommended") or a cheaper alternative (role: "alternative"), resolved via system.id → drupal_system_mappings → drupal_project_products over the ingested projects (#378/#416). Ranked by role, then the project's featured flag, then its gallery order; matched rows are capped at a small high-relevance set (6).

Linkability is mandatory (#983): a project with no resolvable canonicalUrl is excluded from the list entirely — never returned unlinked — so every row is safely renderable as a deep-link.

Geo proximity (#698): when the buyer's coarse location resolves — which requires BOTH the admin toggle (/admin/privacy) and Iubenda measurement consent, re-derived per request from the Cloudflare edge geo and never persisted by this endpoint — same-city/same-region projects get a secondary score bonus that re-ranks them within their role band (role always dominates by construction), and matched rows carry nearYou: true, rendered as a "Near you" chip. Backfilled related rows never claim proximity. With any gate closed or no location resolved, the order is exactly the system-driven ranking above and no near-you claim renders — see the buyer-geolocation section of the privacy reference.

Backfill to three (#983): strict system-matches lead; when fewer than three exist, other linkable published projects backfill (curated order) up to three, carrying role: "related" with matchedSystemName: null. A related row is a real installation but makes no system claim — surfaces render it location-only (never "Uses <system>", never the "Your system" chip). The results view shows these on the dedicated "Projects like yours" tab, hidden when the list is empty. matchedProjects is [] whenever nothing is recommended (no signal to match on).

Corpus-grounded evidence (evidence / groundedRationale, #455)

The recommendation is evidence-backed: evidence cites the real NanaWall content that justifies the recommended system's fit — a CitedPassage[] retrieved through the corpus-retrieval read path (#66) and shaped in recommendation-evidence.ts. Each passage carries its quoted text, its corpus contentType, its canonical sourceUrl, and the capture version — so every citation is provenance-complete. Retrieval already excludes retired/superseded items, so a stale passage is never cited.

Citation link resolution (#1422) is the shared rule in $lib/citation-links.ts (reused by NanaSage answer linking): a pdp citation resolves through the deep-link seam ($lib/deeplink, #414) using the recommended system's captured PDP path; any other URL-bearing source (url, drupal-view, drupal-dump) links to its canonical sourceUrl only when verified live (#1008, fail-closed — dead and unchecked URLs degrade to title-only); an uploaded document has no public link and always renders as its title. Links render in a new tab with rel="noopener".

Pass-through only — no hallucinated citations. The UX shows only passages retrieval actually returned; there is no free-text invention. evidence is [] when nothing is recommended or the corpus has no supporting content (the surface renders "no supporting content", the correct degradation).

groundedRationale is the blended "why this fits" the guided-selection result renders: the cited passages (spec), the strongest matching project (#345, recommended-role first, else the first match, else null), and the system's resourceUrl (resources deep-link) — each field independently optional. hasAny is true only when there is real evidence (a passage or a project); a bare resources link is not grounding, so an empty corpus + no-match recommendation omits the block entirely.

  • canonicalUrl — the absolute nanawall.com project URL (from the project's slug, #416); null when the project has no slug, in which case render the name without a link (never fabricate one).
  • image — the project's primary image as a client-safe MediaItem, joined through media_tags (tag type project); null when the project has no ingested media (render the row without a thumbnail — no placeholder).
  • matchedSystemName — the system the project used, for a "Uses {name}" line.

Deferred (#483): facet-granular matching (application / building type / aesthetic) and geographic proximity — the facet mappings are factor-granular and the buyer's answers carry no location, so today's signal is the system. The matcher lives in src/lib/server/project-matching.ts; the client view model + shaper in src/lib/matching.ts.

Selection-relevant imagery (selectionMedia, #1114)

selectionMedia is retrieval-ranked imagery for the current selection — a SelectionMediaAsset[] scored by how well each image's associations match the selection's system · attribute · factor · application · geography axes, so the result surfaces selection-relevant imagery rather than a generic gallery. It is distinct from the recommended system's own media/gallery (#245, the curated hero/gallery): this arm draws on the extended media associations (media_tags, #1112) and accepted AI visual tags (media_visual_tags, #1113), which share the attribute/factor facet spine with the text corpus.

  • Each item carries its resolved src (via displaySrc), alt, title, the relevance score, and the matchedAxes that earned it (so the UI can explain "why this image").
  • Bounded at catalog scale (#1114 AC-04): every candidate comes from an indexed (tag_type, tag_ref) / geography lookup — no full media scan — and the response is capped. selectionMedia is [] when nothing is recommended or no asset matched the selection context. Built in src/lib/server/media-evidence.ts on the retrieveMediaForSelection core (src/lib/server/media-retrieval.ts).

Contextual illustration ranking (illustrationRanking, #1282)

illustrationRanking is the per-value illustration match for the page's thesis "here's the {System} in the setting you described": { hero, answers } — one RankedIllustration for the system hero and one per buyer answer — scored by leaf-tag overlap (#1279) with the recommended system + scene, with a fallback ladder (exact → system → context → canonical (#1244) → education) so a slot is never a hole. Each result carries its chosen tier, score, and a human-readable why; the system-vs-context weighting is a tunable knob. It is null when nothing is recommended, and the public surface (#410) owns the rendering. Full reference: Media illustrations → Contextual illustration ranking.

Empty result (explained, never a dead end)

A no-match selection returns HTTP 200 with recommended: null, empty alternatives, and a human-readable explanation distinguishing the two causes:

  • No answers — nothing to recommend from; prompt to answer at least one question.
  • No rule matched — suggest adjusting/broadening answers or contacting NanaWall.

Exclusion guarantees

Retired systems and retired or disabled rules never appear in the output, by construction: getRules returns only fire-eligible rules, listSystems returns only active systems, and the engine additionally checks every candidate against the active set. Enabling/disabling a rule in the admin changes the result with no code change.

What this maps to

  • Load path + shaping → src/lib/server/recommendation.ts (getRecommendation, pure shapeRecommendationResult)
  • Project matching (#345) → src/lib/server/project-matching.ts (getMatchedProjectViews, pure rankProjectMatches), client view model → src/lib/matching.ts
  • Corpus-grounded evidence (#455) → src/lib/server/recommendation-evidence.ts (loadRecommendationEvidence, pure shapeEvidence / composeGroundedRationale) over the corpus-retrieval read path; rendered in src/routes/select/SelectionFlow.svelte
  • Endpoint → src/routes/api/recommendation/+server.ts (composes RecommendationResult + matchedProjects + evidence + groundedRationale into RecommendationResponse)
  • Engine → src/lib/server/recommend.ts (see recommendation-matching.md, cost-tier-right-sizing.md)
  • Verifying the contract end to end against a seeded database → recommendation-validation.md