// agent

Messy Itinerary Travel Planner

by ozzo · Jul 12, 2026 Public

Choose how to run this agent

⚡ Local runs on your GPU. For usable speed it needs a WebGPU-capable browser — Chrome or Edge on a machine with a graphics card, or an Apple Silicon Mac. Without a supported GPU, pick OpenAI or Anthropic above instead. Check your machine
Try it now

Requires an API key and an AgentOp account.

92 downloads
0 forks
0.0 rating

Description

Drop your messy pile of booking PDFs and trip notes and get a clean day-by-day itinerary — plus warnings about gaps like a 14:00 landing vs a 16:00 check-in — all in your browser.

What this agent can do

Messy Itinerary Travel Planner is built from the Messy Itinerary Travel Planner template and loads pypdf in the browser. Runs fully on your own device: llama.cpp compiled to WebAssembly, GPU-accelerated through WebGPU, with no API key and no server. After the one-time model download it works offline. Can run on OpenAI models with your own API key, encrypted in your browser. Can run on Anthropic Claude models with your own API key, encrypted in your browser.

Runs in the browser

  • Local embeddings. Computes text embeddings inside the browser with Transformers.js, the basis for searching your own text by meaning rather than keywords.
  • Document Q&A (local RAG). Answers from documents you drop in: the text is chunked and indexed locally in the browser (IndexedDB), the relevant passages are retrieved for each question, and nothing is uploaded.

Source Code

agent.py
import json
import re

# How many document passages to retrieve per follow-up question.
RAG_TOP_K = [[[RAG_TOP_K|5]]]
# Cap on the pre-parsed schedule sheet handed to the model when building the
# itinerary (sized for the 8K-token default context of current local models).
MAX_FACTS_CHARS = [[[MAX_FACTS_CHARS|6000]]]

# One entry per ingested file: {"source": name, "facts": [schedule lines]}
_TRIP_FILES = []

# Lines that look schedule-relevant: times, dates, travel keywords…
_TIMEY_RE = re.compile(
    r"[0-9]{1,2}:[0-9]{2}"                                    # 14:05
    r"|\b[0-9]{1,2}\s?(?:am|pm)\b"                         # 2 pm
    r"|[0-9]{4}-[0-9]{2}-[0-9]{2}"                            # 2026-08-14
    r"|\b[0-9]{1,2}[/.][0-9]{1,2}[/.][0-9]{2,4}\b"          # 14/08/2026
    r"|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+[0-9]{1,2}\b"
    r"|\b[0-9]{1,2}(?:st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b"
    r"|\b(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)day\b"
    r"|check[- ]?in|check[- ]?out|departure|departs|arrival|arrives|boarding"
    r"|confirmation|booking|reservation|pick[- ]?up",
    re.I,
)
# …plus flight numbers (case-sensitive so plain words never match).
_FLIGHT_RE = re.compile(r"\b[A-Z]{2}[0-9]{2,4}\b")


def _extract_pdf_text(pdf_bytes):
    """(JS-callable) Extract plain text from a PDF's raw bytes via pypdf."""
    import io
    from pypdf import PdfReader

    reader = PdfReader(io.BytesIO(pdf_bytes))
    return "\n\n".join((page.extract_text() or "") for page in reader.pages)


def _scan_facts(text):
    """Pull the schedule-relevant lines out of one file's text."""
    facts = []
    for raw in text.splitlines():
        line = " ".join(raw.split())
        if len(line) < 4:
            continue
        if _TIMEY_RE.search(line) or _FLIGHT_RE.search(line):
            facts.append(line[:200])
    return facts


async def _ingest_trip_file(source, text):
    """(JS-callable) Index one booking/note file and scan its schedule lines.

    Returns JSON {"chunks": int, "facts": int} for the file list UI.
    """
    chunks = await agentop_rag.add_document(source, text)
    facts = _scan_facts(text)
    _TRIP_FILES.append({"source": source, "facts": facts})
    return json.dumps({"chunks": chunks, "facts": len(facts)})


def _facts_sheet():
    lines = []
    for f in _TRIP_FILES:
        lines.append(f"FILE: {f['source']}")
        lines.extend(f"  - {fact}" for fact in (f["facts"] or ["(no dated lines found)"]))
    return "\n".join(lines)[:MAX_FACTS_CHARS]


async def _build_itinerary():
    """(JS-callable) Draft the day-by-day itinerary + gap check via wllama."""
    if not _TRIP_FILES:
        return "Drop your booking PDFs and notes first, then build the itinerary."
    prompt = (
        "Below are schedule lines extracted from one traveller's booking files "
        "(flights, hotels, notes). Reconstruct the trip as a clean itinerary.\n"
        "Format exactly:\n"
        "## Itinerary\n"
        "One '### <day, date>' section per day, in order; under each, one "
        "chronological bullet per event: time — what and where, with flight "
        "numbers or booking references when given.\n\n"
        "## Gaps & watch-outs\n"
        "Bullets for logistical problems you can see: arrival vs check-in "
        "mismatches (e.g. landing at 14:00 but check-in from 16:00), "
        "connections under 90 minutes, nights with no lodging, missing "
        "transport between cities. Write 'None spotted.' if it all lines up.\n\n"
        "Use ONLY the lines below. If a date or time is ambiguous, say so "
        "rather than guessing.\n\n"
        f"SCHEDULE LINES:\n{_facts_sheet()}"
    )
    # TEMPLATE_SYSTEM_PROMPT only exists as a JS global; the query bridge falls
    # back to it when custom_prompt is empty (same idiom as the base template).
    return await process_user_query_wllama(
        prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )


async def process_user_query(query):
    """Follow-up Q&A grounded in the uploaded trip documents (RAG).

    Overrides the default router so retrieval is deterministic (small local
    models are unreliable at deciding to call a search tool themselves).
    """
    if not _TRIP_FILES:
        return (
            "I don't have any trip files yet — drop your flight PDFs, hotel "
            "emails, and notes above and I'll piece the trip together."
        )
    hits = await agentop_rag.search(query, RAG_TOP_K)
    excerpts = "\n\n".join(
        f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
    ) or "(nothing relevant found)"
    grounded_prompt = (
        "Answer the traveller's QUESTION using ONLY the TRIP DOCUMENT "
        "EXCERPTS below. Use exact dates, times, flight numbers, and booking "
        "references from the excerpts, and cite them like [1]. If the answer "
        "is not in the excerpts, say you could not find it in the trip files.\n\n"
        f"TRIP DOCUMENT EXCERPTS:\n{excerpts}\n\nQUESTION: {query}"
    )
    return await process_user_query_wllama(
        grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )

More by ozzo

Receipt & Invoice Extractor

Based on the Receipt & Invoice Extractor template.

New Hire Handbook Q&A

Based on the New Hire Handbook Q&A template.

Contract Plain-Language Explainer

Upload a contract and get it explained in plain language — obligations, fees, deadlines, exit claus…

WhatsApp Sales Copilot

Turn a raw WhatsApp chat export into a mini CRM — typed quotes, bookings, payments and boarding pas…

Private Quote & Material Estimator

Drop competing contractor quotes and compare them side by side — totals, inclusions, exclusions — t…

Semantic Search

Search your own notes or documents by meaning, not keywords — instant results with no LLM download.