# meridian-ai

Dedicated AI service for the Meridian travel-agency platform. It owns every LLM / embedding /
transcription call the product needs and exposes a small internal HTTP API that
[Meridian-Backend](../Meridian-Backend) (Laravel) calls into. The frontend never talks to this
service directly — it talks to `Meridian-Backend`, which persists results, enforces
company/workspace scoping, and forwards progress to the browser over its existing channels.

This document specifies **what AI functionality the product needs** and **the endpoints
required to deliver it**, grounded in two sources of truth in
[`Meridian-Frontend`](../Meridian-Frontend):
- [`Meridian travel AI infrastructure-2/meridian-overview.md`](../Meridian-Frontend/Meridian%20travel%20AI%20infrastructure-2/meridian-overview.md) —
  the target product/architecture spec.
- [`requests.md`](../Meridian-Frontend/requests.md) — the frontend team's own audit of which
  endpoints are real (`✅`), defined-but-unwired (`⏳`), or don't exist yet (`❌`).

Four pillars drive everything below, in the order a trip actually happens:

1. **Trip / itinerary generation** — turn a brief into bookable day-by-day options.
2. **Flight & accommodation searching** — real inventory, ranked and matched to preferences.
3. **Preference & desire extraction from conversation history** — what the traveler actually
   wants, mined from briefs, calls, and chat.
4. **Call transcription (including video)** — discovery calls become structured trip input
   instead of an agent's manual notes.

---

## 0. Reference implementation (v1)

