Search

Natural-language catalog search — query understanding, franchise & time-aware queries, opt-in personalization, scoring, and coverage.

POST /v1/search searches your airline’s catalog (plus the shared global catalog) in natural language. You send a passenger-style query; the platform parses it into structured constraints, ranks the matching titles semantically, and tells you exactly how it understood the query. Optionally, a seatback integration can opt a search into behavior personalization for the passenger who is asking.

Request

1{
2 "query": "romantic comedy from the 90s",
3 "topK": 5,
4 "kindOverride": null
5}
FieldRequiredRules
queryyes1–1000 characters. Longer is rejected with 422 request.validation.
topKno1–100; defaults to 10. Out of range is a 422.
kindOverrideno"Movie", "TV", "Audio", or "Game"any casing, plus synonyms. Forces the content kind instead of the parsed one. Anything else is a 422 whose message lists the accepted kinds.
flightMinutesRemainingnoInteger, ≥ 1 when present (0 or negative is a 422). The minutes left in the passenger’s flight — it only takes effect when the query itself asks for content that fits the flight. Omitting it never changes results for other queries; when the session is bound to a flight, the platform can derive it instead.
seatIdnoMember locator for personalization. Omit for an unpersonalized search.
memberSlotnoWhich traveler on a shared seat; omit for solo — meaningful only with seatId.

The request body is capped at 64 KiB (413 above it). There is no airline field — your credential carries the airline.

Search is the platform’s one session-optional endpoint: with no X-Session-Id header it is a plain airline-scoped search (the unchanged behavior for server-to-server callers). Send the header to opt into personalization — but a malformed value is then a 400 session.unresolved, same as on the session-required endpoints, never silently ignored.

How your query is understood

Every response echoes the parse in parsed, so you never have to guess what the platform did with the text:

  • originalQuery — your text, verbatim.
  • filters — the structured constraints extracted from it: yearFrom / yearTo, genres[] (all listed genres must match), cast[], titleContains, contentKind, ratingMax, entities[], runtimeMax, scoreMin, awardWinning — plus airlineId, the tenant your credential resolved to (server-side; you never send it).
  • semanticText — the meaning left over after constraint extraction (“feel-good”), used for semantic ranking. Empty when nothing remains.
  • source — which parser produced the filters: "ai" (the model parser, the primary path) or "rules" (the deterministic fallback).
  • fitsFlightTimetrue when the query asked for content that fits the remaining flight.
  • recommendForMetrue when the query asked for personal picks rather than stating content constraints.

Parsing handles the phrasings passengers actually use:

  • Decades: ”90s” or “1990s” ⇒ yearFrom: 1990, yearTo: 1999.
  • Compound genres: “romcom” ⇒ genres: ["Romance", "Comedy"].
  • Kind keywords: “shows”, “series”, “TV” ⇒ contentKind: "TV".

How parsing runs. Query parsing is AI-primary: a language model extracts the structured constraints — including cast names, with fuzzy correction of well-known names (“Tom Cruz” → “Tom Cruise”) — and the response’s "source": "ai" tells you the model produced them. If the model is briefly unavailable the platform falls back to the deterministic rules engine automatically ("source": "rules") and never fails the request — but the rules engine extracts a narrower set: no cast, titleContains, or ratingMax, it never populates entities, scoreMin, or awardWinning, and it never flags fitsFlightTime or recommendForMe. Branch on parsed.source if that distinction matters to you.

Content kinds

kindOverride forces the content kind instead of the parsed one, and it accepts all four kinds the catalog carries:

ValueAlso accepted
Movieany casing (movie, MOVIE)
TVany casing (tv)
Audiomusic, albums, audiobooks
Gamegames

kindOverride is the documented channel for audio and game: the query parser does not infer those kinds from free text, so “play me some jazz” without an override is not read as an audio request. Anything outside the accepted set is a 422 request.validation whose message lists the kinds.

The catalog-serve kind filter is stricter, on purpose. Serve takes the four canonical values — Movie, TV, Audio, Game — in any casing and no synonyms: music and games resolve on search’s kindOverride and are a 422 on serve. The non-parity is deliberate (a free-text query surface and a structured filter parameter are not the same thing), not an oversight.

Audio and game titles are hidden from younger members today. Audio and game content ratings carry no recognized scale yet and normalize to null, and an unrated title is treated as most-restrictive — so audio and game results are excluded for Child, Tween, and Unknown members and returned normally for Adult members. An audio or game search for a younger member returns an empty result set, honestly, rather than an error.

