Appearance
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 representation → matching engine → cost-tier right-sizing → this 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(inmatchedRuleIdsorder, 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 puresummarizeFiredRulesthe 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'sslug, #416);nullwhen 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-safeMediaItem, joined throughmedia_tags(tag typeproject);nullwhen 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(viadisplaySrc),alt,title, the relevancescore, and thematchedAxesthat 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.selectionMediais[]when nothing is recommended or no asset matched the selection context. Built insrc/lib/server/media-evidence.tson theretrieveMediaForSelectioncore (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, pureshapeRecommendationResult) - Project matching (#345) →
src/lib/server/project-matching.ts(getMatchedProjectViews, purerankProjectMatches), client view model →src/lib/matching.ts - Corpus-grounded evidence (#455) →
src/lib/server/recommendation-evidence.ts(loadRecommendationEvidence, pureshapeEvidence/composeGroundedRationale) over the corpus-retrieval read path; rendered insrc/routes/select/SelectionFlow.svelte - Endpoint →
src/routes/api/recommendation/+server.ts(composesRecommendationResult+matchedProjects+evidence+groundedRationaleintoRecommendationResponse) - 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