A working Python/FastAPI implementation lives in [`app/`](app/) — built from `goplaces_backend/v4`
(a separate, earlier itinerary-generation prototype at `C:\Safe\2026\goplaces_backend\v4`:
`agent.py`'s OpenAI/Ollama-swappable chat wrapper, `main.py`'s strict-JSON prompting style, and
`persistence.py`'s day-keyed itinerary shape), rewritten to be stateless and provider-agnostic
rather than single-conversation and OpenAI-only.

It ships exactly four endpoints — a narrower, synchronous slice of the full async/7-capability
spec in §2-§6 below, covering the four pillars above end-to-end:

| Endpoint | Pillar | Request → Response |
|---|---|---|
| `POST /generate_itinerary` | Trip generation | brief + preferences (+ optional flight/stay candidates) → ranked day-by-day options |
| `POST /recommend_stay` | Flight & accommodation search | itinerary + preferences (+ optional search candidates) → ranked accommodation recommendations |
| `POST /suggest_response` | Preference/desire signal from conversation | conversation history + trip context → drafted reply candidates |
| `POST /suggest_questions` | (feeds trip generation) | trip brief → clarifying multiple-choice questions |

**Provider-agnostic by design:** every endpoint is written against the `LLMProvider` interface
in [`app/providers/base.py`](app/providers/base.py), never against a vendor SDK directly.
`app/providers/factory.py` reads `LLM_PROVIDER` (`openai` | `anthropic` | `ollama`) and
constructs the matching implementation — switching providers, or pointing at a local Ollama
model instead of a cloud one, is a single env var change, not a code change.

**Deviations from §2-§6 worth knowing about:**
- **Synchronous, not async/job-based.** Each endpoint makes one LLM call and returns directly
  — no `generation_id`/polling/callback machinery yet. Fine for a single LLM call; revisit if
  real multi-step inventory search (§2.2) gets added on top and latency grows.
- **No transcription endpoint yet** (pillar 4, §2.4/§5.4) — this v1 covers the other three
  pillars; call transcription/summarization is still spec-only.
- **Search stays optional, not owned.** `generate_itinerary`/`recommend_stay` accept optional
  `flight_candidates`/`stay_candidates`/`candidates` and rank/build around them when given, but
  don't call SerpApi (or anything else) themselves — same "search stays behind the caller"
  decision as §3, just enforced by the request schema instead of by convention.
- **Auth is the simple half of §4.1** — a static bearer token (`MERIDIAN_AI_SERVICE_TOKEN`),
  not yet the signed-request-with-nonce scheme §4.1 describes for replay protection.

Run it locally:
```
cd meridian-ai
python -m venv .venv && source .venv/Scripts/activate   # Windows Git Bash; use .venv\Scripts\activate.bat on cmd
pip install -r requirements.txt
cp .env.example .env   # fill in OPENAI_API_KEY (or ANTHROPIC_API_KEY / point at a local Ollama)
uvicorn app.main:app --reload --port 8000
```
Tests (`pytest`) run against a `FakeProvider` and need no API key — they check the FastAPI
wiring/schemas/error-handling, not any provider's actual output quality.

---

## 1. Where things stand today (gap analysis)

`requests.md` is blunt about this, so `meridian-ai` should be too: most of what it needs to
plug into **does not exist yet** in `Meridian-Backend`. Building the AI service and building
the endpoints it depends on are the same project.

| Area | Current state | Gap |
|---|---|---|
| Itinerary generation | `TripController::generateItinerary` builds a **hard-coded rotating template** (`sleep(4)` + canned day titles, no LLM call). Frontend's create-trip modal "currently simulates generation without calling the API" (`requests.md` #9) | Needs real generation: entity extraction, inventory search, ranking, day/block construction |
| Flight search | `GET /itinerary/:id/flights/search` → `SerpApiService::searchFlights` — **real, working**, returns `FlightSearchResponse` | AI needs to rank/annotate these results against preferences, not just list them |
| Accommodation search | `GET /itinerary/:id/accommodation/search` → `SerpApiService::searchHotels` — **real, working**, returns `HotelSearchResponse` | Same — needs AI ranking/annotation, not a new search implementation |
| Activities | **Mock only** — `ctx.getActivities()` in the frontend, no backend endpoint at all | Needs a real source (own DB or provider) before AI can rank anything |
| Call records | `Call.transcript`/`Call.notes` and `CallActionItem.description`/`status` exist in the schema, but are **filled in manually by an agent** — `requests.md` confirms call logs on Trip Detail are `ctx.getCallLogs()` mock data | Needs transcription (incl. video) and summarization to actually populate these columns |
| Conversation history / Messages | **100% mock** — `requests.md`: "Messages page uses 100% mock data. No API endpoints have been defined for messaging yet." No `Conversation`/`Message` models exist | Blocks preference extraction from live chat and AI draft replies until conversation storage exists (see §6) |
| Preference & desire extraction | **Does not exist** — `Trip.description`, `Itinerary.description`, `Customer.notes` are free text only, nothing structured | Needs an extraction endpoint plus somewhere in Laravel to persist structured output |
| Pre-generation question suggester | **Does not exist** | New endpoint; no schema changes needed (questions/answers round-trip through the generation request) |
| Agent activity feed | **Does not exist** — `requests.md`: mock data in `constants/app.ts` | Out of scope for `meridian-ai` (bookkeeping, not AI) — but every capability below must return enough for Laravel to log an activity record |

**Conclusion:** `meridian-ai` is greenfield, and it's the forcing function for a few backend
gaps (conversation storage, an activities source, structured preference storage) that block
full delivery of pillars 3 and 4. Where a capability is blocked, that's called out explicitly
below rather than glossed over.

---

## 2. Required functionality

### 2.1 Trip / Itinerary Generation

**Trigger:** agent clicks "Generate with Meridian" (replaces the stub in
`TripController::generateItinerary`; wires up the currently-simulated create-trip modal).

**Pipeline:**
1. Extract entities from the brief (destinations, dates, budget, traveler count, dietary,
   interests) — reuses §2.3's extraction if the brief has already been analyzed.
2. Optionally ask 2–4 clarifying questions first (§2.1.1) before spending a full generation
   call on an ambiguous brief.
3. Search inventory — flight/stay candidates come from Laravel's existing
   `SerpApiService::searchFlights`/`searchHotels` calls (§2.2), passed in as-is; activities
   from whatever source is configured.
4. Rank candidates against preferences, budget, and constraints.
5. Construct exactly 3 options: one AI pick (best fit), one value, one premium/wildcard.
6. Build day-by-day blocks with pricing per option, using real candidate prices (not
   estimates) wherever a candidate was selected.
7. Generate optional per-day filler suggestions for gaps (§2.1.3).
8. Return full options with cost breakdown; Laravel persists them as `Itinerary` +
   `ItineraryDay` + `ItineraryFlight`/`ItineraryAccommodation` rows.

Runs **async** — LLM + multiple inventory calls exceed a normal request timeout. Laravel
queues the job, gets a `generation_id` back immediately, and polls or receives a callback.

**Regeneration** is the same endpoint called again with the same brief (fresh alternatives), a
modified brief, a preset (`cheaper` / `more_luxury` / `less_travel`), or a scope limited to a
single option or day.

#### 2.1.1 Pre-generation question suggester
Analyzes the brief for ambiguity (missing budget, unclear travel style, vague preferences) and
returns 2–4 ranked multiple-choice questions. Answers fold back into the generation request as
`answered_questions` — no persistence needed.

#### 2.1.2 Day-by-day suggestion engine
Given one day of an already-generated itinerary (existing blocks + preferences), returns a
single ranked filler suggestion with one-click-add activity data.

### 2.2 Flight & Accommodation Searching

The *search* itself is already real (`SerpApiService`) — what's missing is the AI layer on top
that turns a flat results list into a decision:
1. **Rank** results against trip preferences (budget, max stops, dates, traveler count) rather
   than showing them in whatever order the provider returned.
2. **Flag** an AI-recommended pick per list (mirrors `is_ai_pick` at the itinerary-option
   level).
3. **Score/annotate** each result with why it fits or doesn't (e.g. "1 stop — matches your max
   1 stop preference", "sea-view listed in description — matches interest").

This reuses the *exact* shapes Laravel already gets back from SerpApi
(`FlightSearchResponse`/`HotelSearchResponse` — see `src/types/app.ts`), so `meridian-ai` never
needs its own inventory integration; Laravel calls SerpApi first (key stays server-side, as
documented in `SerpApiService`), then sends the results here to be ranked.

### 2.3 Preference & Desire Extraction from Conversation History

Runs over **every** text source the agency has about a traveler, and is meant to be called
repeatedly as new information arrives, merging into a running profile rather than
overwriting it wholesale:
- Trip brief (available today)
- Call transcripts (available once §2.4 ships)
- Conversation/message history (blocked today — see §6; the endpoint accepts message arrays
  now so no API change is needed once `Conversation`/`Message` exist)

Extracts: dietary needs, allergies, preferred airlines, travel style, deal-breakers, luxury
preferences, and free-text "desires" that don't fit a fixed field (e.g. "wants a photo op at
sunset", "celebrating an anniversary"). This structured profile is what makes generation
(§2.1), search ranking (§2.2), and draft replies actually personalized instead of generic.

### 2.4 Call Transcription & Summarization (audio + video)

Discovery calls happen over Google Meet/Zoom, so the source recording is usually **video**,
not a bare audio file. Two-stage, offered as separate endpoints so Laravel can show a raw
transcript quickly or chain both:
1. **Transcribe**: recording (audio *or* video — `.mp4`/`.webm`/`.mov` as well as `.mp3`/`.wav`)
   → text with speaker diarization. Video input has its audio track extracted before it hits
   the transcription provider; the video itself isn't otherwise analyzed (no frame/vision
   processing) — that's a non-goal, see §8.