Quality & acclaim queries

Queries that state a rating bar or ask for acclaim are understood as constraints, and every result carries its scores:

The query saysWhat happens
”an IMDB rating of 7 or better”, “rated above 8”parsed.filters.scoreMin is set to that bar (0–10 scale)
“top rated”, “critically acclaimed”scoreMin is set to the acclaim default, 7.5
”award winning”, “Oscar winners”parsed.filters.awardWinning: true
  • The score bar follows the data-gap rule. A title with a known score below the bar is excluded; a title with no known score is not excluded — exactly as with the rating and runtime filters. A data gap is not a violation.
  • Award matching is grounded. A title qualifies only when its own catalog data carries award recognition — never inferred from popularity or acclaim.
  • Matching results carry reason strings like "IMDB 7.2" and "Award winner".
  • Quality phrases are never treated as franchise entities. “Award winning action movies” is a quality constraint, not a search for a franchise called “award winning” — a deterministic guard, so these queries return a full lineup rather than a handful of accidental matches.

Every result item carries the scores it was judged on, whether or not the query asked:

FieldMeaning
imdbRating0–10 scale (7.2). Omitted when unknown — never fabricated.
rtTomatometer0–10 scale (8.3). Omitted when unknown.
awardWinnerBoolean; always present.

These are catalog-supplied review and editorial data, as carried by your own catalog feed. Display them with the result — do not re-derive or recompute them.

Franchise & entity queries

Queries that name a franchise, character, or creator — “Bond films”, “Marvel movies” — resolve against what your catalog’s own data actually carries, not against words in titles:

  • parsed.filters.entities lists the canonical entity names the query stated (["James Bond"]). It is always present, empty when the query names none.
  • A title matches when its own catalog data — keywords, title, cast, or synopsis — carries the entity. A title keyworded “James Bond” matches even if its name contains no “Bond”.
  • Matching results carry a reason naming the franchise (e.g. "James Bond franchise").
  • An entity your catalog does not carry returns zero results — never invented or look-alike titles. Grounded-empty is the designed behavior, not a failure.

Time-aware queries: “fits the flight”

When the seatback knows how long is left in the flight, send it — and when the query itself asks for something finishable (“something I can finish before we land”), the platform turns it into a runtime ceiling:

$curl -X POST "https://api.staging.westiq.ai/v1/search" \
> -H "Authorization: Bearer $ACCESS_TOKEN" \
> -H "Content-Type: application/json" \
> -d '{"query": "something I can finish before we land", "flightMinutesRemaining": 95, "topK": 3}'
  • The ceiling is the smaller of flightMinutesRemaining and any explicit duration in the query (“under 2 hours” ⇒ 120). It lands in parsed.filters.runtimeMax (minutes; omitted when no ceiling applies).
  • Titles whose known runtime exceeds the ceiling are excluded. Titles with unknown runtime are not excluded — a data gap is not a violation, the same rule the rating gate follows.
  • Fitting results carry a reason like "Runtime 92 min fits your 95 min".
  • parsed.fitsFlightTime: true confirms the query was understood as flight-fit. If it is true on a request that sent no flightMinutesRemaining and the session is not bound to a flight, no ceiling was applied — treat that as the signal to start sending the field.
  • A query that never mentions flight-time fit ignores flightMinutesRemaining entirely.

When you don’t send the minutes

If the session is bound to a flight and a flight-fit query arrives without flightMinutesRemaining, the platform derives the remaining minutes from that flight’s scheduled arrival time rather than skipping the ceiling.

A request-supplied flightMinutesRemaining is always authoritative — the aircraft knows its own delays, the schedule does not.

The response’s flightMinutesRemainingServerDerived (boolean, always present) tells you where the value came from: true only when the platform derived it. Read it precisely — it reports the origin of the remaining minutes value, not which ceiling ultimately applied. A tighter runtime limit stated in the query text (“under 90 minutes”) still wins while this flag reads true.

Personalization & personal picks

Search is unpersonalized by default. A seatback integration opts in per request by sending the passenger’s session and member locator alongside its event stream:

$curl -X POST "https://api.staging.westiq.ai/v1/search" \
> -H "Authorization: Bearer $ACCESS_TOKEN" \
> -H "X-Session-Id: seat-12A-f3d9c1" \
> -H "Content-Type: application/json" \
> -d '{"query": "thrillers from the 2000s", "seatId": "12A", "topK": 5}'