2. **Summarize**: transcript → summary + numbered action points + decision tags. Laravel maps
   action points onto `CallActionItem` rows, and can immediately feed the transcript into
   §2.3's extraction to update the traveler's preference profile from what was actually said
   on the call.

### 2.5 AI Draft Replies

Given a conversation's recent messages plus trip/call/preference context, returns a single
draft reply with a confidence score. **Blocked** on `Meridian-Backend` having
`Conversation`/`Message` models (§6) — until then, Laravel can pass an ad-hoc
`context_message` instead of full history, which is why the request shape below makes
conversation history optional rather than required.

---

## 3. Architecture

```
┌────────────────────┐        ┌──────────────────────┐        ┌─────────────────────┐
│  Meridian-Frontend  │──────▶│   Meridian-Backend    │──────▶│     meridian-ai      │
│  (React, browser)   │  REST │   (Laravel)           │  REST │  (this service)      │
│                     │◀──────│                       │◀──────│                      │
└────────────────────┘  WS/   │ - auth, RBAC          │callback│ - LLM orchestration  │
                        poll   │ - persistence          │       │ - embeddings/RAG     │
                                │ - calls SerpApi first   │       │ - transcription      │
                                │ - queues AI jobs,       │       │ - inventory ranking  │
                                │   stores results        │       │ - preference merging │
                                └───────────┬────────────┘       └──────────┬───────────┘
                                            │                               │
                                     MySQL/PostgreSQL              LLM provider (Anthropic/
                                     (trips, itineraries,           OpenAI), transcription
                                     calls, ...)                    provider (Whisper/Deepgram),
                                                                     SerpApi (already in Laravel)
```

- `meridian-ai` is **stateless** with respect to product data — no database of its own trips/
  travelers. Laravel sends everything needed per request (brief, preferences, candidate
  inventory, transcript, message history, prior extracted profile) and gets a result or a job
  handle back. Company/workspace scoping and RBAC stay entirely inside Laravel
  (`UserHelper::user_company`, etc.) — `meridian-ai` never sees a company id it needs to
  enforce anything against.
- The one thing it *does* own statefully is **in-flight job progress** (generation/
  transcription/summarization), keyed by its own job id, so it can report progress
  independent of Laravel's queue.
- **SerpApi stays behind Laravel.** `SerpApiService` already exists there and its own doc
  comment is explicit about keeping the key server-side; `meridian-ai` never calls SerpApi
  directly — Laravel searches first and passes results in as candidates.

---

## 4. API conventions

- **Base URL:** `/v1`, internal network only (not internet-exposed) — Laravel is the only
  caller (see §4.1 for how that's enforced).
- **IDs passed through, not generated:** every product-facing ID in a request/response
  (`trip_id`, `itinerary_id`, `call_id`, `itinerary_day_id`, `customer_id`, ...) is whatever
  Laravel's `IdGeneratorService` already produced (format `{PREFIX}_{HEX}{RANDOM}`, e.g.
  `TRP_01F3A9B2C7D4E5F6`). `meridian-ai` only mints its own job identifiers
  (`generation_id`, `transcription_id`, `summarization_id`) in their own namespace
  (`gen_...`, `trx_...`, `sum_...`).
- **Timestamps:** ISO 8601 UTC.
- **Money:** matches Laravel's existing convention of integer amounts + a separate `currency`
  field (see `ItineraryFlightResponse.cost`/`currency`), e.g. `{ "amount": 84500, "currency":
  "GHS" }`. Note SerpApi results already come back in **USD only** (`SerpApiService`: GHS is
  unsupported by the provider) — `meridian-ai` doesn't convert currency, it passes through
  whatever currency each candidate/result already carries.
- **Async jobs:** long-running work returns `202 Accepted` with a job id and `status:
  "in_progress"`; Laravel polls `GET /v1/jobs/{job_id}` or supplies a `callback_url` (§4.2).
- **Errors:** `{ "error": { "code": "GENERATION_FAILED", "message": "...", "details": {} } }`.

### 4.1 Authentication

`meridian-ai` never sees an end user or their frontend session — it authenticates **Laravel
as a caller**, not the traveler/agent behind the request. These are two separate trust
domains and they are not bridged:

```
[ Frontend ]──Sanctum bearer token──▶[ Meridian-Backend ]──service credential──▶[ meridian-ai ]
   (user identity, RBAC checked           (already authorized             (trusts the channel,
    here, never seen past this point)      the request here)               not the user)
```

Laravel has already validated the user's Sanctum token and enforced RBAC/company scoping
*before* it ever calls `meridian-ai` — that authorization decision is final by the time the
request leaves Laravel. So `meridian-ai` doesn't need to re-authenticate the user; it only
needs to know the caller is genuinely Laravel and not something else that reached the
service's network.

**The frontend's bearer token is never forwarded to `meridian-ai`.** Forwarding it would
require `meridian-ai` to validate Sanctum tokens (meaning DB access or a callback into
Laravel just to check a token — the exact coupling the stateless design in §3 avoids), and it
would needlessly widen the blast radius of a leaked user session to a second service that has
no use for it.

Instead, every request is authenticated at the service level:

1. **Network isolation** — `meridian-ai` is not reachable from the public internet at all
   (VPC/security group restricted to Laravel's egress IPs). This is a floor, not the whole
   mechanism — it doesn't stop a compromised process inside the network.
2. **Per-request signing**, reusing the same HMAC scheme already used for callbacks (§4.2)
   rather than a single static bearer token that's valid forever if it leaks. Laravel signs
   each request over method + path + body + timestamp + nonce:
   ```
   X-Meridian-Timestamp: 1751702400
   X-Meridian-Nonce:     8f3a1c9e-...
   X-Meridian-Signature: hmac_sha256(MERIDIAN_AI_SERVICE_TOKEN, "{method}\n{path}\n{timestamp}\n{nonce}\n{body}")
   ```
   `meridian-ai` recomputes the signature, rejects anything older than ~5 minutes (clock-skew
   window), and rejects a `nonce` it's already seen within that window (short-TTL cache) to
   stop replay of a captured request. This is symmetric with how `meridian-ai`'s own callbacks
   to Laravel are already signed in §4.2 — one shared pattern for both directions.
3. **Claims, not credentials, for attribution.** Every request body carries a plain
   (unsigned-on-its-own) `requested_by: { user_id, company_id }` block. `meridian-ai` doesn't
   — and can't — verify this against anything; it's there purely so `meridian-ai`'s logs say
   "this generation was requested by company X / user Y" for auditability. Its trustworthiness
   comes entirely from having arrived over an already-authenticated channel (steps 1-2), the
   same way a request header set by a trusted reverse proxy is trusted by the app behind it.

If `meridian-ai` is ever deployed somewhere network isolation is impractical (e.g. a shared
Kubernetes cluster with no VPC boundary), upgrade step 1 to mTLS between the two services
rather than relying on steps 2-3 alone — but for a single internal caller, signed requests
over a private network is enough.

### 4.2 Callbacks (preferred over polling)

Every async endpoint accepts an optional `callback_url`. When the job finishes, `meridian-ai`
POSTs the final result to that URL, signed with an `X-Meridian-Signature` HMAC header Laravel
verifies against `MERIDIAN_AI_WEBHOOK_SECRET` — the same signing scheme as §4.1, just in the
other direction. This is what lets Laravel push
`generation.progress`/`generation.done`/`call.processed` over its own WebSocket/notification
layer without `meridian-ai` knowing anything about connected clients.

---

## 5. Endpoints

### 5.1 Itinerary generation

#### `POST /v1/itineraries/generate`

```json
Request:
{
  "trip_id": "TRP_01F3A9B2C7D4E5F6",
  "brief": "10-day honeymoon in early October. Sea views, fine dining, privacy...",
  "preferences": {
    "budget": 85000,
    "currency": "GHS",
    "traveler_count": 2,
    "dates": { "start_date": "2026-10-04", "end_date": "2026-10-14" },
    "dietary": ["pescatarian"],
    "max_stops": 1,
    "interests": ["sea views", "fine dining", "privacy", "boat experiences"],
    "travel_style": "balanced",
    "desires": ["celebrating an anniversary", "wants a sunset photo op"]
  },
  "answered_questions": [
    { "question": "Beachfront resort or cliffside caldera view?",
      "answer": "Cliffside caldera view (e.g. Santorini)" }
  ],
  "flight_candidates": [
    {
      "id": "flt_9a2c",
      "price": 620,
      "currency": "USD",
      "total_duration": 780,
      "stops": 1,
      "airline_logo": "https://.../turkish.png",
      "legs": [
        {
          "airline": "Turkish Airlines",
          "airline_logo": "https://.../turkish.png",
          "flight_number": "TK 624",
          "departure_airport": "ACC",
          "departure_airport_name": "Kotoka Intl",
          "departure_time": "2026-10-04T22:10:00",
          "arrival_airport": "IST",
          "arrival_airport_name": "Istanbul Airport",
          "arrival_time": "2026-10-05T06:40:00",
          "duration": 420,
          "airplane": "Airbus A330"
        }
      ]
    }
  ],
  "stay_candidates": [
    {
      "property_token": "ChIJ...",
      "name": "Caldera View Suites",
      "link": "https://...",
      "hotel_class": "5-star",
      "overall_rating": 4.8,
      "rate_per_night": 340,
      "total_rate": 3400,
      "currency": "USD",
      "thumbnail": "https://...",
      "gps_coordinates": { "latitude": 36.4167, "longitude": 25.4325 }
    }
  ],
  "num_options": 3,
  "callback_url": "https://api.meridian.app/internal/ai/callbacks/generation"
}

Response 202:
{ "generation_id": "gen_01HABCDE", "status": "in_progress", "estimated_seconds": 25 }
```

`flight_candidates`/`stay_candidates` are exactly what Laravel already gets back from
`GET /itinerary/:id/flights/search` and `.../accommodation/search` (`FlightSearchResult[]` /
`HotelSearchResult[]`, unwrapped from their response envelopes) — no reshaping needed on the
Laravel side before forwarding them here.

#### `GET /v1/itineraries/generate/{generation_id}`

```json
Response (in_progress):
{
  "generation_id": "gen_01HABCDE",
  "status": "in_progress",
  "progress": [
    { "label": "Scanned 6 flight routes", "done": true },
    { "label": "Matched 9 sea-view stays", "done": true },
    { "label": "Curating experiences", "done": false }
  ]
}

Response (completed):
{
  "generation_id": "gen_01HABCDE",
  "status": "completed",
  "options": [
    {
      "letter": "A",
      "name": "Santorini + Amalfi",
      "is_ai_pick": true,
      "summary": "10 nights combining Santorini caldera views with Amalfi Coast luxury.",
      "total": 84500,
      "currency": "GHS",
      "days": [
        {
          "day_number": 1,
          "date": "2026-10-04",
          "title": "Arrival in Santorini",
          "blocks": [
            { "kind": "flight", "title": "ACC to JTR via IST", "price": 18200, "currency": "GHS", "source_ref": "flt_9a2c" },
            { "kind": "stay", "title": "Caldera View Suite, 3 nights", "price": 21000, "currency": "GHS", "source_ref": "ChIJ..." }
          ],
          "suggestion": { "text": "A couples sunset spa - fits the honeymoon profile.", "kind": "activity" }
        }
      ],
      "costs": [ { "category": "flight", "amount": 28400 }, { "category": "stay", "amount": 42000 } ],
      "meta": { "flight_count": 2, "stay_count": 2, "activity_count": 5, "dietary_option_count": 4 }
    }
  ]
}

Response (failed):
{ "generation_id": "gen_01HABCDE", "status": "failed", "error": { "code": "INVENTORY_EMPTY", "message": "No stays matched budget + dates." } }
```

`source_ref` echoes back the candidate's own id (`flight_candidates[].id` /
`stay_candidates[].property_token`) so Laravel can create the `ItineraryFlight`/
`ItineraryAccommodation` row from the real candidate it already has in hand, instead of
re-parsing the LLM's text for a price.

#### `POST /v1/itineraries/{generation_id}/regenerate`

Same body as `generate`, plus:
```json
{
  "scope": "all",             // "all" | "option" | "day"
  "option_letter": "A",        // required if scope != "all"
  "day_number": 3,             // required if scope == "day"
  "preset": null               // "cheaper" | "more_luxury" | "less_travel" | null
}
```
Response: identical shape to `generate` (`202` + new `generation_id`).