When the session and locator resolve a real behavior profile:

  • Results get a bounded additive re-rank toward the member’s observed affinities — a nudge on top of relevance, never a replacement for it.
  • Re-ranked items carry a deterministic reason string, e.g. "Matches your Thriller affinity".
  • The response’s personalized: true confirms a profile actually shaped this ranking.

personalized: false is the honest answer for every other case — no locator sent, sessionless call, or a member with no observed behavior yet (cold start). It is never faked.

Personal-pick queries. A query that asks for recommendations instead of stating constraints — “show me movies you think I’d like”, “what should I watch”, “surprise me” — flips ranking from semantic matching to the member’s affinity-ranked lineup, echoed as parsed.recommendForMe: true. Stated filters still apply (“comedies you think I’d like” stays comedies). A cold member gets a neutral, deterministic lineup — sensible, not random, and personalized stays false. Because no semantic query is being ranked in this mode, results carry cosineScore: 0.0 and no "semantic …" reason — the affinity reason is the ranking evidence.

Locator semantics are the platform-wide ones: fail-closed, never loud — an unknown seat or unbound member personalizes nothing and errors nothing.

Member age-gating on session-bound search

Sending X-Session-Id opts into more than personalization: it also opts into child-safety filtering for the identified member.

  • With a session — results are hard-filtered to what is age-appropriate for the member your seatId / memberSlot names, before ranking. The member’s age tier comes from the session roster, never from the request.
  • Without a session — a sessionless, machine-to-machine search is not age-filtered. Results are byte-identical to what they have always been.

Two fail-closed rules apply when a session is supplied:

  • An unresolved or unbound member gets the most-restrictive tier. An unknown locator narrows results; it never widens them and never errors.
  • A title with no content rating is treated as most-restrictive, so an unrated title is hidden from Child, Tween, and Unknown members. (This is what currently hides audio and game titles from those members.)

So the same query can legitimately return different results for the same airline depending on whether you sent a session — that is the gate working, not an inconsistency.

Flight-bound search

If the session is bound to a flight (via flightInstanceId at session setup) and that flight has a pushed active catalog, search results are intersected with the flight’s active set — on every search mode, semantic queries and the recommend-for-me lineup alike.

The intersection can only ever narrow. It never widens the airline catalog and never overrides member age-gating: an active title that is age-inappropriate for the member stays hidden. A session with no flight binding, a flight with no push, and every sessionless call keep full-catalog behavior exactly as before.

Search is one of three narrowed surfaces, alongside scored catalog serve and suggestions. Together they carry the guarantee end to end: a flight-bound session cannot be shown a title that is not loaded on the aircraft.

Results & scoring

Ranking is a fixed pipeline: structured filters prune the candidate set, vector similarity ranks the survivors, and an exact-title containment match adds a fixed boost on top of the semantic score.

Each result carries:

FieldMeaning
itemIdCatalog item id: a kind prefix plus the title id — "M:" movie, "T:" TV, "A:" audio, "G:" game (e.g. "M:123", "A:456"). See the warning below.
title, year, kind, genres, castThe catalog metadata that matched
scoreFinal ranking score (semantic + any exact-title boost + any affinity nudge)
cosineScoreThe raw semantic-similarity component
reasonsHuman-readable match evidence, e.g. "Year 1996", "semantic 0.81", "James Bond franchise", "Runtime 92 min fits your 95 min", "IMDB 7.2", "Award winner", "Matches your Thriller affinity"
imdbRating, rtTomatometer, awardWinnerQuality signals — the first two omitted when unknown

If you switch on the itemId prefix, handle all four. The prefix set is M: / T: / A: / G:. The M: and T: forms are byte-identical to earlier releases, so nothing you already parse changes — but a client that branches on the prefix, or validates it against a two-value pattern, will now meet A: and G: on any airline whose catalog carries audio or game titles. This is the one place in the four-kind rollout where an existing integration can actually break.

candidateCount is not a catalog-wide match total. Retrieval is index-accelerated: ranking examines the query’s nearest-candidate set — typically 3×topK, clamped to [50, 300]; queries with a cast filter examine a wider set for recall — and candidateCount reports how many of those examined candidates survived the filters. Treat it as “matches among the examined nearest candidates”. For catalog-size signals use totalItems and coverage (read the basis rule below) — never candidateCount. (Shopping search’s candidateCount is unaffected: that endpoint filters its whole catalog.)

What totalItems counts