#### `POST /v1/itineraries/questions`

```json
Request:
{ "trip_id": "TRP_01F3A9B2C7D4E5F6", "brief": "10-day honeymoon...", "preferences": { "budget": 85000, "currency": "GHS" } }

Response 200:
{
  "questions": [
    {
      "id": "q_01",
      "text": "You mentioned 'sea views' - are you thinking:",
      "type": "single_choice",
      "options": ["A beachfront resort", "A cliffside caldera view (e.g. Santorini)", "Any sea view is fine"]
    }
  ]
}
```
Synchronous — one short LLM call, no search/ranking involved.

#### `POST /v1/itineraries/days/suggest`

```json
Request:
{
  "itinerary_day_id": "IDY_01F3A9B2C7D4E5F6",
  "existing_blocks": [ { "kind": "stay", "title": "Caldera View Suite" } ],
  "preferences": { "interests": ["sea views", "fine dining", "privacy"] },
  "free_time_window": { "start": "14:00", "end": "18:00" }
}

Response 200:
{
  "suggestion": "A couples sunset spa at the hotel - fits the honeymoon profile and the free afternoon.",
  "activity": { "name": "Couples sunset spa", "kind": "activity", "price": 1800, "currency": "GHS", "duration_minutes": 120 }
}
```
Synchronous.

### 5.2 Flight & accommodation ranking

#### `POST /v1/flights/rank`

```json
Request:
{
  "trip_id": "TRP_01F3A9B2C7D4E5F6",
  "preferences": { "max_stops": 1, "budget": 85000, "currency": "GHS", "traveler_count": 2 },
  "results": [ /* FlightSearchResult[] straight from GET /itinerary/:id/flights/search */ ]
}

Response 200:
{
  "ranked": [
    {
      "id": "flt_9a2c",
      "fit_score": 0.94,
      "is_ai_pick": true,
      "reasons": ["1 stop - matches your max 1 stop preference", "Arrives before 9am, no lost day"]
    }
  ]
}
```

#### `POST /v1/stays/rank`

```json
Request:
{
  "trip_id": "TRP_01F3A9B2C7D4E5F6",
  "preferences": { "interests": ["sea views", "privacy"], "budget": 85000, "currency": "GHS" },
  "results": [ /* HotelSearchResult[] straight from GET /itinerary/:id/accommodation/search */ ]
}

Response 200:
{
  "ranked": [
    {
      "property_token": "ChIJ...",
      "fit_score": 0.91,
      "is_ai_pick": true,
      "reasons": ["5-star with caldera-facing rooms", "Within budget at 2 pax / 10 nights"]
    }
  ]
}
```

Both are synchronous — ranking an already-fetched list is a single short LLM call, not a
search. Laravel merges `ranked[]` back onto the `FlightSearchResult`/`HotelSearchResult` list
it already has by matching `id`/`property_token`.

### 5.3 Preference & desire extraction

#### `POST /v1/preferences/extract`

```json
Request:
{
  "trip_id": "TRP_01F3A9B2C7D4E5F6",
  "customer_id": "CUS_01F3A9B2C7D4E5F6",
  "sources": [
    { "type": "trip_brief", "text": "10-day honeymoon, sea views, fine dining, privacy..." },
    { "type": "call_transcript", "call_id": "CALL_01...", "text": "..." },
    {
      "type": "conversation_messages",
      "conversation_id": "CONV_01...",
      "messages": [
        { "from": "traveler", "text": "Actually we'd love a private pool if it's not too much extra.", "timestamp": "2026-07-05T09:00:00Z" }
      ]
    }
  ],
  "existing_profile": { "dietary": ["pescatarian"] }
}

Response 200:
{
  "profile": {
    "dietary": ["pescatarian"],
    "allergies": ["shellfish"],
    "preferred_airlines": ["Turkish Airlines", "Emirates"],
    "travel_style": "balanced",
    "deal_breakers": ["long layovers", "shared transfers"],
    "luxury_preferences": ["private pool", "butler service", "Michelin dining"],
    "desires": ["celebrating an anniversary", "wants a sunset photo op"]
  },
  "new_since_existing_profile": ["luxury_preferences: private pool"]
}
```

`sources` is a list, not a single blob, specifically so this one endpoint covers the brief,
a call transcript, and raw conversation history (once `Conversation`/`Message` exist — see
§6) without changing shape later; `messages` inside a `conversation_messages` source lets the
model weigh recency/attribution per message rather than one flattened string. Passing
`existing_profile` makes each call a **merge**, not an overwrite, so preference extraction can
run every time new signal arrives (new message, new call) instead of only once per trip.
Synchronous.

### 5.4 Call transcription & summarization (audio + video)

#### `POST /v1/calls/transcribe`

```json
Request:
{
  "call_id": "CALL_01F3A9B2C7D4E5F6",
  "recording_url": "https://storage.meridian.app/private/calls/CALL_01.../recording.mp4",
  "media_type": "video",              // "video" | "audio" - "video" extracts the audio track first
  "callback_url": "https://api.meridian.app/internal/ai/callbacks/transcription"
}

Response 202:
{ "transcription_id": "trx_01HABCDE", "status": "in_progress" }
```

```json
Callback / GET /v1/calls/transcribe/{transcription_id} (completed):
{
  "transcription_id": "trx_01HABCDE",
  "status": "completed",
  "call_id": "CALL_01F3A9B2C7D4E5F6",
  "transcript": "...",
  "segments": [
    { "speaker": "Agent", "text": "So tell me about the trip you're picturing.", "start": 4.2, "end": 7.8 },
    { "speaker": "Traveler 1", "text": "It's our honeymoon, so somewhere with sea views...", "start": 8.1, "end": 14.6 }
  ],
  "duration_seconds": 1680,
  "speaker_count": 3
}
```

Accepts a direct file/URL upload today (matches how `Call.transcript`/recording would be
attached now); if Meridian later adds a bot that joins Google Meet directly (per the product
spec), that bot is what produces the `recording_url` this endpoint consumes — dispatching and
joining the meeting is an integration concern for `Meridian-Backend`, not something
`meridian-ai` does itself (see §8).

#### `POST /v1/calls/summarize`

```json
Request:
{
  "call_id": "CALL_01F3A9B2C7D4E5F6",
  "transcript": "...",              // or { "transcription_id": "trx_01HABCDE" } to chain directly
  "trip_context": { "brief": "...", "preferences": { } },   // optional, improves relevance
  "callback_url": "https://api.meridian.app/internal/ai/callbacks/summarization"
}

Response 202:
{ "summarization_id": "sum_01HABCDE", "status": "in_progress" }
```

```json
Callback / GET /v1/calls/summarize/{summarization_id} (completed):
{
  "summarization_id": "sum_01HABCDE",
  "status": "completed",
  "call_id": "CALL_01F3A9B2C7D4E5F6",
  "summary": "Newlywed couple planning a 10-day honeymoon...",
  "action_points": [
    { "text": "Send 3 itinerary options by 15 Jun", "assignable": true },
    { "text": "Prioritise sea-view suites with private terraces", "assignable": false }
  ],
  "decisions": ["Region: Mediterranean", "Max 1 stop on flights", "10 nights", "Budget ~ GHS 85k"]
}
```
`action_points` map 1:1 onto new `CallActionItem` rows (`description` = `text`, `status` =
`pending`). Laravel should immediately fan the resulting `transcript` out to
`POST /v1/preferences/extract` (as a `call_transcript` source) so the call's content updates
the traveler's profile, not just the call record.

### 5.5 Draft replies

#### `POST /v1/conversations/draft-reply`

```json
Request:
{
  "trip_id": "TRP_01F3A9B2C7D4E5F6",
  "recent_messages": [
    { "from": "traveler", "text": "Does option A include breakfast?", "timestamp": "2026-07-05T09:00:00Z" }
  ],
  "context_message": "Traveler is asking about breakfast inclusion",
  "trip_context": {
    "brief": "10-day honeymoon...",
    "preferences": { "dietary": ["pescatarian"] },
    "selected_option": { "letter": "A", "name": "Santorini + Amalfi" },
    "call_summaries": ["Newlywed couple planning a 10-day honeymoon..."]
  }
}

Response 200:
{
  "draft": "Great question! Option A includes daily breakfast at the Caldera View Suite...",
  "confidence": 0.92,
  "context_used": ["trip: Santorini + Amalfi", "preferences: pescatarian"]
}
```
`recent_messages` is optional and `context_message` is the fallback specifically because
`Conversation`/`Message` storage doesn't exist yet (§6) — once it does, Laravel should always
send real history instead of the single-line fallback. Synchronous.

### 5.6 Job status (generic)

#### `GET /v1/jobs/{job_id}`

Fallback for any async job id (`gen_...`, `trx_...`, `sum_...`) when Laravel needs to poll
instead of relying on the callback (e.g. after a restart mid-job).

---

## 6. What Meridian-Backend needs to add

`meridian-ai` only works once Laravel exposes matching public endpoints and stores results.