totalItems is this request’s catalog: the titles the request could draw from, before any query filter or age gate.

  • On an ordinary request that is your visible catalog — your airline’s titles plus shared global content.
  • On a flight-narrowed request it is your visible catalog intersected with the flight’s active set. coverage uses the same basis.

totalItems is a per-request basis figure, not a library size. It equals your airline’s catalog only when the request is not narrowed — the moment a session binds a flight with a pushed active catalog, the same field reports a per-flight number. Don’t cache it as a catalog statistic, and don’t compare it across bound and unbound requests.

Coverage, reported honestly

coverage tells you how complete the metadata behind your results is — reported, never papered over:

1{
2 "movieCount": 38,
3 "tvSeriesCount": 7,
4 "genreCoveragePct": 100,
5 "castCoveragePct": 100,
6 "synopsisCoveragePct": 100
7}

The three …Pct fields are percentages on the 0–100 scale (42.5 means 42.5%), never 0–1 fractions. They report how many of the items on this request’s basis carry at least one genre, at least one cast member, and a synopsis respectively — if a coverage number is low, semantic ranking quality is limited by the catalog metadata, not by your queries.

Acceptable use & content safety

Search serves in-flight entertainment discovery, and every request passes a content-safety check before any model runs. A query refused on safety or scope grounds — attempts to misuse the search surface rather than find catalog content — returns 422 content.refused with a friendly generic message:

1{
2 "schemaVersion": "1",
3 "code": "content.refused",
4 "message": "I can only help you explore what's available on this flight — try searching the movies, shows, music, or shopping catalog.",
5 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d"
6}

Three things an integrator should build against:

  • Handle it as “show the message, don’t retry”. The refusal is about the query text, not your integration — surface the message (or your own copy) and let the passenger rephrase. It is deliberately distinct from 422 request.validation (your request broke a rule — fix and retry), so branch on code, not on the 422 status.
  • The message never says which check fired. No rule identifier is disclosed, by design.
  • Refusal ≠ degradation. If the AI parse is merely unavailable (throttled, transport fault, or a moderation-provider outage), search still degrades to the deterministic rules fallback and returns a normal 200 with "source": "rules" — a refused query is the only case that 422s.

Benign catalog and personalization queries — everything this guide documents — are unaffected. The same check protects shopping search; every other model-backed endpoint surfaces content blocks in its own documented denial shape.

Worked example

$curl -X POST "https://api.staging.westiq.ai/v1/search" \
> -H "Authorization: Bearer $ACCESS_TOKEN" \
> -H "Content-Type: application/json" \
> -d '{"query": "romantic comedy from the 90s", "topK": 5}'

Abridged response (sandbox tenant):

1{
2 "schemaVersion": "1",
3 "parsed": {
4 "originalQuery": "romantic comedy from the 90s",
5 "filters": {
6 "airlineId": "demo_airline",
7 "yearFrom": 1990,
8 "yearTo": 1999,
9 "genres": ["Romance", "Comedy"],
10 "entities": []
11 },
12 "semanticText": "",
13 "source": "ai",
14 "fitsFlightTime": false,
15 "recommendForMe": false
16 },
17 "results": [
18 {
19 "itemId": "M:…",
20 "title": "You've Got Mail",
21 "year": 1998,
22 "kind": "Movie",
23 "score": 0.71,
24 "reasons": ["Year 1998", "Genres: Romance, Comedy", "semantic 0.71"]
25 },
26 {
27 "itemId": "M:…",
28 "title": "Sleepless in Seattle",
29 "year": 1993,
30 "kind": "Movie",
31 "score": 0.69,
32 "reasons": ["Year 1993", "Genres: Romance, Comedy", "semantic 0.69"]
33 }
34 ],
35 "candidateCount": 6,
36 "totalItems": 45,
37 "personalized": false,
38 "flightMinutesRemainingServerDerived": false,
39 "coverage": {
40 "movieCount": 38,
41 "tvSeriesCount": 7,
42 "genreCoveragePct": 100,
43 "castCoveragePct": 100,
44 "synopsisCoveragePct": 100
45 },
46 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d"
47}

Errors

Standard envelope: 422 request.validation (query length, topK range, flightMinutesRemaining below 1, bad kindOverride), 422 content.refused, 400 session.unresolved (a malformed X-Session-Id — only when you send the header), 429 rate.limited over the per-client request-rate limit (honor the Retry-After header), 401 / 403, 413 request.malformed (body over 64 KiB), 500.

Full request and response schemas are in the API Reference.