| Laravel route (new/changed) | Calls into meridian-ai | Persists to |
|---|---|---|
| `POST /trips/{trip}/generate-itinerary` (replace stub) | `POST /v1/itineraries/generate` | `Itinerary`, `ItineraryDay`, `ItineraryFlight`, `ItineraryAccommodation` |
| `GET /ai/generations/{generationId}` | `GET /v1/itineraries/generate/{id}` | proxy, or cache in new `ai_generations` table |
| `POST /trips/{trip}/ai/questions` | `POST /v1/itineraries/questions` | — (ephemeral) |
| `GET /itinerary/{id}/flights/search` (augment) | `POST /v1/flights/rank` after the existing SerpApi call | — (ephemeral, merged into response) |
| `GET /itinerary/{id}/accommodation/search` (augment) | `POST /v1/stays/rank` after the existing SerpApi call | — (ephemeral, merged into response) |
| `POST /trips/{trip}/ai/preferences` | `POST /v1/preferences/extract` | new `trip_preferences` table or JSON column on `trips`/`customers` |
| `POST /calls/{call}/transcribe` | `POST /v1/calls/transcribe` | `Call.transcript` |
| `POST /calls/{call}/summarize` | `POST /v1/calls/summarize` | new `Call.summary`/`Call.decisions` columns + `CallActionItem` rows |
| `POST /itinerary/days/{itineraryDay}/suggestion` | `POST /v1/itineraries/days/suggest` | `itinerary_day_destinations` (new `is_ai_suggested` flag) |
| `POST /conversations/{conversation}/draft` | `POST /v1/conversations/draft-reply` | — (ephemeral; blocked, see below) |

Schema additions needed in `Meridian-Backend` (not in `meridian-ai`, which is stateless):
- `calls`: add `summary` (text, nullable), `decisions` (json, nullable).
- `itinerary`: add `generation_id` (string, nullable, indexed) and `is_ai_pick` (boolean,
  default false).
- `itinerary_day_destinations`: add `is_ai_suggested` (boolean, default false).
- New `trip_preferences` table (or JSON column on `trips`/`customers`) to persist extraction
  output — this is what makes preference extraction *durable* instead of recomputed from
  scratch on every generation call.
- New `ai_generations` table if Laravel wants to cache job status instead of proxying every
  poll through to `meridian-ai` — `{ generation_id, trip_id, status, payload (json),
  created_at, updated_at }`.
- **`Conversation`/`Message` models — the actual blocker.** Per `requests.md`, Messages is
  100% mock today: no conversations list, no message history, no send endpoint. This directly
  blocks two of the four pillars this document is about — draft replies (§5.5) can't use real
  history, and preference extraction (§5.3) can't mine conversation history because there
  isn't any to mine. Both endpoints are already shaped to accept conversation data as soon as
  it exists (`messages`/`recent_messages` arrays), so no `meridian-ai` API change is needed —
  only the Laravel-side storage and channel ingestion.
- An **activities source** (own curated table, or a provider integration) — `ctx.getActivities()`
  is currently frontend-only mock data; day-suggestions and full itinerary generation can't
  include real activities without one.

---

## 7. Config / environment

```
# meridian-ai
LLM_PROVIDER=anthropic                 # or openai
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
TRANSCRIPTION_PROVIDER=deepgram        # or whisper - both accept audio; video is demuxed first
FFMPEG_PATH=/usr/bin/ffmpeg            # used to extract the audio track from video recordings
DEEPGRAM_API_KEY=
MERIDIAN_AI_SERVICE_TOKEN=             # HMAC key Laravel signs each outbound request with (§4.1)
MERIDIAN_AI_WEBHOOK_SECRET=            # HMAC key meridian-ai signs each callback with (§4.2)

# Meridian-Backend (new entries in config/services.php, alongside the existing 'serpapi' block)
'meridian_ai' => [
    'base_url' => env('MERIDIAN_AI_BASE_URL'),
    'service_token' => env('MERIDIAN_AI_SERVICE_TOKEN'),
    'webhook_secret' => env('MERIDIAN_AI_WEBHOOK_SECRET'),
],
```

---

## 8. Non-goals

- **Joining/recording calls.** `meridian-ai` transcribes a recording it's given; dispatching a
  bot into a Google Meet/Zoom call and producing the recording file is a
  `Meridian-Backend`/integration concern.
- **Video vision analysis.** Video input is demuxed to audio for transcription only — no
  frame/screen-share/slide analysis. If that's wanted later, it's a distinct capability, not
  an extension of §2.4.
- **Channel integrations** (WhatsApp/Gmail/Instagram ingestion, webhook handling, contact
  matching) — `meridian-ai` only drafts a reply once given message context; it never sends
  one or ingests a channel itself.
- **Payment/invoicing, RBAC, seat/plan enforcement** — entirely Laravel.
- **Activity feed persistence** — Laravel logs an activity record when it gets a completed
  result back; `meridian-ai` has no activity log of its own.
- **Long-term conversation/vector storage** — if RAG-style retrieval over past conversations
  is needed later, it's an addition to `meridian-ai`'s embedding layer, not a reason for it to
  own a primary datastore of product entities.
